mirror of
https://github.com/cranci1/Sora.git
synced 2026-01-11 20:10:24 +00:00
Fix downloads of modules that state they are HLS but might return mp4 streams + Consolidated download methods into 1 file (#188)
* Add MP4 Fallback redirect if stream URL is MP4 for some reason * Consolidate Download Method * Clean up download logic * Further Cleanup * Fix Logging Oopsie
This commit is contained in:
parent
c48dbbe3cb
commit
b03ff287fe
4 changed files with 531 additions and 550 deletions
527
Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift
Normal file
527
Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
//
|
||||
// JSController+Downloader.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by doomsboygaming on 6/13/25
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
|
||||
struct DownloadRequest {
|
||||
let url: URL
|
||||
let headers: [String: String]
|
||||
let title: String?
|
||||
let imageURL: URL?
|
||||
let isEpisode: Bool
|
||||
let showTitle: String?
|
||||
let season: Int?
|
||||
let episode: Int?
|
||||
let subtitleURL: URL?
|
||||
let showPosterURL: URL?
|
||||
|
||||
init(url: URL, headers: [String: String], title: String? = nil, imageURL: URL? = nil,
|
||||
isEpisode: Bool = false, showTitle: String? = nil, season: Int? = nil,
|
||||
episode: Int? = nil, subtitleURL: URL? = nil, showPosterURL: URL? = nil) {
|
||||
self.url = url
|
||||
self.headers = headers
|
||||
self.title = title
|
||||
self.imageURL = imageURL
|
||||
self.isEpisode = isEpisode
|
||||
self.showTitle = showTitle
|
||||
self.season = season
|
||||
self.episode = episode
|
||||
self.subtitleURL = subtitleURL
|
||||
self.showPosterURL = showPosterURL
|
||||
}
|
||||
}
|
||||
|
||||
struct QualityOption {
|
||||
let name: String
|
||||
let url: String
|
||||
let height: Int?
|
||||
|
||||
init(name: String, url: String, height: Int? = nil) {
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.height = height
|
||||
}
|
||||
}
|
||||
|
||||
extension JSController {
|
||||
|
||||
func downloadWithM3U8Support(url: URL, headers: [String: String], title: String? = nil,
|
||||
imageURL: URL? = nil, isEpisode: Bool = false,
|
||||
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
|
||||
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
|
||||
completionHandler: ((Bool, String) -> Void)? = nil) {
|
||||
|
||||
let request = DownloadRequest(
|
||||
url: url, headers: headers, title: title, imageURL: imageURL,
|
||||
isEpisode: isEpisode, showTitle: showTitle, season: season,
|
||||
episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL
|
||||
)
|
||||
|
||||
logDownloadStart(request: request)
|
||||
|
||||
if url.absoluteString.contains(".m3u8") {
|
||||
handleM3U8Download(request: request, completionHandler: completionHandler)
|
||||
} else {
|
||||
handleDirectDownload(request: request, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func handleM3U8Download(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) {
|
||||
let preferredQuality = DownloadQualityPreference.current.rawValue
|
||||
logM3U8Detection(preferredQuality: preferredQuality)
|
||||
|
||||
parseM3U8(url: request.url, headers: request.headers) { [weak self] qualities in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self else { return }
|
||||
|
||||
if qualities.isEmpty {
|
||||
self.logM3U8NoQualities()
|
||||
self.downloadWithOriginalMethod(request: request, completionHandler: completionHandler)
|
||||
return
|
||||
}
|
||||
|
||||
self.logM3U8QualitiesFound(qualities: qualities)
|
||||
let selectedQuality = self.selectQualityBasedOnPreference(qualities: qualities, preferredQuality: preferredQuality)
|
||||
self.logM3U8QualitySelected(quality: selectedQuality)
|
||||
|
||||
if let qualityURL = URL(string: selectedQuality.url) {
|
||||
let qualityRequest = DownloadRequest(
|
||||
url: qualityURL, headers: request.headers, title: request.title,
|
||||
imageURL: request.imageURL, isEpisode: request.isEpisode,
|
||||
showTitle: request.showTitle, season: request.season,
|
||||
episode: request.episode, subtitleURL: request.subtitleURL,
|
||||
showPosterURL: request.showPosterURL
|
||||
)
|
||||
self.downloadWithOriginalMethod(request: qualityRequest, completionHandler: completionHandler)
|
||||
} else {
|
||||
self.logM3U8InvalidURL()
|
||||
self.downloadWithOriginalMethod(request: request, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDirectDownload(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) {
|
||||
logDirectDownload()
|
||||
|
||||
let urlString = request.url.absoluteString.lowercased()
|
||||
if urlString.contains(".mp4") || urlString.contains("mp4") {
|
||||
logMP4Detection()
|
||||
downloadMP4(request: request, completionHandler: completionHandler)
|
||||
} else {
|
||||
downloadWithOriginalMethod(request: request, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func downloadMP4(url: URL, headers: [String: String], title: String? = nil,
|
||||
imageURL: URL? = nil, isEpisode: Bool = false,
|
||||
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
|
||||
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
|
||||
completionHandler: ((Bool, String) -> Void)? = nil) {
|
||||
|
||||
let request = DownloadRequest(
|
||||
url: url, headers: headers, title: title, imageURL: imageURL,
|
||||
isEpisode: isEpisode, showTitle: showTitle, season: season,
|
||||
episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL
|
||||
)
|
||||
|
||||
downloadMP4(request: request, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
private func downloadMP4(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) {
|
||||
guard validateURL(request.url) else {
|
||||
completionHandler?(false, "Invalid URL scheme")
|
||||
return
|
||||
}
|
||||
|
||||
guard let downloadSession = downloadURLSession else {
|
||||
completionHandler?(false, "Download session not available")
|
||||
return
|
||||
}
|
||||
|
||||
let metadata = createAssetMetadata(from: request)
|
||||
let downloadType: DownloadType = request.isEpisode ? .episode : .movie
|
||||
let downloadID = UUID()
|
||||
|
||||
let asset = AVURLAsset(url: request.url, options: [
|
||||
"AVURLAssetHTTPHeaderFieldsKey": request.headers
|
||||
])
|
||||
|
||||
guard let downloadTask = downloadSession.makeAssetDownloadTask(
|
||||
asset: asset,
|
||||
assetTitle: request.title ?? request.url.lastPathComponent,
|
||||
assetArtworkData: nil,
|
||||
options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000]
|
||||
) else {
|
||||
completionHandler?(false, "Failed to create download task")
|
||||
return
|
||||
}
|
||||
|
||||
let activeDownload = createActiveDownload(
|
||||
id: downloadID, request: request, asset: asset,
|
||||
downloadTask: downloadTask, downloadType: downloadType, metadata: metadata
|
||||
)
|
||||
|
||||
addActiveDownload(activeDownload, task: downloadTask)
|
||||
setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID)
|
||||
downloadTask.resume()
|
||||
|
||||
postDownloadNotification()
|
||||
completionHandler?(true, "Download started")
|
||||
}
|
||||
|
||||
|
||||
private func parseM3U8(url: URL, headers: [String: String], completion: @escaping ([QualityOption]) -> Void) {
|
||||
var request = URLRequest(url: url)
|
||||
for (key, value) in headers {
|
||||
request.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
logM3U8FetchStart(url: url)
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
self.logHTTPStatus(httpResponse.statusCode, for: url)
|
||||
if httpResponse.statusCode >= 400 {
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
self.logM3U8FetchError(error)
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data, let content = String(data: data, encoding: .utf8) else {
|
||||
self.logM3U8DecodeError()
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
self.logM3U8FetchSuccess(dataSize: data.count)
|
||||
let qualities = self.parseM3U8Content(content: content, baseURL: url)
|
||||
completion(qualities)
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func parseM3U8Content(content: String, baseURL: URL) -> [QualityOption] {
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
logM3U8ParseStart(lineCount: lines.count)
|
||||
|
||||
var qualities: [QualityOption] = []
|
||||
qualities.append(QualityOption(name: "Auto (Recommended)", url: baseURL.absoluteString))
|
||||
|
||||
for (index, line) in lines.enumerated() {
|
||||
if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count {
|
||||
if let qualityOption = parseStreamInfoLine(line: line, nextLine: lines[index + 1], baseURL: baseURL) {
|
||||
if !qualities.contains(where: { $0.name == qualityOption.name }) {
|
||||
qualities.append(qualityOption)
|
||||
logM3U8QualityAdded(quality: qualityOption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logM3U8ParseComplete(qualityCount: qualities.count - 1) // -1 for Auto
|
||||
return qualities
|
||||
}
|
||||
|
||||
private func parseStreamInfoLine(line: String, nextLine: String, baseURL: URL) -> QualityOption? {
|
||||
guard let resolutionRange = line.range(of: "RESOLUTION="),
|
||||
let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",")
|
||||
?? line[resolutionRange.upperBound...].range(of: "\n") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let resolutionPart = String(line[resolutionRange.upperBound..<resolutionEndRange.lowerBound])
|
||||
guard let heightStr = resolutionPart.components(separatedBy: "x").last,
|
||||
let height = Int(heightStr) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let qualityName = getQualityName(for: height)
|
||||
let qualityURL = resolveQualityURL(nextLine.trimmingCharacters(in: .whitespacesAndNewlines), baseURL: baseURL)
|
||||
|
||||
return QualityOption(name: qualityName, url: qualityURL, height: height)
|
||||
}
|
||||
|
||||
private func getQualityName(for height: Int) -> String {
|
||||
switch height {
|
||||
case 1080...: return "\(height)p (FHD)"
|
||||
case 720..<1080: return "\(height)p (HD)"
|
||||
case 480..<720: return "\(height)p (SD)"
|
||||
default: return "\(height)p"
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveQualityURL(_ urlString: String, baseURL: URL) -> String {
|
||||
if urlString.hasPrefix("http") {
|
||||
return urlString
|
||||
}
|
||||
|
||||
if urlString.contains(".m3u8") {
|
||||
return URL(string: urlString, relativeTo: baseURL)?.absoluteString
|
||||
?? baseURL.deletingLastPathComponent().absoluteString + "/" + urlString
|
||||
}
|
||||
|
||||
return urlString
|
||||
}
|
||||
|
||||
|
||||
private func selectQualityBasedOnPreference(qualities: [QualityOption], preferredQuality: String) -> QualityOption {
|
||||
guard qualities.count > 1 else {
|
||||
logQualitySelectionSingle()
|
||||
return qualities[0]
|
||||
}
|
||||
|
||||
let (autoQuality, sortedQualities) = categorizeQualities(qualities: qualities)
|
||||
logQualitySelectionStart(preference: preferredQuality, sortedCount: sortedQualities.count)
|
||||
|
||||
let selected = selectQualityByPreference(
|
||||
preference: preferredQuality,
|
||||
sortedQualities: sortedQualities,
|
||||
autoQuality: autoQuality,
|
||||
fallback: qualities[0]
|
||||
)
|
||||
|
||||
logQualitySelectionResult(quality: selected, preference: preferredQuality)
|
||||
return selected
|
||||
}
|
||||
|
||||
private func categorizeQualities(qualities: [QualityOption]) -> (auto: QualityOption?, sorted: [QualityOption]) {
|
||||
let autoQuality = qualities.first { $0.name.contains("Auto") }
|
||||
let nonAutoQualities = qualities.filter { !$0.name.contains("Auto") }
|
||||
|
||||
let sortedQualities = nonAutoQualities.sorted { first, second in
|
||||
let firstHeight = first.height ?? extractHeight(from: first.name)
|
||||
let secondHeight = second.height ?? extractHeight(from: second.name)
|
||||
return firstHeight > secondHeight
|
||||
}
|
||||
|
||||
return (autoQuality, sortedQualities)
|
||||
}
|
||||
|
||||
private func selectQualityByPreference(preference: String, sortedQualities: [QualityOption],
|
||||
autoQuality: QualityOption?, fallback: QualityOption) -> QualityOption {
|
||||
switch preference {
|
||||
case "Best":
|
||||
return sortedQualities.first ?? fallback
|
||||
case "High":
|
||||
return findQualityByType(["720p", "HD"], in: sortedQualities) ?? sortedQualities.first ?? fallback
|
||||
case "Medium":
|
||||
return findQualityByType(["480p", "SD"], in: sortedQualities)
|
||||
?? (sortedQualities.isEmpty ? fallback : sortedQualities[sortedQualities.count / 2])
|
||||
case "Low":
|
||||
return sortedQualities.last ?? fallback
|
||||
default:
|
||||
return autoQuality ?? fallback
|
||||
}
|
||||
}
|
||||
|
||||
private func findQualityByType(_ types: [String], in qualities: [QualityOption]) -> QualityOption? {
|
||||
return qualities.first { quality in
|
||||
types.contains { quality.name.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
private func extractHeight(from qualityName: String) -> Int {
|
||||
return Int(qualityName.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
|
||||
}
|
||||
|
||||
|
||||
private func validateURL(_ url: URL) -> Bool {
|
||||
return url.scheme == "http" || url.scheme == "https"
|
||||
}
|
||||
|
||||
private func createAssetMetadata(from request: DownloadRequest) -> AssetMetadata? {
|
||||
guard let title = request.title else { return nil }
|
||||
|
||||
return AssetMetadata(
|
||||
title: title,
|
||||
posterURL: request.imageURL,
|
||||
showTitle: request.showTitle,
|
||||
season: request.season,
|
||||
episode: request.episode,
|
||||
showPosterURL: request.showPosterURL ?? request.imageURL
|
||||
)
|
||||
}
|
||||
|
||||
private func createActiveDownload(id: UUID, request: DownloadRequest, asset: AVURLAsset,
|
||||
downloadTask: AVAssetDownloadTask? = nil, urlSessionTask: URLSessionDownloadTask? = nil,
|
||||
downloadType: DownloadType, metadata: AssetMetadata?) -> JSActiveDownload {
|
||||
return JSActiveDownload(
|
||||
id: id,
|
||||
originalURL: request.url,
|
||||
progress: 0.0,
|
||||
task: downloadTask,
|
||||
urlSessionTask: urlSessionTask,
|
||||
queueStatus: .downloading,
|
||||
type: downloadType,
|
||||
metadata: metadata,
|
||||
title: request.title,
|
||||
imageURL: request.imageURL,
|
||||
subtitleURL: request.subtitleURL,
|
||||
asset: asset,
|
||||
headers: request.headers,
|
||||
module: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func addActiveDownload(_ download: JSActiveDownload, task: URLSessionTask) {
|
||||
activeDownloads.append(download)
|
||||
activeDownloadMap[task] = download.id
|
||||
}
|
||||
|
||||
private func postDownloadNotification() {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadWithOriginalMethod(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) {
|
||||
self.startDownload(
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
title: request.title,
|
||||
imageURL: request.imageURL,
|
||||
isEpisode: request.isEpisode,
|
||||
showTitle: request.showTitle,
|
||||
season: request.season,
|
||||
episode: request.episode,
|
||||
subtitleURL: request.subtitleURL,
|
||||
showPosterURL: request.showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private func setupMP4ProgressObservation(for task: AVAssetDownloadTask, downloadID: UUID) {
|
||||
let observation = task.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] progress, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self else { return }
|
||||
self.updateMP4DownloadProgress(task: task, progress: progress.fractionCompleted)
|
||||
NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
if mp4ProgressObservations == nil {
|
||||
mp4ProgressObservations = [:]
|
||||
}
|
||||
mp4ProgressObservations?[downloadID] = observation
|
||||
}
|
||||
|
||||
private func updateMP4DownloadProgress(task: AVAssetDownloadTask, progress: Double) {
|
||||
guard let downloadID = activeDownloadMap[task],
|
||||
let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
|
||||
return
|
||||
}
|
||||
activeDownloads[downloadIndex].progress = progress
|
||||
}
|
||||
|
||||
func cleanupMP4ProgressObservation(for downloadID: UUID) {
|
||||
mp4ProgressObservations?[downloadID]?.invalidate()
|
||||
mp4ProgressObservations?[downloadID] = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension JSController {
|
||||
private func logDownloadStart(request: DownloadRequest) {
|
||||
Logger.shared.log("Download process started for URL: \(request.url.absoluteString)", type: "Download")
|
||||
Logger.shared.log("Title: \(request.title ?? "None"), Episode: \(request.isEpisode ? "Yes" : "No")", type: "Debug")
|
||||
if let showTitle = request.showTitle, let episode = request.episode {
|
||||
Logger.shared.log("Show: \(showTitle), Season: \(request.season ?? 1), Episode: \(episode)", type: "Debug")
|
||||
}
|
||||
if let subtitle = request.subtitleURL {
|
||||
Logger.shared.log("Subtitle URL provided: \(subtitle.absoluteString)", type: "Debug")
|
||||
}
|
||||
}
|
||||
|
||||
private func logM3U8Detection(preferredQuality: String) {
|
||||
Logger.shared.log("M3U8 playlist detected - quality preference: \(preferredQuality)", type: "Download")
|
||||
}
|
||||
|
||||
private func logM3U8NoQualities() {
|
||||
Logger.shared.log("No quality options found in M3U8, using original URL", type: "Warning")
|
||||
}
|
||||
|
||||
private func logM3U8QualitiesFound(qualities: [QualityOption]) {
|
||||
Logger.shared.log("Found \(qualities.count) quality options in M3U8 playlist", type: "Download")
|
||||
for (index, quality) in qualities.enumerated() {
|
||||
Logger.shared.log("Quality \(index + 1): \(quality.name)", type: "Debug")
|
||||
}
|
||||
}
|
||||
|
||||
private func logM3U8QualitySelected(quality: QualityOption) {
|
||||
Logger.shared.log("Selected quality: \(quality.name)", type: "Download")
|
||||
Logger.shared.log("Final download URL: \(quality.url)", type: "Debug")
|
||||
}
|
||||
|
||||
private func logM3U8InvalidURL() {
|
||||
Logger.shared.log("Invalid quality URL detected, falling back to original", type: "Warning")
|
||||
}
|
||||
|
||||
private func logDirectDownload() {
|
||||
Logger.shared.log("Direct download initiated (non-M3U8)", type: "Download")
|
||||
}
|
||||
|
||||
private func logMP4Detection() {
|
||||
Logger.shared.log("MP4 stream detected, using MP4 download method", type: "Download")
|
||||
}
|
||||
|
||||
private func logM3U8FetchStart(url: URL) {
|
||||
Logger.shared.log("Fetching M3U8 content from: \(url.absoluteString)", type: "Debug")
|
||||
}
|
||||
|
||||
private func logHTTPStatus(_ statusCode: Int, for url: URL) {
|
||||
let logType = statusCode >= 400 ? "Error" : "Debug"
|
||||
Logger.shared.log("HTTP \(statusCode) for M3U8 request: \(url.absoluteString)", type: logType)
|
||||
}
|
||||
|
||||
private func logM3U8FetchError(_ error: Error) {
|
||||
Logger.shared.log("Failed to fetch M3U8 content: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
|
||||
private func logM3U8DecodeError() {
|
||||
Logger.shared.log("Failed to decode M3U8 file content", type: "Error")
|
||||
}
|
||||
|
||||
private func logM3U8FetchSuccess(dataSize: Int) {
|
||||
Logger.shared.log("Successfully fetched M3U8 content (\(dataSize) bytes)", type: "Debug")
|
||||
}
|
||||
|
||||
private func logM3U8ParseStart(lineCount: Int) {
|
||||
Logger.shared.log("Parsing M3U8 file with \(lineCount) lines", type: "Debug")
|
||||
}
|
||||
|
||||
private func logM3U8QualityAdded(quality: QualityOption) {
|
||||
Logger.shared.log("Added quality option: \(quality.name)", type: "Debug")
|
||||
}
|
||||
|
||||
private func logM3U8ParseComplete(qualityCount: Int) {
|
||||
Logger.shared.log("M3U8 parsing complete: \(qualityCount) quality options found", type: "Debug")
|
||||
}
|
||||
|
||||
private func logQualitySelectionSingle() {
|
||||
Logger.shared.log("Only one quality available, using default", type: "Debug")
|
||||
}
|
||||
|
||||
private func logQualitySelectionStart(preference: String, sortedCount: Int) {
|
||||
Logger.shared.log("Quality selection: \(sortedCount) options, preference: \(preference)", type: "Debug")
|
||||
}
|
||||
|
||||
private func logQualitySelectionResult(quality: QualityOption, preference: String) {
|
||||
Logger.shared.log("Quality selected: \(quality.name) (preference: \(preference))", type: "Download")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
//
|
||||
// JSController+M3U8Download.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by doomsboygaming on 5/22/25
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// No need to import DownloadQualityPreference as it's in the same module
|
||||
|
||||
// Extension for integrating M3U8StreamExtractor with JSController for downloads
|
||||
extension JSController {
|
||||
|
||||
/// Initiates a download for a given URL, handling M3U8 playlists if necessary
|
||||
/// - Parameters:
|
||||
/// - url: The URL to download
|
||||
/// - headers: HTTP headers to use for the request
|
||||
/// - title: Title for the download (optional)
|
||||
/// - imageURL: Image URL for the content (optional)
|
||||
/// - isEpisode: Whether this is an episode (defaults to false)
|
||||
/// - showTitle: Title of the show this episode belongs to (optional)
|
||||
/// - season: Season number (optional)
|
||||
/// - episode: Episode number (optional)
|
||||
/// - subtitleURL: Optional subtitle URL to download after video (optional)
|
||||
/// - completionHandler: Called when the download is initiated or fails
|
||||
func downloadWithM3U8Support(url: URL, headers: [String: String], title: String? = nil,
|
||||
imageURL: URL? = nil, isEpisode: Bool = false,
|
||||
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
|
||||
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
|
||||
completionHandler: ((Bool, String) -> Void)? = nil) {
|
||||
// Use headers passed in from caller rather than generating our own baseUrl
|
||||
// Receiving code should already be setting module.metadata.baseUrl
|
||||
|
||||
print("---- DOWNLOAD PROCESS STARTED ----")
|
||||
print("Original URL: \(url.absoluteString)")
|
||||
print("Headers: \(headers)")
|
||||
print("Title: \(title ?? "None")")
|
||||
print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")")
|
||||
if let subtitle = subtitleURL {
|
||||
print("Subtitle URL: \(subtitle.absoluteString)")
|
||||
}
|
||||
|
||||
// Check if the URL is an M3U8 file
|
||||
if url.absoluteString.contains(".m3u8") {
|
||||
// Get the user's quality preference
|
||||
let preferredQuality = DownloadQualityPreference.current.rawValue
|
||||
|
||||
print("URL detected as M3U8 playlist - will select quality based on user preference: \(preferredQuality)")
|
||||
|
||||
// Parse the M3U8 content to extract available qualities, matching CustomPlayer approach
|
||||
parseM3U8(url: url, baseUrl: url.absoluteString, headers: headers) { [weak self] qualities in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self else { return }
|
||||
|
||||
if qualities.isEmpty {
|
||||
print("M3U8 Analysis: No quality options found in M3U8, downloading with original URL")
|
||||
self.downloadWithOriginalMethod(
|
||||
url: url,
|
||||
headers: headers,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
isEpisode: isEpisode,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
subtitleURL: subtitleURL,
|
||||
showPosterURL: showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
print("M3U8 Analysis: Found \(qualities.count) quality options")
|
||||
for (index, quality) in qualities.enumerated() {
|
||||
print(" \(index + 1). \(quality.0) - \(quality.1)")
|
||||
}
|
||||
|
||||
// Select appropriate quality based on user preference
|
||||
let selectedQuality = self.selectQualityBasedOnPreference(qualities: qualities, preferredQuality: preferredQuality)
|
||||
|
||||
print("M3U8 Analysis: Selected quality: \(selectedQuality.0)")
|
||||
print("M3U8 Analysis: Selected URL: \(selectedQuality.1)")
|
||||
|
||||
if let qualityURL = URL(string: selectedQuality.1) {
|
||||
print("FINAL DOWNLOAD URL: \(qualityURL.absoluteString)")
|
||||
print("QUALITY SELECTED: \(selectedQuality.0)")
|
||||
|
||||
// Download with standard headers that match the player
|
||||
self.downloadWithOriginalMethod(
|
||||
url: qualityURL,
|
||||
headers: headers,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
isEpisode: isEpisode,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
subtitleURL: subtitleURL,
|
||||
showPosterURL: showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
} else {
|
||||
print("M3U8 Analysis: Invalid quality URL, falling back to original URL")
|
||||
print("FINAL DOWNLOAD URL (fallback): \(url.absoluteString)")
|
||||
|
||||
self.downloadWithOriginalMethod(
|
||||
url: url,
|
||||
headers: headers,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
isEpisode: isEpisode,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
subtitleURL: subtitleURL,
|
||||
showPosterURL: showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not an M3U8 file, use the original download method with standard headers
|
||||
print("URL is not an M3U8 playlist - downloading directly")
|
||||
print("FINAL DOWNLOAD URL (direct): \(url.absoluteString)")
|
||||
|
||||
downloadWithOriginalMethod(
|
||||
url: url,
|
||||
headers: headers,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
isEpisode: isEpisode,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
subtitleURL: subtitleURL,
|
||||
showPosterURL: showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an M3U8 file to extract available quality options, matching CustomPlayer's approach exactly
|
||||
/// - Parameters:
|
||||
/// - url: The URL of the M3U8 file
|
||||
/// - baseUrl: The base URL for setting headers
|
||||
/// - headers: HTTP headers to use for the request
|
||||
/// - completion: Called with the array of quality options (name, URL)
|
||||
private func parseM3U8(url: URL, baseUrl: String, headers: [String: String], completion: @escaping ([(String, String)]) -> Void) {
|
||||
var request = URLRequest(url: url)
|
||||
|
||||
// Add headers from headers passed to downloadWithM3U8Support
|
||||
// This ensures we use the same headers as the player (from module.metadata.baseUrl)
|
||||
for (key, value) in headers {
|
||||
request.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
print("M3U8 Parser: Fetching M3U8 content from: \(url.absoluteString)")
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
// Log HTTP status for debugging
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
print("M3U8 Parser: HTTP Status: \(httpResponse.statusCode) for \(url.absoluteString)")
|
||||
|
||||
if httpResponse.statusCode >= 400 {
|
||||
print("M3U8 Parser: HTTP Error: \(httpResponse.statusCode)")
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
print("M3U8 Parser: Error fetching M3U8: \(error.localizedDescription)")
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data, let content = String(data: data, encoding: .utf8) else {
|
||||
print("M3U8 Parser: Failed to load or decode M3U8 file")
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
print("M3U8 Parser: Successfully fetched M3U8 content (\(data.count) bytes)")
|
||||
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
print("M3U8 Parser: Found \(lines.count) lines in M3U8 file")
|
||||
|
||||
var qualities: [(String, String)] = []
|
||||
|
||||
// Always include the original URL as "Auto" option
|
||||
qualities.append(("Auto (Recommended)", url.absoluteString))
|
||||
print("M3U8 Parser: Added 'Auto' quality option with original URL")
|
||||
|
||||
func getQualityName(for height: Int) -> String {
|
||||
switch height {
|
||||
case 1080...: return "\(height)p (FHD)"
|
||||
case 720..<1080: return "\(height)p (HD)"
|
||||
case 480..<720: return "\(height)p (SD)"
|
||||
default: return "\(height)p"
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the M3U8 content to extract available streams - exactly like CustomPlayer
|
||||
print("M3U8 Parser: Scanning for quality options...")
|
||||
var qualitiesFound = 0
|
||||
|
||||
for (index, line) in lines.enumerated() {
|
||||
if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count {
|
||||
print("M3U8 Parser: Found stream info at line \(index): \(line)")
|
||||
|
||||
if let resolutionRange = line.range(of: "RESOLUTION="),
|
||||
let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",")
|
||||
?? line[resolutionRange.upperBound...].range(of: "\n") {
|
||||
|
||||
let resolutionPart = String(line[resolutionRange.upperBound..<resolutionEndRange.lowerBound])
|
||||
print("M3U8 Parser: Extracted resolution: \(resolutionPart)")
|
||||
|
||||
if let heightStr = resolutionPart.components(separatedBy: "x").last,
|
||||
let height = Int(heightStr) {
|
||||
|
||||
let nextLine = lines[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let qualityName = getQualityName(for: height)
|
||||
|
||||
print("M3U8 Parser: Found height \(height)px, quality name: \(qualityName)")
|
||||
print("M3U8 Parser: Stream URL from next line: \(nextLine)")
|
||||
|
||||
var qualityURL = nextLine
|
||||
if !nextLine.hasPrefix("http") && nextLine.contains(".m3u8") {
|
||||
// Handle relative URLs
|
||||
let baseURLString = url.deletingLastPathComponent().absoluteString
|
||||
let resolvedURL = URL(string: nextLine, relativeTo: url)?.absoluteString
|
||||
?? baseURLString + "/" + nextLine
|
||||
|
||||
qualityURL = resolvedURL
|
||||
print("M3U8 Parser: Resolved relative URL to: \(qualityURL)")
|
||||
}
|
||||
|
||||
if !qualities.contains(where: { $0.0 == qualityName }) {
|
||||
qualities.append((qualityName, qualityURL))
|
||||
qualitiesFound += 1
|
||||
print("M3U8 Parser: Added quality option: \(qualityName) - \(qualityURL)")
|
||||
} else {
|
||||
print("M3U8 Parser: Skipped duplicate quality: \(qualityName)")
|
||||
}
|
||||
} else {
|
||||
print("M3U8 Parser: Failed to extract height from resolution: \(resolutionPart)")
|
||||
}
|
||||
} else {
|
||||
print("M3U8 Parser: Failed to extract resolution from line: \(line)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("M3U8 Parser: Found \(qualitiesFound) distinct quality options (plus Auto)")
|
||||
print("M3U8 Parser: Total quality options: \(qualities.count)")
|
||||
completion(qualities)
|
||||
}.resume()
|
||||
}
|
||||
|
||||
/// Selects the appropriate quality based on user preference
|
||||
/// - Parameters:
|
||||
/// - qualities: Available quality options (name, URL)
|
||||
/// - preferredQuality: User's preferred quality
|
||||
/// - Returns: The selected quality (name, URL)
|
||||
private func selectQualityBasedOnPreference(qualities: [(String, String)], preferredQuality: String) -> (String, String) {
|
||||
// If only one quality is available, return it
|
||||
if qualities.count <= 1 {
|
||||
print("Quality Selection: Only one quality option available, returning it directly")
|
||||
return qualities[0]
|
||||
}
|
||||
|
||||
// Extract "Auto" quality and the remaining qualities
|
||||
let autoQuality = qualities.first { $0.0.contains("Auto") }
|
||||
let nonAutoQualities = qualities.filter { !$0.0.contains("Auto") }
|
||||
|
||||
print("Quality Selection: Found \(nonAutoQualities.count) non-Auto quality options")
|
||||
print("Quality Selection: Auto quality option: \(autoQuality?.0 ?? "None")")
|
||||
|
||||
// Sort non-auto qualities by resolution (highest first)
|
||||
let sortedQualities = nonAutoQualities.sorted { first, second in
|
||||
let firstHeight = Int(first.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
|
||||
let secondHeight = Int(second.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
|
||||
return firstHeight > secondHeight
|
||||
}
|
||||
|
||||
print("Quality Selection: Sorted qualities (highest to lowest):")
|
||||
for (index, quality) in sortedQualities.enumerated() {
|
||||
print(" \(index + 1). \(quality.0) - \(quality.1)")
|
||||
}
|
||||
|
||||
print("Quality Selection: User preference is '\(preferredQuality)'")
|
||||
|
||||
// Select quality based on preference
|
||||
switch preferredQuality {
|
||||
case "Best":
|
||||
// Return the highest quality (first in sorted list)
|
||||
let selected = sortedQualities.first ?? qualities[0]
|
||||
print("Quality Selection: Selected 'Best' quality: \(selected.0)")
|
||||
return selected
|
||||
|
||||
case "High":
|
||||
// Look for 720p quality
|
||||
let highQuality = sortedQualities.first {
|
||||
$0.0.contains("720p") || $0.0.contains("HD")
|
||||
}
|
||||
|
||||
if let high = highQuality {
|
||||
print("Quality Selection: Found specific 'High' (720p/HD) quality: \(high.0)")
|
||||
return high
|
||||
} else if let first = sortedQualities.first {
|
||||
print("Quality Selection: No specific 'High' quality found, using highest available: \(first.0)")
|
||||
return first
|
||||
} else {
|
||||
print("Quality Selection: No non-Auto qualities found, falling back to default: \(qualities[0].0)")
|
||||
return qualities[0]
|
||||
}
|
||||
|
||||
case "Medium":
|
||||
// Look for 480p quality
|
||||
let mediumQuality = sortedQualities.first {
|
||||
$0.0.contains("480p") || $0.0.contains("SD")
|
||||
}
|
||||
|
||||
if let medium = mediumQuality {
|
||||
print("Quality Selection: Found specific 'Medium' (480p/SD) quality: \(medium.0)")
|
||||
return medium
|
||||
} else if !sortedQualities.isEmpty {
|
||||
// Return middle quality from sorted list if no exact match
|
||||
let middleIndex = sortedQualities.count / 2
|
||||
print("Quality Selection: No specific 'Medium' quality found, using middle quality: \(sortedQualities[middleIndex].0)")
|
||||
return sortedQualities[middleIndex]
|
||||
} else {
|
||||
print("Quality Selection: No non-Auto qualities found, falling back to default: \(autoQuality?.0 ?? qualities[0].0)")
|
||||
return autoQuality ?? qualities[0]
|
||||
}
|
||||
|
||||
case "Low":
|
||||
// Return lowest quality (last in sorted list)
|
||||
if let lowest = sortedQualities.last {
|
||||
print("Quality Selection: Selected 'Low' quality: \(lowest.0)")
|
||||
return lowest
|
||||
} else {
|
||||
print("Quality Selection: No non-Auto qualities found, falling back to default: \(autoQuality?.0 ?? qualities[0].0)")
|
||||
return autoQuality ?? qualities[0]
|
||||
}
|
||||
|
||||
default:
|
||||
// Default to Auto if available, otherwise first quality
|
||||
if let auto = autoQuality {
|
||||
print("Quality Selection: Default case, using Auto quality: \(auto.0)")
|
||||
return auto
|
||||
} else {
|
||||
print("Quality Selection: No Auto quality found, using first available: \(qualities[0].0)")
|
||||
return qualities[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The original download method (adapted to be called internally)
|
||||
/// This method should match the existing download implementation in JSController-Downloads.swift
|
||||
private func downloadWithOriginalMethod(url: URL, headers: [String: String], title: String? = nil,
|
||||
imageURL: URL? = nil, isEpisode: Bool = false,
|
||||
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
|
||||
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
|
||||
completionHandler: ((Bool, String) -> Void)? = nil) {
|
||||
// Call the existing download method
|
||||
self.startDownload(
|
||||
url: url,
|
||||
headers: headers,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
isEpisode: isEpisode,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
subtitleURL: subtitleURL,
|
||||
showPosterURL: showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
//
|
||||
// JSController+MP4Download.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by doomsboygaming on 5/22/25
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
// Extension for handling MP4 direct video downloads using AVAssetDownloadTask
|
||||
extension JSController {
|
||||
|
||||
/// Initiates a download for a given MP4 URL using the existing AVAssetDownloadURLSession
|
||||
/// - Parameters:
|
||||
/// - url: The MP4 URL to download
|
||||
/// - headers: HTTP headers to use for the request
|
||||
/// - title: Title for the download (optional)
|
||||
/// - imageURL: Image URL for the content (optional)
|
||||
/// - isEpisode: Whether this is an episode (defaults to false)
|
||||
/// - showTitle: Title of the show this episode belongs to (optional)
|
||||
/// - season: Season number (optional)
|
||||
/// - episode: Episode number (optional)
|
||||
/// - subtitleURL: Optional subtitle URL to download after video (optional)
|
||||
/// - completionHandler: Called when the download is initiated or fails
|
||||
func downloadMP4(url: URL, headers: [String: String], title: String? = nil,
|
||||
imageURL: URL? = nil, isEpisode: Bool = false,
|
||||
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
|
||||
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
|
||||
completionHandler: ((Bool, String) -> Void)? = nil) {
|
||||
|
||||
// Validate URL
|
||||
guard url.scheme == "http" || url.scheme == "https" else {
|
||||
completionHandler?(false, "Invalid URL scheme")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure download session is available
|
||||
guard let downloadSession = downloadURLSession else {
|
||||
completionHandler?(false, "Download session not available")
|
||||
return
|
||||
}
|
||||
|
||||
// Create metadata for the download
|
||||
var metadata: AssetMetadata? = nil
|
||||
if let title = title {
|
||||
metadata = AssetMetadata(
|
||||
title: title,
|
||||
posterURL: imageURL,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
showPosterURL: showPosterURL ?? imageURL
|
||||
)
|
||||
}
|
||||
|
||||
// Determine download type based on isEpisode
|
||||
let downloadType: DownloadType = isEpisode ? .episode : .movie
|
||||
|
||||
// Generate a unique download ID
|
||||
let downloadID = UUID()
|
||||
|
||||
// Create AVURLAsset with headers passed through AVURLAssetHTTPHeaderFieldsKey
|
||||
let asset = AVURLAsset(url: url, options: [
|
||||
"AVURLAssetHTTPHeaderFieldsKey": headers
|
||||
])
|
||||
|
||||
// Create AVAssetDownloadTask using existing session
|
||||
guard let downloadTask = downloadSession.makeAssetDownloadTask(
|
||||
asset: asset,
|
||||
assetTitle: title ?? url.lastPathComponent,
|
||||
assetArtworkData: nil,
|
||||
options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000]
|
||||
) else {
|
||||
completionHandler?(false, "Failed to create download task")
|
||||
return
|
||||
}
|
||||
|
||||
// Create an active download object
|
||||
let activeDownload = JSActiveDownload(
|
||||
id: downloadID,
|
||||
originalURL: url,
|
||||
progress: 0.0,
|
||||
task: downloadTask,
|
||||
urlSessionTask: nil,
|
||||
queueStatus: .downloading,
|
||||
type: downloadType,
|
||||
metadata: metadata,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
subtitleURL: subtitleURL,
|
||||
asset: asset,
|
||||
headers: headers,
|
||||
module: nil
|
||||
)
|
||||
|
||||
// Add to active downloads and tracking
|
||||
activeDownloads.append(activeDownload)
|
||||
activeDownloadMap[downloadTask] = downloadID
|
||||
|
||||
// Set up progress observation for MP4 downloads
|
||||
setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID)
|
||||
|
||||
// Start the download
|
||||
downloadTask.resume()
|
||||
|
||||
// Post notification for UI updates using NotificationCenter directly since postDownloadNotification is private
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil)
|
||||
}
|
||||
|
||||
// Initial success callback
|
||||
completionHandler?(true, "Download started")
|
||||
}
|
||||
|
||||
// MARK: - MP4 Progress Observation
|
||||
|
||||
/// Sets up progress observation for MP4 downloads using AVAssetDownloadTask
|
||||
/// Since AVAssetDownloadTask doesn't provide progress for single MP4 files through delegate methods,
|
||||
/// we observe the task's progress property directly
|
||||
private func setupMP4ProgressObservation(for task: AVAssetDownloadTask, downloadID: UUID) {
|
||||
let observation = task.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] progress, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self else { return }
|
||||
|
||||
// Update download progress using existing infrastructure
|
||||
self.updateMP4DownloadProgress(task: task, progress: progress.fractionCompleted)
|
||||
|
||||
// Post notification for UI updates
|
||||
NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Store observation for cleanup using existing property from main JSController class
|
||||
if mp4ProgressObservations == nil {
|
||||
mp4ProgressObservations = [:]
|
||||
}
|
||||
mp4ProgressObservations?[downloadID] = observation
|
||||
}
|
||||
|
||||
/// Updates download progress for a specific MP4 task (avoiding name collision with existing method)
|
||||
private func updateMP4DownloadProgress(task: AVAssetDownloadTask, progress: Double) {
|
||||
guard let downloadID = activeDownloadMap[task],
|
||||
let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Update progress using existing mechanism
|
||||
activeDownloads[downloadIndex].progress = progress
|
||||
}
|
||||
|
||||
/// Cleans up MP4 progress observation for a specific download
|
||||
func cleanupMP4ProgressObservation(for downloadID: UUID) {
|
||||
mp4ProgressObservations?[downloadID]?.invalidate()
|
||||
mp4ProgressObservations?[downloadID] = nil
|
||||
}
|
||||
}
|
||||
|
|
@ -93,12 +93,11 @@
|
|||
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */; };
|
||||
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */; };
|
||||
722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */; };
|
||||
722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */; };
|
||||
722248662DCBC13E00CABE2D /* JSController-Downloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */; };
|
||||
72443C7D2DC8036500A61321 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7C2DC8036500A61321 /* DownloadView.swift */; };
|
||||
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; };
|
||||
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */; };
|
||||
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */; };
|
||||
72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */; };
|
||||
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
|
|
@ -187,12 +186,11 @@
|
|||
7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = "<group>"; };
|
||||
7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = "<group>"; };
|
||||
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-HeaderManager.swift"; sourceTree = "<group>"; };
|
||||
722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+M3U8Download.swift"; sourceTree = "<group>"; };
|
||||
722248652DCBC13E00CABE2D /* JSController-Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Downloads.swift"; sourceTree = "<group>"; };
|
||||
72443C7C2DC8036500A61321 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = "<group>"; };
|
||||
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = "<group>"; };
|
||||
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = "<group>"; };
|
||||
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = "<group>"; };
|
||||
72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+Downloader.swift"; sourceTree = "<group>"; };
|
||||
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
|
|
@ -470,11 +468,10 @@
|
|||
134A387B2DE4B5B90041B687 /* Downloads */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */,
|
||||
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */,
|
||||
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */,
|
||||
722248652DCBC13E00CABE2D /* JSController-Downloads.swift */,
|
||||
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */,
|
||||
722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */,
|
||||
);
|
||||
path = Downloads;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -757,7 +754,6 @@
|
|||
13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */,
|
||||
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */,
|
||||
722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */,
|
||||
722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */,
|
||||
133D7C942D2BE2640075467E /* JSController.swift in Sources */,
|
||||
133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
|
||||
0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */,
|
||||
|
|
@ -776,9 +772,9 @@
|
|||
13B77E202DA457AA00126FDF /* AniListPushUpdates.swift in Sources */,
|
||||
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
|
||||
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */,
|
||||
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */,
|
||||
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */,
|
||||
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */,
|
||||
72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */,
|
||||
0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */,
|
||||
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
|
||||
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */,
|
||||
|
|
|
|||
Loading…
Reference in a new issue