mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-02-01 16:08:04 +00:00
wip
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -10,6 +10,6 @@
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[swift]": {
|
||||
"editor.defaultFormatter": "sswg.swift-lang"
|
||||
"editor.defaultFormatter": "swiftlang.swift-vscode"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,36 @@
|
||||
import ExpoModulesCore
|
||||
import AVFoundation
|
||||
import ExpoModulesCore
|
||||
|
||||
public class HlsDownloaderModule: Module {
|
||||
var activeDownloads: [Int: (task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any])] = [:]
|
||||
var activeDownloads:
|
||||
[Int: (task: AVAssetDownloadTask, delegate: HLSDownloadDelegate, metadata: [String: Any])] = [:]
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("HlsDownloader")
|
||||
|
||||
Events("onProgress", "onError", "onComplete")
|
||||
|
||||
Function("downloadHLSAsset") { (providedId: String, url: String, assetTitle: String, metadata: [String: Any]?) -> Void in
|
||||
print("Starting download - ID: \(providedId), URL: \(url), Title: \(assetTitle), Metadata: \(String(describing: metadata))")
|
||||
Function("downloadHLSAsset") {
|
||||
(providedId: String, url: String, assetTitle: String, metadata: [String: Any]?) -> Void in
|
||||
print(
|
||||
"Starting download - ID: \(providedId), URL: \(url), Title: \(assetTitle), Metadata: \(String(describing: metadata))"
|
||||
)
|
||||
|
||||
guard let assetURL = URL(string: url) else {
|
||||
self.sendEvent("onError", [
|
||||
"id": providedId,
|
||||
"error": "Invalid URL",
|
||||
"state": "FAILED",
|
||||
"metadata": metadata ?? [:]
|
||||
])
|
||||
self.sendEvent(
|
||||
"onError",
|
||||
[
|
||||
"id": providedId,
|
||||
"error": "Invalid URL",
|
||||
"state": "FAILED",
|
||||
"metadata": metadata ?? [:],
|
||||
])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let asset = AVURLAsset(url: assetURL)
|
||||
let configuration = URLSessionConfiguration.background(withIdentifier: "com.example.hlsdownload")
|
||||
let configuration = URLSessionConfiguration.background(
|
||||
withIdentifier: "com.example.hlsdownload")
|
||||
let delegate = HLSDownloadDelegate(module: self)
|
||||
delegate.providedId = providedId
|
||||
let downloadSession = AVAssetDownloadURLSession(
|
||||
@@ -32,29 +39,35 @@ public class HlsDownloaderModule: Module {
|
||||
delegateQueue: OperationQueue.main
|
||||
)
|
||||
|
||||
guard let task = downloadSession.makeAssetDownloadTask(
|
||||
asset: asset,
|
||||
assetTitle: assetTitle,
|
||||
assetArtworkData: nil,
|
||||
options: nil
|
||||
) else {
|
||||
self.sendEvent("onError", [
|
||||
guard
|
||||
let task = downloadSession.makeAssetDownloadTask(
|
||||
asset: asset,
|
||||
assetTitle: assetTitle,
|
||||
assetArtworkData: nil,
|
||||
options: nil
|
||||
)
|
||||
else {
|
||||
self.sendEvent(
|
||||
"onError",
|
||||
[
|
||||
"id": providedId,
|
||||
"error": "Failed to create download task",
|
||||
"state": "FAILED",
|
||||
"metadata": metadata ?? [:]
|
||||
])
|
||||
"error": "Failed to create download task",
|
||||
"state": "FAILED",
|
||||
"metadata": metadata ?? [:],
|
||||
])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
delegate.taskIdentifier = task.taskIdentifier
|
||||
self.activeDownloads[task.taskIdentifier] = (task, delegate, metadata ?? [:])
|
||||
self.sendEvent("onProgress", [
|
||||
"id": providedId,
|
||||
"progress": 0.0,
|
||||
"state": "PENDING",
|
||||
"metadata": metadata ?? [:]
|
||||
])
|
||||
self.sendEvent(
|
||||
"onProgress",
|
||||
[
|
||||
"id": providedId,
|
||||
"progress": 0.0,
|
||||
"state": "PENDING",
|
||||
"metadata": metadata ?? [:],
|
||||
])
|
||||
|
||||
task.resume()
|
||||
print("Download task started with identifier: \(task.taskIdentifier)")
|
||||
@@ -76,20 +89,35 @@ public class HlsDownloaderModule: Module {
|
||||
"bytesDownloaded": downloaded,
|
||||
"bytesTotal": total,
|
||||
"state": self.mappedState(for: task),
|
||||
"metadata": metadata
|
||||
])
|
||||
}
|
||||
"metadata": metadata,
|
||||
])
|
||||
}
|
||||
return downloads
|
||||
}
|
||||
|
||||
OnStartObserving { }
|
||||
OnStopObserving { }
|
||||
}
|
||||
OnStartObserving {}
|
||||
OnStopObserving {}
|
||||
}
|
||||
|
||||
func removeDownload(with id: Int) {
|
||||
activeDownloads.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
func persistDownloadedFolder(originalLocation: URL, folderName: String) throws -> URL {
|
||||
let fileManager = FileManager.default
|
||||
let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let destinationDir = documents.appendingPathComponent("downloads", isDirectory: true)
|
||||
if !fileManager.fileExists(atPath: destinationDir.path) {
|
||||
try fileManager.createDirectory(at: destinationDir, withIntermediateDirectories: true)
|
||||
}
|
||||
let newLocation = destinationDir.appendingPathComponent(folderName, isDirectory: true)
|
||||
if fileManager.fileExists(atPath: newLocation.path) {
|
||||
try fileManager.removeItem(at: newLocation)
|
||||
}
|
||||
try fileManager.moveItem(at: originalLocation, to: newLocation)
|
||||
return newLocation
|
||||
}
|
||||
|
||||
func mappedState(for task: URLSessionTask, errorOccurred: Bool = false) -> String {
|
||||
if errorOccurred { return "FAILED" }
|
||||
switch task.state {
|
||||
@@ -112,51 +140,79 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate {
|
||||
self.module = module
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange, totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange) {
|
||||
let downloaded = loadedTimeRanges.reduce(0.0) { total, value in
|
||||
let timeRange = value.timeRangeValue
|
||||
return total + CMTimeGetSeconds(timeRange.duration)
|
||||
}
|
||||
func urlSession(
|
||||
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didLoad timeRange: CMTimeRange,
|
||||
totalTimeRangesLoaded loadedTimeRanges: [NSValue], timeRangeExpectedToLoad: CMTimeRange
|
||||
) {
|
||||
let downloaded = loadedTimeRanges.reduce(0.0) { total, value in
|
||||
let timeRange = value.timeRangeValue
|
||||
return total + CMTimeGetSeconds(timeRange.duration)
|
||||
}
|
||||
|
||||
let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
|
||||
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
|
||||
let total = CMTimeGetSeconds(timeRangeExpectedToLoad.duration)
|
||||
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
|
||||
|
||||
self.downloadedSeconds = downloaded
|
||||
self.totalSeconds = total
|
||||
self.downloadedSeconds = downloaded
|
||||
self.totalSeconds = total
|
||||
|
||||
let progress = total > 0 ? downloaded / total : 0
|
||||
let progress = total > 0 ? downloaded / total : 0
|
||||
|
||||
module?.sendEvent("onProgress", [
|
||||
"id": providedId,
|
||||
"progress": progress,
|
||||
"bytesDownloaded": downloaded,
|
||||
"bytesTotal": total,
|
||||
"state": progress >= 1.0 ? "DONE" : "DOWNLOADING",
|
||||
"metadata": metadata
|
||||
module?.sendEvent(
|
||||
"onProgress",
|
||||
[
|
||||
"id": providedId,
|
||||
"progress": progress,
|
||||
"bytesDownloaded": downloaded,
|
||||
"bytesTotal": total,
|
||||
"state": progress >= 1.0 ? "DONE" : "DOWNLOADING",
|
||||
"metadata": metadata,
|
||||
])
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession, assetDownloadTask: AVAssetDownloadTask,
|
||||
didFinishDownloadingTo location: URL
|
||||
) {
|
||||
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
|
||||
let folderName = providedId // using providedId as the folder name
|
||||
do {
|
||||
guard let module = module else { return }
|
||||
let newLocation = try module.persistDownloadedFolder(
|
||||
originalLocation: location, folderName: folderName)
|
||||
|
||||
module.sendEvent(
|
||||
"onComplete",
|
||||
[
|
||||
"id": providedId,
|
||||
"location": newLocation.absoluteString,
|
||||
"state": "DONE",
|
||||
"metadata": metadata,
|
||||
])
|
||||
} catch {
|
||||
module?.sendEvent(
|
||||
"onError",
|
||||
[
|
||||
"id": providedId,
|
||||
"error": error.localizedDescription,
|
||||
"state": "FAILED",
|
||||
"metadata": metadata,
|
||||
])
|
||||
}
|
||||
module?.removeDownload(with: assetDownloadTask.taskIdentifier)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
let metadata = module?.activeDownloads[assetDownloadTask.taskIdentifier]?.metadata ?? [:]
|
||||
module?.sendEvent("onComplete", [
|
||||
"id": providedId,
|
||||
"location": location.absoluteString,
|
||||
"state": "DONE",
|
||||
"metadata": metadata
|
||||
])
|
||||
module?.removeDownload(with: assetDownloadTask.taskIdentifier)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let error = error {
|
||||
let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:]
|
||||
module?.sendEvent("onError", [
|
||||
"id": providedId,
|
||||
"error": error.localizedDescription,
|
||||
"state": "FAILED",
|
||||
"metadata": metadata
|
||||
])
|
||||
module?.removeDownload(with: taskIdentifier)
|
||||
}
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let error = error {
|
||||
let metadata = module?.activeDownloads[task.taskIdentifier]?.metadata ?? [:]
|
||||
module?.sendEvent(
|
||||
"onError",
|
||||
[
|
||||
"id": providedId,
|
||||
"error": error.localizedDescription,
|
||||
"state": "FAILED",
|
||||
"metadata": metadata,
|
||||
])
|
||||
module?.removeDownload(with: taskIdentifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import ExpoModulesCore
|
||||
import VLCKit
|
||||
import UIKit
|
||||
|
||||
|
||||
public class VLCPlayerView: UIView {
|
||||
func setupView(parent: UIView) {
|
||||
self.backgroundColor = .black
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@/modules/hls-downloader";
|
||||
import * as FileSystem from "expo-file-system";
|
||||
import { DownloadInfo } from "@/modules/hls-downloader/src/HlsDownloader.types";
|
||||
import { processStream } from "@/utils/hls/av-file-parser";
|
||||
import { parseBootXML, processStream } from "@/utils/hls/av-file-parser";
|
||||
|
||||
type DownloadContextType = {
|
||||
downloads: Record<string, DownloadInfo>;
|
||||
@@ -50,6 +50,22 @@ const persistDownloadedFile = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the boot.xml file and parses it to get the streams
|
||||
*/
|
||||
const getBootStreams = async (path: string) => {
|
||||
const b = `${path}/boot.xml`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(b);
|
||||
if (fileInfo.exists) {
|
||||
const boot = await FileSystem.readAsStringAsync(b, {
|
||||
encoding: FileSystem.EncodingType.UTF8,
|
||||
});
|
||||
return parseBootXML(boot);
|
||||
} else {
|
||||
console.log(`No boot.xml found in ${path}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const NativeDownloadProvider: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
@@ -88,6 +104,11 @@ export const NativeDownloadProvider: React.FC<{
|
||||
);
|
||||
|
||||
setDownloads({ ...hlsDownloadStates, ...regularDownloadStates });
|
||||
|
||||
console.log("Existing downloads:", {
|
||||
...hlsDownloadStates,
|
||||
...regularDownloadStates,
|
||||
});
|
||||
};
|
||||
|
||||
initializeDownloads();
|
||||
@@ -106,43 +127,30 @@ export const NativeDownloadProvider: React.FC<{
|
||||
});
|
||||
|
||||
const completeListener = addCompleteListener(async (payload) => {
|
||||
if (typeof payload === "string") {
|
||||
// Handle string ID (old HLS downloads)
|
||||
setDownloads((prev) => {
|
||||
const newDownloads = { ...prev };
|
||||
delete newDownloads[payload];
|
||||
return newDownloads;
|
||||
});
|
||||
} else {
|
||||
// Handle OnCompleteEventPayload (with location)
|
||||
console.log("Download complete event received:", payload);
|
||||
console.log("Original download location:", payload.location);
|
||||
console.log("Download complete to:", payload.location);
|
||||
|
||||
try {
|
||||
if (payload?.metadata?.Name) {
|
||||
const newLocation = await persistDownloadedFile(
|
||||
payload.location,
|
||||
payload.metadata.Name
|
||||
);
|
||||
console.log("File successfully persisted to:", newLocation);
|
||||
// try {
|
||||
// if (payload?.id) {
|
||||
// const newLocation = await persistDownloadedFile(
|
||||
// payload.location,
|
||||
// payload.id
|
||||
// );
|
||||
// console.log("File successfully persisted to:", newLocation);
|
||||
// } else {
|
||||
// console.log(
|
||||
// "No filename in metadata, using original location",
|
||||
// payload
|
||||
// );
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error("Failed to persist file:", error);
|
||||
// }
|
||||
|
||||
processStream(newLocation);
|
||||
} else {
|
||||
console.log(
|
||||
"No filename in metadata, using original location",
|
||||
payload
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to persist file:", error);
|
||||
}
|
||||
|
||||
setDownloads((prev) => {
|
||||
const newDownloads = { ...prev };
|
||||
delete newDownloads[payload.id];
|
||||
return newDownloads;
|
||||
});
|
||||
}
|
||||
setDownloads((prev) => {
|
||||
const newDownloads = { ...prev };
|
||||
delete newDownloads[payload.id];
|
||||
return newDownloads;
|
||||
});
|
||||
});
|
||||
|
||||
const errorListener = addErrorListener((error) => {
|
||||
|
||||
@@ -19,7 +19,31 @@ export interface StreamInfo {
|
||||
segPaths: string[];
|
||||
}
|
||||
|
||||
// 1. Parse boot.xml to extract stream definitions.
|
||||
// Rewrites m3u8 files with local paths
|
||||
export async function rewriteM3U8Files(path: string) {
|
||||
const streams = await getBootStreams(path);
|
||||
if (!streams) return;
|
||||
for (const stream of streams) {
|
||||
const streamDirectory = `${path}/${stream.id}`;
|
||||
processStream(streamDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
// Opens the boot.xml file and parses it to get the streams
|
||||
const getBootStreams = async (path: string) => {
|
||||
const b = `${path}/boot.xml`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(b);
|
||||
if (fileInfo.exists) {
|
||||
const boot = await FileSystem.readAsStringAsync(b, {
|
||||
encoding: FileSystem.EncodingType.UTF8,
|
||||
});
|
||||
return parseBootXML(boot);
|
||||
} else {
|
||||
console.log(`No boot.xml found in ${path}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse boot.xml to extract stream definitions.
|
||||
export function parseBootXML(xml: string): StreamDefinition[] {
|
||||
const json = parser.parse(xml);
|
||||
const pkg = json.HLSMoviePackage;
|
||||
@@ -33,7 +57,7 @@ export function parseBootXML(xml: string): StreamDefinition[] {
|
||||
}));
|
||||
}
|
||||
|
||||
// 2. Parse StreamInfoBoot.xml to extract the local m3u8 path and segment paths.
|
||||
// Parse StreamInfoBoot.xml to extract the local m3u8 path and segment paths.
|
||||
export function parseStreamInfo(xml: string): StreamInfo {
|
||||
const json = parser.parse(xml);
|
||||
const streamInfo = json.StreamInfo;
|
||||
@@ -56,7 +80,7 @@ export function parseStreamInfo(xml: string): StreamInfo {
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Update the m3u8 playlist content by replacing remote segment URLs with local paths.
|
||||
// Update the m3u8 playlist content by replacing remote segment URLs with local paths.
|
||||
export function updatePlaylistWithLocalSegments(
|
||||
playlistContent: string,
|
||||
segPaths: string[]
|
||||
@@ -76,29 +100,45 @@ export function updatePlaylistWithLocalSegments(
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// Example: Process a stream directory using Expo FileSystem.
|
||||
// Process a stream directory using Expo FileSystem.
|
||||
export async function processStream(streamDir: string): Promise<void> {
|
||||
console.log(`Processing stream directory: ${streamDir}`);
|
||||
|
||||
// Read StreamInfoBoot.xml from the stream directory.
|
||||
const streamInfoPath = `${streamDir}/StreamInfoBoot.xml`;
|
||||
console.log(`Reading StreamInfoBoot.xml from: ${streamInfoPath}`);
|
||||
try {
|
||||
const streamInfoXML = await FileSystem.readAsStringAsync(streamInfoPath, {
|
||||
encoding: FileSystem.EncodingType.UTF8,
|
||||
});
|
||||
console.log('Successfully read StreamInfoBoot.xml');
|
||||
const streamInfo = parseStreamInfo(streamInfoXML);
|
||||
console.log(`Parsed stream info: ${JSON.stringify(streamInfo, null, 2)}`);
|
||||
|
||||
// Read the local m3u8 file.
|
||||
const playlistPath = `${streamDir}/${streamInfo.localM3U8}`;
|
||||
console.log(`Reading m3u8 playlist from: ${playlistPath}`);
|
||||
const playlistContent = await FileSystem.readAsStringAsync(playlistPath, {
|
||||
encoding: FileSystem.EncodingType.UTF8,
|
||||
});
|
||||
console.log('Successfully read m3u8 playlist');
|
||||
|
||||
// Replace remote segment URIs with local segment paths.
|
||||
console.log('Updating playlist with local segment paths');
|
||||
const updatedPlaylist = updatePlaylistWithLocalSegments(
|
||||
playlistContent,
|
||||
streamInfo.segPaths
|
||||
);
|
||||
|
||||
// Save the updated playlist back to disk.
|
||||
console.log(`Writing updated playlist back to: ${playlistPath}`);
|
||||
await FileSystem.writeAsStringAsync(playlistPath, updatedPlaylist, {
|
||||
encoding: FileSystem.EncodingType.UTF8,
|
||||
});
|
||||
console.log('Successfully wrote updated playlist');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error processing stream directory ${streamDir}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user