From fdbe4a024b5aab29f91c0eca790fd943178c8a3e Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Mon, 17 Feb 2025 09:05:16 +0100 Subject: [PATCH] feat: move rewrite logic into swift --- modules/hls-downloader/ios/HLSRewrite.swift | 209 ++++++++++++++++++ .../hls-downloader/ios/HlsDownloader.podspec | 1 + .../ios/HlsDownloaderModule.swift | 68 +++--- providers/NativeDownloadProvider.tsx | 6 +- 4 files changed, 254 insertions(+), 30 deletions(-) create mode 100644 modules/hls-downloader/ios/HLSRewrite.swift diff --git a/modules/hls-downloader/ios/HLSRewrite.swift b/modules/hls-downloader/ios/HLSRewrite.swift new file mode 100644 index 00000000..c7e3169d --- /dev/null +++ b/modules/hls-downloader/ios/HLSRewrite.swift @@ -0,0 +1,209 @@ +// +// HLSRewrite.swift +// + +import Foundation +import XMLCoder + +// MARK: - Models + +public struct Boot: Codable { + public let Version: String + public let HLSMoviePackageType: String + public let Streams: Streams + public let MasterPlaylist: MasterPlaylist + public let DataItems: DataItems + + public struct Streams: Codable { + public let Stream: [Stream] + } + public struct Stream: Codable { + public let ID: String + public let NetworkURL: String + public let Path: String + public let Complete: String + } + public struct MasterPlaylist: Codable { + public let NetworkURL: String + } + public struct DataItems: Codable { + public let Directory: String + public let DataItem: DataItem + } + public struct DataItem: Codable { + public let ID: String + public let Category: String + public let Name: String + public let DescriptorPath: String + public let DataPath: String + public let Role: String + } +} + +public struct StreamInfo: Codable { + public let Version: String + public let Complete: String + public let PeakBandwidth: Int + public let Compressable: String + public let MediaPlaylist: MediaPlaylist + public let StreamType: String + public let MediaSegments: MediaSegments + public let EvictionPolicy: String + public let MediaBytesStored: Int + + private enum CodingKeys: String, CodingKey { + case Version + case Complete + case PeakBandwidth + case Compressable + case MediaPlaylist + case StreamType = "Type" // Map "Type" in XML to "StreamType" in Swift + case MediaSegments + case EvictionPolicy + case MediaBytesStored + } + + public struct MediaPlaylist: Codable { + public let NetworkURL: String + public let PathToLocalCopy: String? + } + public struct MediaSegments: Codable { + public let SEG: [SEG] + } +} + +public struct SEG: Codable { + public let Dur: Double + public let Len: Double + public let Off: Double + public let PATH: String + public let SeqNum: Int + public let Tim: Double + public let URL: String +} + +// MARK: - XML Parsing Functions + +public func parseBootXML(_ xml: String) throws -> Boot { + let data = Data(xml.utf8) + let decoder = XMLDecoder() + decoder.shouldProcessNamespaces = false + let boot = try decoder.decode(Boot.self, from: data) + print(boot.Streams) + return boot +} + +public func parseStreamInfoXml(_ xml: String) throws -> StreamInfo { + let data = Data(xml.utf8) + let decoder = XMLDecoder() + decoder.shouldProcessNamespaces = false + return try decoder.decode(StreamInfo.self, from: data) +} + +// MARK: - HLS Rewrite Functions + +/// Entry point for rewriting m3u8 playlists with local paths. +public func rewriteM3U8Files(baseDir: String) async throws { + guard let bootData = await loadBootData(baseDir: baseDir) else { return } + let localPlaylistPaths = try await processAllStreams(baseDir: baseDir, bootData: bootData) + let masterPath = URL(fileURLWithPath: baseDir) + .appendingPathComponent("Data") + .appendingPathComponent(bootData.DataItems.DataItem.DataPath) + .path + try await updateMasterPlaylist(masterPath: masterPath, localPlaylistPaths: localPlaylistPaths) +} + +/// Loads and parses boot.xml from the base directory. +private func loadBootData(baseDir: String) async -> Boot? { + let bootPath = URL(fileURLWithPath: baseDir).appendingPathComponent("boot.xml") + do { + guard FileManager.default.fileExists(atPath: bootPath.path) else { return nil } + let bootXML = try String(contentsOf: bootPath, encoding: .utf8) + return try parseBootXML(bootXML) + } catch { + print("Failed to load boot.xml from \(baseDir): \(error)") + return nil + } +} + +/// Processes all stream directories from boot data. +private func processAllStreams(baseDir: String, bootData: Boot) async throws -> [String] { + var localPaths = [String]() + for stream in bootData.Streams.Stream { + let streamDir = URL(fileURLWithPath: baseDir).appendingPathComponent(stream.ID) + do { + if let streamInfo = try await processStream(streamDir: streamDir.path), + let localCopyPath = streamInfo.MediaPlaylist.PathToLocalCopy, + !localCopyPath.isEmpty + { + let fullPath = URL(fileURLWithPath: streamDir.path) + .appendingPathComponent(localCopyPath) + .absoluteString // Use absoluteString instead of path + localPaths.append(fullPath) + } + } catch { + print("Skipping stream \(stream.ID) due to error: \(error)") + } + } + return localPaths +} + +/// Updates the master playlist by replacing remote URIs with local playlist paths. +private func updateMasterPlaylist(masterPath: String, localPlaylistPaths: [String]) async throws { + let masterURL = URL(fileURLWithPath: masterPath) + do { + let masterContent = try String(contentsOf: masterURL, encoding: .utf8) + let updatedContent = updatePlaylistWithLocalSegments( + content: masterContent, localPaths: localPlaylistPaths) + try updatedContent.write(to: masterURL, atomically: true, encoding: .utf8) + } catch { + print("Error updating master playlist at \(masterPath): \(error)") + throw error + } +} + +/// Updates an m3u8 playlist by replacing segment URIs with provided local paths. +public func updatePlaylistWithLocalSegments(content: String, localPaths: [String]) -> String { + var lines = content.components(separatedBy: "\n") + var index = 0 + for i in 0.. StreamInfo? { + let streamInfoPath = URL(fileURLWithPath: streamDir).appendingPathComponent( + "StreamInfoBoot.xml") + do { + let streamXML = try String(contentsOf: streamInfoPath, encoding: .utf8) + let streamInfo = try parseStreamInfoXml(streamXML) + + guard let localPath = streamInfo.MediaPlaylist.PathToLocalCopy, + !localPath.isEmpty + else { + print("No local m3u8 specified in \(streamDir); skipping.") + return nil + } + + let m3u8Path = URL(fileURLWithPath: streamDir).appendingPathComponent(localPath) + let m3u8Content = try String(contentsOf: m3u8Path, encoding: .utf8) + let localSegmentPaths = streamInfo.MediaSegments.SEG.map { seg in + URL(fileURLWithPath: streamDir) + .appendingPathComponent(seg.PATH) + .absoluteString // Use absoluteString instead of path + } + let updatedContent = updatePlaylistWithLocalSegments( + content: m3u8Content, localPaths: localSegmentPaths) + try updatedContent.write(to: m3u8Path, atomically: true, encoding: .utf8) + return streamInfo + } catch { + print("Error processing stream at \(streamDir): \(error)") + throw error + } +} diff --git a/modules/hls-downloader/ios/HlsDownloader.podspec b/modules/hls-downloader/ios/HlsDownloader.podspec index 82e87ba5..53ac0583 100644 --- a/modules/hls-downloader/ios/HlsDownloader.podspec +++ b/modules/hls-downloader/ios/HlsDownloader.podspec @@ -10,6 +10,7 @@ Pod::Spec.new do |s| s.static_framework = true s.dependency 'ExpoModulesCore' + s.dependency 'XMLCoder' # Swift/Objective-C compatibility s.pod_target_xcconfig = { diff --git a/modules/hls-downloader/ios/HlsDownloaderModule.swift b/modules/hls-downloader/ios/HlsDownloaderModule.swift index deb99f58..8480357e 100644 --- a/modules/hls-downloader/ios/HlsDownloaderModule.swift +++ b/modules/hls-downloader/ios/HlsDownloaderModule.swift @@ -264,24 +264,24 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { originalLocation: location, folderName: folderName) // Calculate download size - let fileManager = FileManager.default - let enumerator = fileManager.enumerator( - at: newLocation, - includingPropertiesForKeys: [.totalFileAllocatedSizeKey], - options: [.skipsHiddenFiles], - errorHandler: nil)! + // let fileManager = FileManager.default + // let enumerator = fileManager.enumerator( + // at: newLocation, + // includingPropertiesForKeys: [.totalFileAllocatedSizeKey], + // options: [.skipsHiddenFiles], + // errorHandler: nil)! - var totalSize: Int64 = 0 - while let filePath = enumerator.nextObject() as? URL { - do { - let resourceValues = try filePath.resourceValues(forKeys: [.totalFileAllocatedSizeKey]) - if let size = resourceValues.totalFileAllocatedSize { - totalSize += Int64(size) - } - } catch { - print("Error calculating size: \(error)") - } - } + // var totalSize: Int64 = 0 + // while let filePath = enumerator.nextObject() as? URL { + // do { + // let resourceValues = try filePath.resourceValues(forKeys: [.totalFileAllocatedSizeKey]) + // if let size = resourceValues.totalFileAllocatedSize { + // totalSize += Int64(size) + // } + // } catch { + // print("Error calculating size: \(error)") + // } + // } if !metadata.isEmpty { let metadataLocation = newLocation.deletingLastPathComponent().appendingPathComponent( @@ -290,16 +290,30 @@ class HLSDownloadDelegate: NSObject, AVAssetDownloadDelegate { try jsonData.write(to: metadataLocation) } - module.sendEvent( - "onComplete", - [ - "id": providedId, - "location": newLocation.absoluteString, - "state": "DONE", - "metadata": metadata, - "startTime": startTime, - "bytesDownloaded": totalSize, - ]) + Task { + do { + try await rewriteM3U8Files(baseDir: newLocation.path) + module.sendEvent( + "onComplete", + [ + "id": providedId, + "location": newLocation.absoluteString, + "state": "DONE", + "metadata": metadata, + "startTime": startTime, + ]) + } catch { + module.sendEvent( + "onError", + [ + "id": providedId, + "error": error.localizedDescription, + "state": "FAILED", + "metadata": metadata, + "startTime": startTime, + ]) + } + } } catch { module?.sendEvent( "onError", diff --git a/providers/NativeDownloadProvider.tsx b/providers/NativeDownloadProvider.tsx index f202c832..b566f1ac 100644 --- a/providers/NativeDownloadProvider.tsx +++ b/providers/NativeDownloadProvider.tsx @@ -193,8 +193,8 @@ export const NativeDownloadProvider: React.FC<{ const completeListener = addCompleteListener(async (payload) => { try { - await rewriteM3U8Files(payload.location); - await markFileAsDone(payload.id); + // await rewriteM3U8Files(payload.location); + // await markFileAsDone(payload.id); setDownloads((prev) => { const newDownloads = { ...prev }; @@ -261,7 +261,7 @@ export const NativeDownloadProvider: React.FC<{ } } }; - checkForUnparsedDownloads(); + // checkForUnparsedDownloads(); }, []); const startDownload = async (