mirror of
https://github.com/cranci1/Sora.git
synced 2026-05-06 02:09:06 +00:00
1757 lines
79 KiB
Swift
1757 lines
79 KiB
Swift
//
|
|
// JSController-Downloads.swift
|
|
// Sora
|
|
//
|
|
// Created by doomsboygaming on 5/22/25
|
|
//
|
|
|
|
import Foundation
|
|
import AVKit
|
|
import AVFoundation
|
|
import SwiftUI
|
|
|
|
/// Enumeration of different download notification types to enable selective UI updates
|
|
enum DownloadNotificationType: String {
|
|
case progress = "downloadProgressChanged" // Progress updates during download (no cache clearing needed)
|
|
case statusChange = "downloadStatusChanged" // Download started/queued/cancelled (no cache clearing needed)
|
|
case completed = "downloadCompleted" // Download finished (cache clearing needed)
|
|
case deleted = "downloadDeleted" // Asset deleted (cache clearing needed)
|
|
case libraryChange = "downloadLibraryChanged" // Library updated (cache clearing needed)
|
|
case cleanup = "downloadCleanup" // Cleanup operations (cache clearing needed)
|
|
}
|
|
|
|
// Extension for download functionality
|
|
extension JSController {
|
|
|
|
// MARK: - Download Session Setup
|
|
|
|
// Class-level property to track asset validation
|
|
private static var hasValidatedAssets = false
|
|
|
|
// MARK: - Progress Update Debouncing
|
|
|
|
/// Tracks the last time a progress notification was sent for each download
|
|
private static var lastProgressUpdateTime: [UUID: Date] = [:]
|
|
|
|
/// Minimum time interval between progress notifications (in seconds)
|
|
private static let progressUpdateInterval: TimeInterval = 0.5 // Max 2 updates per second
|
|
|
|
/// Pending progress updates to batch and send
|
|
private static var pendingProgressUpdates: [UUID: (progress: Double, episodeNumber: Int?)] = [:]
|
|
|
|
/// Timer for batched progress updates
|
|
private static var progressUpdateTimer: Timer?
|
|
|
|
func initializeDownloadSession() {
|
|
#if targetEnvironment(simulator)
|
|
Logger.shared.log("Download Sessions are not available on Simulator", type: "Download")
|
|
#else
|
|
Task {
|
|
let sessionIdentifier = "hls-downloader-\(UUID().uuidString)"
|
|
|
|
let configuration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
|
|
|
|
configuration.allowsCellularAccess = true
|
|
configuration.shouldUseExtendedBackgroundIdleMode = true
|
|
configuration.waitsForConnectivity = true
|
|
|
|
await MainActor.run {
|
|
self.downloadURLSession = AVAssetDownloadURLSession(
|
|
configuration: configuration,
|
|
assetDownloadDelegate: self,
|
|
delegateQueue: .main
|
|
)
|
|
|
|
Logger.shared.log("Download session initialized with ID: \(sessionIdentifier)", type: "Download")
|
|
}
|
|
}
|
|
#endif
|
|
|
|
loadSavedAssets()
|
|
}
|
|
|
|
/// Sets up JavaScript download function if needed
|
|
func setupDownloadFunction() {
|
|
// No JavaScript-side setup needed for now
|
|
Logger.shared.log("Download function setup completed", type: "Download")
|
|
}
|
|
|
|
/// Helper function to post download notifications with proper naming
|
|
private func postDownloadNotification(_ type: DownloadNotificationType, userInfo: [String: Any]? = nil) {
|
|
DispatchQueue.main.async {
|
|
NotificationCenter.default.post(
|
|
name: NSNotification.Name(type.rawValue),
|
|
object: nil,
|
|
userInfo: userInfo
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Download Queue Management
|
|
|
|
/// Initiates a download for the specified URL with the given headers
|
|
/// - Parameters:
|
|
/// - url: The URL to download
|
|
/// - headers: HTTP headers to use for the request
|
|
/// - title: Optional title for the download (defaults to filename)
|
|
/// - imageURL: Optional image URL for the download
|
|
/// - isEpisode: Indicates if the download is for an episode
|
|
/// - showTitle: Optional show title for the episode (anime title)
|
|
/// - season: Optional season number for the episode
|
|
/// - episode: Optional episode number for the episode
|
|
/// - subtitleURL: Optional subtitle URL to download after video
|
|
/// - module: Optional module to determine streamType
|
|
/// - completionHandler: Optional callback for download status
|
|
func startDownload(
|
|
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,
|
|
module: ScrapingModule? = nil,
|
|
completionHandler: ((Bool, String) -> Void)? = nil
|
|
) {
|
|
// If a module is provided, use the stream type aware download
|
|
if let module = module {
|
|
// Use the stream type aware download method
|
|
downloadWithStreamTypeSupport(
|
|
url: url,
|
|
headers: headers,
|
|
title: title,
|
|
imageURL: imageURL,
|
|
module: module,
|
|
isEpisode: isEpisode,
|
|
showTitle: showTitle,
|
|
season: season,
|
|
episode: episode,
|
|
subtitleURL: subtitleURL,
|
|
showPosterURL: showPosterURL,
|
|
completionHandler: completionHandler
|
|
)
|
|
return
|
|
}
|
|
|
|
// Legacy path for downloads without a module - use AVAssetDownloadURLSession
|
|
// Create an asset with custom HTTP header fields for authorization
|
|
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
|
|
|
|
let downloadTitle = title ?? url.lastPathComponent
|
|
|
|
// Ensure we have a proper anime title for episodes
|
|
let animeTitle = isEpisode ? (showTitle ?? "Unknown Anime") : nil
|
|
|
|
// Create metadata for the download with proper anime title
|
|
let downloadType: DownloadType = isEpisode ? .episode : .movie
|
|
let assetMetadata = AssetMetadata(
|
|
title: downloadTitle,
|
|
overview: nil,
|
|
posterURL: imageURL, // Episode thumbnail
|
|
backdropURL: imageURL,
|
|
releaseDate: nil,
|
|
showTitle: animeTitle,
|
|
season: season,
|
|
episode: episode,
|
|
showPosterURL: showPosterURL // Main show poster
|
|
)
|
|
|
|
// Create the download ID now so we can use it for notifications
|
|
let downloadID = UUID()
|
|
|
|
// Create a download object with queued status
|
|
let download = JSActiveDownload(
|
|
id: downloadID,
|
|
originalURL: url,
|
|
progress: 0,
|
|
task: nil, // Task will be created when the download starts
|
|
urlSessionTask: nil,
|
|
queueStatus: .queued,
|
|
type: downloadType,
|
|
metadata: assetMetadata,
|
|
title: downloadTitle,
|
|
imageURL: imageURL,
|
|
subtitleURL: subtitleURL,
|
|
asset: asset,
|
|
headers: headers,
|
|
module: module
|
|
)
|
|
|
|
// Add to the download queue
|
|
downloadQueue.append(download)
|
|
|
|
// Immediately notify users about queued download
|
|
postDownloadNotification(.statusChange)
|
|
|
|
// If this is an episode, also post a progress update to force UI refresh with queued status
|
|
if let episodeNumber = download.metadata?.episode {
|
|
postDownloadNotification(.progress, userInfo: [
|
|
"episodeNumber": episodeNumber,
|
|
"progress": 0.0,
|
|
"status": "queued"
|
|
])
|
|
}
|
|
|
|
// Inform caller of success
|
|
completionHandler?(true, "Download queued")
|
|
|
|
// Process the queue if we're not already doing so
|
|
if !isProcessingQueue {
|
|
processDownloadQueue()
|
|
}
|
|
}
|
|
|
|
/// Process the download queue and start downloads as slots are available
|
|
func processDownloadQueue() {
|
|
// Set flag to prevent multiple concurrent processing
|
|
isProcessingQueue = true
|
|
|
|
// Check if download session is ready before processing queue
|
|
guard downloadURLSession != nil else {
|
|
Logger.shared.log("Download session not ready, deferring queue processing...", type: "Download")
|
|
isProcessingQueue = false
|
|
// Retry after a delay to allow session initialization
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
self?.processDownloadQueue()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Calculate how many more downloads we can start
|
|
let activeCount = activeDownloads.count
|
|
let slotsAvailable = max(0, maxConcurrentDownloads - activeCount)
|
|
|
|
if slotsAvailable > 0 && !downloadQueue.isEmpty {
|
|
// Get the next batch of downloads to start (up to available slots)
|
|
let nextBatch = Array(downloadQueue.prefix(slotsAvailable))
|
|
|
|
// Remove these from the queue
|
|
downloadQueue.removeFirst(min(slotsAvailable, downloadQueue.count))
|
|
|
|
// Force UI update for queue changes first
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
// Trigger @Published update for downloadQueue changes
|
|
self.objectWillChange.send()
|
|
|
|
// Post notification for queue status change
|
|
self.postDownloadNotification(.statusChange)
|
|
}
|
|
|
|
// Start each download with a small delay to ensure UI updates properly
|
|
for (index, queuedDownload) in nextBatch.enumerated() {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.1) { [weak self] in
|
|
self?.startQueuedDownload(queuedDownload)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we still have queued downloads, schedule another check
|
|
if !downloadQueue.isEmpty {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
|
self?.processDownloadQueue()
|
|
}
|
|
} else {
|
|
// No more queued downloads
|
|
isProcessingQueue = false
|
|
}
|
|
}
|
|
|
|
/// Start a previously queued download
|
|
private func startQueuedDownload(_ queuedDownload: JSActiveDownload) {
|
|
Logger.shared.log("Starting queued download: \(queuedDownload.title ?? queuedDownload.originalURL.lastPathComponent)", type: "Download")
|
|
|
|
// If we have a module, use the same method as manual downloads (this fixes the bug!)
|
|
if let module = queuedDownload.module {
|
|
Logger.shared.log("Using downloadWithStreamTypeSupport for queued download (same as manual downloads)", type: "Download")
|
|
|
|
// Use the exact same method that manual downloads use
|
|
downloadWithStreamTypeSupport(
|
|
url: queuedDownload.originalURL,
|
|
headers: queuedDownload.headers,
|
|
title: queuedDownload.title,
|
|
imageURL: queuedDownload.imageURL,
|
|
module: module,
|
|
isEpisode: queuedDownload.type == .episode,
|
|
showTitle: queuedDownload.metadata?.showTitle,
|
|
season: queuedDownload.metadata?.season,
|
|
episode: queuedDownload.metadata?.episode,
|
|
subtitleURL: queuedDownload.subtitleURL,
|
|
showPosterURL: queuedDownload.metadata?.showPosterURL,
|
|
completionHandler: { success, message in
|
|
if success {
|
|
Logger.shared.log("Queued download started successfully via downloadWithStreamTypeSupport", type: "Download")
|
|
} else {
|
|
Logger.shared.log("Queued download failed: \(message)", type: "Download")
|
|
}
|
|
}
|
|
)
|
|
return
|
|
}
|
|
|
|
// Legacy fallback for downloads without module (should rarely be used now)
|
|
Logger.shared.log("Using legacy download method for queued download (no module available)", type: "Download")
|
|
|
|
guard let asset = queuedDownload.asset else {
|
|
Logger.shared.log("Missing asset for queued download", type: "Download")
|
|
return
|
|
}
|
|
|
|
guard let downloadSession = downloadURLSession else {
|
|
Logger.shared.log("Download session not yet initialized, retrying in background...", type: "Download")
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.startQueuedDownload(queuedDownload)
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let task = downloadSession.makeAssetDownloadTask(
|
|
asset: asset,
|
|
assetTitle: queuedDownload.title ?? queuedDownload.originalURL.lastPathComponent,
|
|
assetArtworkData: nil,
|
|
options: nil
|
|
) else {
|
|
Logger.shared.log("Failed to create download task for queued download", type: "Download")
|
|
return
|
|
}
|
|
|
|
// Create a new download object with the task
|
|
let download = JSActiveDownload(
|
|
id: queuedDownload.id,
|
|
originalURL: queuedDownload.originalURL,
|
|
progress: 0,
|
|
task: task,
|
|
urlSessionTask: nil,
|
|
queueStatus: .downloading,
|
|
type: queuedDownload.type,
|
|
metadata: queuedDownload.metadata,
|
|
title: queuedDownload.title,
|
|
imageURL: queuedDownload.imageURL,
|
|
subtitleURL: queuedDownload.subtitleURL,
|
|
asset: asset,
|
|
headers: queuedDownload.headers,
|
|
module: queuedDownload.module,
|
|
aniListID: queuedDownload.aniListID,
|
|
malID: queuedDownload.malID,
|
|
isFiller: queuedDownload.isFiller
|
|
)
|
|
|
|
// Add to active downloads
|
|
activeDownloads.append(download)
|
|
activeDownloadMap[task] = download.id
|
|
|
|
// Start the download
|
|
task.resume()
|
|
Logger.shared.log("Queued download started: \(download.title ?? download.originalURL.lastPathComponent)", type: "Download")
|
|
|
|
// Save the download state
|
|
saveDownloadState()
|
|
|
|
// Force comprehensive UI updates on main thread
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
// Trigger @Published property updates
|
|
self.objectWillChange.send()
|
|
|
|
// Post general status change notification
|
|
self.postDownloadNotification(.statusChange)
|
|
|
|
// If this is an episode, post detailed progress update with downloading status
|
|
if let episodeNumber = download.metadata?.episode {
|
|
self.postDownloadNotification(.progress, userInfo: [
|
|
"episodeNumber": episodeNumber,
|
|
"progress": 0.0,
|
|
"status": "downloading"
|
|
])
|
|
|
|
// Also post a specific status change notification for this episode
|
|
NotificationCenter.default.post(
|
|
name: NSNotification.Name("episodeStatusChanged"),
|
|
object: nil,
|
|
userInfo: [
|
|
"episodeNumber": episodeNumber,
|
|
"showTitle": download.metadata?.showTitle ?? "",
|
|
"status": "downloading",
|
|
"downloadId": download.id.uuidString
|
|
]
|
|
)
|
|
}
|
|
|
|
// Additional UI refresh notification
|
|
NotificationCenter.default.post(
|
|
name: NSNotification.Name("forceUIRefresh"),
|
|
object: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Clean up a download task when it's completed or failed
|
|
private func cleanupDownloadTask(_ task: URLSessionTask) {
|
|
guard let downloadID = activeDownloadMap[task] else { return }
|
|
|
|
// Clean up MP4 progress observations if this is an MP4 download
|
|
if task is AVAssetDownloadTask {
|
|
cleanupMP4ProgressObservation(for: downloadID)
|
|
}
|
|
|
|
activeDownloads.removeAll { $0.id == downloadID }
|
|
activeDownloadMap.removeValue(forKey: task)
|
|
|
|
// Clean up cancelled download tracking
|
|
cancelledDownloadIDs.remove(downloadID)
|
|
|
|
saveDownloadState()
|
|
|
|
Logger.shared.log("Cleaned up download task", type: "Download")
|
|
// Start processing the queue again if we have pending downloads
|
|
if !downloadQueue.isEmpty && !isProcessingQueue {
|
|
processDownloadQueue()
|
|
}
|
|
}
|
|
|
|
/// Update download progress
|
|
func updateDownloadProgress(task: AVAssetDownloadTask, progress: Double) {
|
|
guard let downloadID = activeDownloadMap[task] else { return }
|
|
|
|
// Clamp progress between 0 and 1
|
|
let finalProgress = min(max(progress, 0.0), 1.0)
|
|
|
|
// Find and update the download progress
|
|
if let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) {
|
|
let download = activeDownloads[downloadIndex]
|
|
let previousProgress = download.progress
|
|
activeDownloads[downloadIndex].progress = finalProgress
|
|
|
|
// Send notifications for progress updates to ensure smooth real-time updates
|
|
// Send notification if:
|
|
// 1. Progress increased by at least 0.5% (0.005) for very smooth updates
|
|
// 2. Download completed (reached 100%)
|
|
// 3. This is the first progress update (from 0)
|
|
// 4. It's been a significant change (covers edge cases)
|
|
let progressDifference = finalProgress - previousProgress
|
|
let shouldUpdate = progressDifference >= 0.005 || finalProgress >= 1.0 || previousProgress == 0.0
|
|
|
|
if shouldUpdate {
|
|
// Post progress update notification (no cache clearing needed for progress updates)
|
|
postDownloadNotification(.progress)
|
|
|
|
// Also post detailed progress update with episode number if it's an episode
|
|
if let episodeNumber = download.metadata?.episode {
|
|
let status = finalProgress >= 1.0 ? "completed" : "downloading"
|
|
postDownloadNotification(.progress, userInfo: [
|
|
"episodeNumber": episodeNumber,
|
|
"progress": finalProgress,
|
|
"status": status
|
|
])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Downloads a subtitle file and associates it with an asset
|
|
/// - Parameters:
|
|
/// - subtitleURL: The URL of the subtitle file to download
|
|
/// - assetID: The ID of the asset this subtitle is associated with
|
|
func downloadSubtitle(subtitleURL: URL, assetID: String) {
|
|
Logger.shared.log("Downloading subtitle from: \(subtitleURL.absoluteString) for asset ID: \(assetID)", type: "Download")
|
|
|
|
// Check if this asset belongs to a cancelled download - if so, don't download subtitle
|
|
if let assetUUID = UUID(uuidString: assetID), cancelledDownloadIDs.contains(assetUUID) {
|
|
Logger.shared.log("Skipping subtitle download for cancelled download: \(assetID)", type: "Download")
|
|
return
|
|
}
|
|
|
|
let session = URLSession.shared
|
|
var request = URLRequest(url: subtitleURL)
|
|
|
|
// Add more comprehensive headers for subtitle downloads
|
|
request.addValue("*/*", forHTTPHeaderField: "Accept")
|
|
request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
|
|
request.addValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode")
|
|
request.addValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
|
|
|
|
// Extract domain from subtitle URL to use as referer
|
|
if let host = subtitleURL.host {
|
|
let referer = "https://\(host)/"
|
|
request.addValue(referer, forHTTPHeaderField: "Referer")
|
|
request.addValue(referer, forHTTPHeaderField: "Origin")
|
|
}
|
|
|
|
Logger.shared.log("Subtitle download request headers: \(request.allHTTPHeaderFields ?? [:])", type: "Download")
|
|
|
|
// Create a task to download the subtitle file
|
|
let task = session.downloadTask(with: request) { [weak self] (tempURL, response, error) in
|
|
guard let self = self else {
|
|
Logger.shared.log("Self reference lost during subtitle download", type: "Download")
|
|
return
|
|
}
|
|
|
|
if let error = error {
|
|
Logger.shared.log("Subtitle download error: \(error.localizedDescription)", type: "Download")
|
|
return
|
|
}
|
|
|
|
guard let tempURL = tempURL else {
|
|
Logger.shared.log("No temporary URL received for subtitle download", type: "Download")
|
|
return
|
|
}
|
|
|
|
guard let downloadDir = self.getPersistentDownloadDirectory() else {
|
|
Logger.shared.log("Failed to get persistent download directory for subtitle", type: "Download")
|
|
return
|
|
}
|
|
|
|
// Log response details for debugging
|
|
if let httpResponse = response as? HTTPURLResponse {
|
|
Logger.shared.log("Subtitle download HTTP status: \(httpResponse.statusCode)", type: "Download")
|
|
Logger.shared.log("Subtitle download content type: \(httpResponse.mimeType ?? "unknown")", type: "Download")
|
|
}
|
|
|
|
// Try to read content to validate it's actually a subtitle file
|
|
do {
|
|
let subtitleData = try Data(contentsOf: tempURL)
|
|
let subtitleContent = String(data: subtitleData, encoding: .utf8) ?? ""
|
|
|
|
if subtitleContent.isEmpty {
|
|
Logger.shared.log("Warning: Subtitle file appears to be empty", type: "Download")
|
|
} else {
|
|
Logger.shared.log("Subtitle file contains \(subtitleData.count) bytes of data", type: "Download")
|
|
if subtitleContent.hasPrefix("WEBVTT") {
|
|
Logger.shared.log("Valid WebVTT subtitle detected", type: "Download")
|
|
} else if subtitleContent.contains(" --> ") {
|
|
Logger.shared.log("Subtitle file contains timing markers", type: "Download")
|
|
} else {
|
|
Logger.shared.log("Warning: Subtitle content doesn't appear to be in a recognized format", type: "Download")
|
|
}
|
|
}
|
|
} catch {
|
|
Logger.shared.log("Error reading subtitle content for validation: \(error.localizedDescription)", type: "Download")
|
|
}
|
|
|
|
// Determine file extension based on the content type or URL
|
|
let fileExtension: String
|
|
if let mimeType = response?.mimeType {
|
|
switch mimeType.lowercased() {
|
|
case "text/vtt", "text/webvtt":
|
|
fileExtension = "vtt"
|
|
case "text/srt", "application/x-subrip":
|
|
fileExtension = "srt"
|
|
default:
|
|
// Use original extension or default to vtt
|
|
fileExtension = subtitleURL.pathExtension.isEmpty ? "vtt" : subtitleURL.pathExtension
|
|
}
|
|
} else {
|
|
fileExtension = subtitleURL.pathExtension.isEmpty ? "vtt" : subtitleURL.pathExtension
|
|
}
|
|
|
|
// Create a filename for the subtitle using the asset ID
|
|
let localFilename = "subtitle-\(assetID).\(fileExtension)"
|
|
let localURL = downloadDir.appendingPathComponent(localFilename)
|
|
|
|
do {
|
|
// If file already exists, remove it
|
|
if FileManager.default.fileExists(atPath: localURL.path) {
|
|
try FileManager.default.removeItem(at: localURL)
|
|
Logger.shared.log("Removed existing subtitle file at \(localURL.path)", type: "Download")
|
|
}
|
|
|
|
// Move the downloaded file to the persistent location
|
|
try FileManager.default.moveItem(at: tempURL, to: localURL)
|
|
|
|
// Update the asset with the subtitle URL
|
|
self.updateAssetWithSubtitle(assetID: assetID, subtitleURL: subtitleURL, localSubtitleURL: localURL)
|
|
|
|
Logger.shared.log("Subtitle downloaded successfully: \(localURL.path)", type: "Download")
|
|
|
|
// Show success notification
|
|
DispatchQueue.main.async {
|
|
DropManager.shared.success("Subtitle downloaded successfully")
|
|
|
|
// Force a UI update for the episode cell
|
|
NotificationCenter.default.post(
|
|
name: NSNotification.Name("downloadStatusChanged"),
|
|
object: nil
|
|
)
|
|
|
|
// If this is an episode, also post a progress update to force UI refresh
|
|
if let asset = self.savedAssets.first(where: { $0.id.uuidString == assetID }),
|
|
let episodeNumber = asset.metadata?.episode {
|
|
NotificationCenter.default.post(
|
|
name: NSNotification.Name("downloadProgressUpdated"),
|
|
object: nil,
|
|
userInfo: [
|
|
"episodeNumber": episodeNumber,
|
|
"progress": 1.0
|
|
]
|
|
)
|
|
}
|
|
}
|
|
} catch {
|
|
Logger.shared.log("Error moving subtitle file: \(error.localizedDescription)", type: "Download")
|
|
}
|
|
}
|
|
|
|
task.resume()
|
|
Logger.shared.log("Subtitle download task started", type: "Download")
|
|
}
|
|
|
|
/// Updates an asset with subtitle information after subtitle download completes
|
|
/// - Parameters:
|
|
/// - assetID: The ID of the asset to update
|
|
/// - subtitleURL: The original subtitle URL
|
|
/// - localSubtitleURL: The local path where the subtitle file is stored
|
|
private func updateAssetWithSubtitle(assetID: String, subtitleURL: URL, localSubtitleURL: URL) {
|
|
// Find the asset in the saved assets array
|
|
if let index = savedAssets.firstIndex(where: { $0.id.uuidString == assetID }) {
|
|
// Create a new asset with the subtitle info (since struct is immutable)
|
|
let existingAsset = savedAssets[index]
|
|
let updatedAsset = DownloadedAsset(
|
|
id: existingAsset.id,
|
|
name: existingAsset.name,
|
|
downloadDate: existingAsset.downloadDate,
|
|
originalURL: existingAsset.originalURL,
|
|
localURL: existingAsset.localURL,
|
|
type: existingAsset.type,
|
|
metadata: existingAsset.metadata,
|
|
subtitleURL: existingAsset.subtitleURL,
|
|
localSubtitleURL: localSubtitleURL
|
|
)
|
|
|
|
// Dispatch the UI update to the main thread
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
// Replace the old asset with the updated one
|
|
self.savedAssets[index] = updatedAsset
|
|
|
|
// Save the updated assets array
|
|
self.saveAssets()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Asset Management
|
|
|
|
/// Load saved assets from UserDefaults
|
|
func loadSavedAssets() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.savedAssets = DownloadPersistence.load()
|
|
self?.objectWillChange.send()
|
|
}
|
|
}
|
|
|
|
/// Migrates any existing .movpkg files from Documents directory to the persistent location
|
|
private func migrateExistingFilesToPersistentStorage() {
|
|
let fileManager = FileManager.default
|
|
|
|
// Get Documents and Application Support directories
|
|
guard let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
|
|
let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
|
return
|
|
}
|
|
|
|
// Create persistent downloads directory if it doesn't exist
|
|
let persistentDir = appSupportDir.appendingPathComponent("SoraDownloads", isDirectory: true)
|
|
do {
|
|
if !fileManager.fileExists(atPath: persistentDir.path) {
|
|
try fileManager.createDirectory(at: persistentDir, withIntermediateDirectories: true)
|
|
Logger.shared.log("Created persistent download directory at \(persistentDir.path)", type: "Download")
|
|
}
|
|
|
|
// Find any video files (.movpkg, .mp4) in the Documents directory
|
|
let files = try fileManager.contentsOfDirectory(at: documentsDir, includingPropertiesForKeys: nil)
|
|
let videoFiles = files.filter { ["movpkg", "mp4"].contains($0.pathExtension.lowercased()) }
|
|
|
|
if !videoFiles.isEmpty {
|
|
Logger.shared.log("Found \(videoFiles.count) video files in Documents directory to migrate", type: "Download")
|
|
|
|
// Migrate each file
|
|
for fileURL in videoFiles {
|
|
let filename = fileURL.lastPathComponent
|
|
let destinationURL = persistentDir.appendingPathComponent(filename)
|
|
|
|
// Check if file already exists in destination
|
|
if fileManager.fileExists(atPath: destinationURL.path) {
|
|
// Generate a unique name to avoid conflicts
|
|
let uniqueID = UUID().uuidString
|
|
let newDestinationURL = persistentDir.appendingPathComponent("\(filename)-\(uniqueID)")
|
|
try fileManager.copyItem(at: fileURL, to: newDestinationURL)
|
|
Logger.shared.log("Migrated file with unique name: \(filename) → \(newDestinationURL.lastPathComponent)", type: "Download")
|
|
} else {
|
|
// Move the file to the persistent directory
|
|
try fileManager.copyItem(at: fileURL, to: destinationURL)
|
|
Logger.shared.log("Migrated file: \(filename)", type: "Download")
|
|
}
|
|
}
|
|
} else {
|
|
Logger.shared.log("No video files found in Documents directory for migration", type: "Download")
|
|
}
|
|
} catch {
|
|
Logger.shared.log("Error during migration: \(error.localizedDescription)", type: "Download")
|
|
}
|
|
}
|
|
|
|
/// Validates that saved assets exist and updates their locations if needed
|
|
private func validateAndUpdateAssetLocations() {
|
|
let fileManager = FileManager.default
|
|
var updatedAssets = false
|
|
var assetsToRemove: [UUID] = []
|
|
|
|
// Check each asset and update its location if needed
|
|
for (index, asset) in savedAssets.enumerated() {
|
|
var needsUpdate = false
|
|
var updatedAsset = asset
|
|
|
|
// Check if the video file exists at the stored path
|
|
if !fileManager.fileExists(atPath: asset.localURL.path) {
|
|
Logger.shared.log("Asset file not found at saved path: \(asset.localURL.path)", type: "Download")
|
|
|
|
// Try to find the file in the persistent directory
|
|
if let persistentURL = findAssetInPersistentStorage(assetName: asset.name) {
|
|
// Update the asset with the new video URL
|
|
Logger.shared.log("Found asset in persistent storage: \(persistentURL.path)", type: "Download")
|
|
updatedAsset = DownloadedAsset(
|
|
id: asset.id,
|
|
name: asset.name,
|
|
downloadDate: asset.downloadDate,
|
|
originalURL: asset.originalURL,
|
|
localURL: persistentURL,
|
|
type: asset.type,
|
|
metadata: asset.metadata,
|
|
subtitleURL: asset.subtitleURL,
|
|
localSubtitleURL: asset.localSubtitleURL
|
|
)
|
|
needsUpdate = true
|
|
} else {
|
|
// If we can't find the video file, mark it for removal
|
|
Logger.shared.log("Asset not found in persistent storage. Marking for removal: \(asset.name)", type: "Download")
|
|
assetsToRemove.append(asset.id)
|
|
updatedAssets = true
|
|
continue // Skip subtitle validation for assets being removed
|
|
}
|
|
}
|
|
|
|
// Check if the subtitle file exists (if one is expected)
|
|
if let localSubtitleURL = updatedAsset.localSubtitleURL {
|
|
if !fileManager.fileExists(atPath: localSubtitleURL.path) {
|
|
Logger.shared.log("Subtitle file not found at saved path: \(localSubtitleURL.path)", type: "Download")
|
|
|
|
// Try to find the subtitle file in the persistent directory
|
|
if let foundSubtitleURL = findSubtitleInPersistentStorage(assetID: updatedAsset.id.uuidString) {
|
|
Logger.shared.log("Found subtitle file in persistent storage: \(foundSubtitleURL.path)", type: "Download")
|
|
updatedAsset = DownloadedAsset(
|
|
id: updatedAsset.id,
|
|
name: updatedAsset.name,
|
|
downloadDate: updatedAsset.downloadDate,
|
|
originalURL: updatedAsset.originalURL,
|
|
localURL: updatedAsset.localURL,
|
|
type: updatedAsset.type,
|
|
metadata: updatedAsset.metadata,
|
|
subtitleURL: updatedAsset.subtitleURL,
|
|
localSubtitleURL: foundSubtitleURL
|
|
)
|
|
needsUpdate = true
|
|
} else {
|
|
// Subtitle file is missing - remove the subtitle reference but keep the video
|
|
Logger.shared.log("Subtitle file not found in persistent storage for asset: \(updatedAsset.name)", type: "Download")
|
|
updatedAsset = DownloadedAsset(
|
|
id: updatedAsset.id,
|
|
name: updatedAsset.name,
|
|
downloadDate: updatedAsset.downloadDate,
|
|
originalURL: updatedAsset.originalURL,
|
|
localURL: updatedAsset.localURL,
|
|
type: updatedAsset.type,
|
|
metadata: updatedAsset.metadata,
|
|
subtitleURL: updatedAsset.subtitleURL,
|
|
localSubtitleURL: nil // Remove the invalid subtitle path
|
|
)
|
|
needsUpdate = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update the asset if any changes were made
|
|
if needsUpdate {
|
|
savedAssets[index] = updatedAsset
|
|
updatedAssets = true
|
|
Logger.shared.log("Updated asset paths for: \(updatedAsset.name)", type: "Download")
|
|
}
|
|
}
|
|
|
|
// Remove assets that don't exist anymore
|
|
if !assetsToRemove.isEmpty {
|
|
let countBefore = savedAssets.count
|
|
savedAssets.removeAll { assetsToRemove.contains($0.id) }
|
|
Logger.shared.log("Removed \(countBefore - savedAssets.count) missing assets from the library", type: "Download")
|
|
|
|
// Notify observers of the change (library cleanup requires cache clearing)
|
|
postDownloadNotification(.cleanup)
|
|
}
|
|
|
|
// Save the updated asset information if changes were made
|
|
if updatedAssets {
|
|
saveAssets()
|
|
Logger.shared.log("Asset validation complete. Updated \(updatedAssets ? "some" : "no") asset paths.", type: "Download")
|
|
}
|
|
}
|
|
|
|
/// Attempts to find an asset in the persistent storage directory
|
|
/// - Parameter assetName: The name of the asset to find
|
|
/// - Returns: URL to the found asset or nil if not found
|
|
private func findAssetInPersistentStorage(assetName: String) -> URL? {
|
|
let fileManager = FileManager.default
|
|
|
|
// Get Application Support directory
|
|
guard let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
|
return nil
|
|
}
|
|
|
|
// Path to our downloads directory
|
|
let downloadDir = appSupportDir.appendingPathComponent("SoraDownloads", isDirectory: true)
|
|
|
|
// Check if directory exists
|
|
guard fileManager.fileExists(atPath: downloadDir.path) else {
|
|
return nil
|
|
}
|
|
|
|
do {
|
|
// Get all files in the directory
|
|
let files = try fileManager.contentsOfDirectory(at: downloadDir, includingPropertiesForKeys: nil)
|
|
|
|
// Try to find a video file that contains the asset name
|
|
for file in files where ["movpkg", "mp4"].contains(file.pathExtension.lowercased()) {
|
|
let filename = file.lastPathComponent
|
|
|
|
// If the filename contains the asset name, it's likely our file
|
|
if filename.contains(assetName) || assetName.contains(filename.components(separatedBy: "-").first ?? "") {
|
|
return file
|
|
}
|
|
}
|
|
} catch {
|
|
Logger.shared.log("Error searching for asset in persistent storage: \(error.localizedDescription)", type: "Download")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Attempts to find a subtitle file in the persistent storage directory
|
|
/// - Parameter assetID: The ID of the asset to find subtitles for
|
|
/// - Returns: URL to the found subtitle file or nil if not found
|
|
private func findSubtitleInPersistentStorage(assetID: String) -> URL? {
|
|
let fileManager = FileManager.default
|
|
|
|
// Get Application Support directory
|
|
guard let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
|
Logger.shared.log("Cannot access Application Support directory for subtitle search", type: "Download")
|
|
return nil
|
|
}
|
|
|
|
// Path to our downloads directory
|
|
let downloadDir = appSupportDir.appendingPathComponent("SoraDownloads", isDirectory: true)
|
|
|
|
// Check if directory exists
|
|
guard fileManager.fileExists(atPath: downloadDir.path) else {
|
|
Logger.shared.log("Download directory does not exist for subtitle search", type: "Download")
|
|
return nil
|
|
}
|
|
|
|
do {
|
|
// Get all files in the directory
|
|
let files = try fileManager.contentsOfDirectory(at: downloadDir, includingPropertiesForKeys: nil)
|
|
|
|
// Common subtitle file extensions
|
|
let subtitleExtensions = ["vtt", "srt", "webvtt"]
|
|
|
|
// Try to find a subtitle file that matches the asset ID pattern
|
|
for file in files {
|
|
let filename = file.lastPathComponent
|
|
let fileExtension = file.pathExtension.lowercased()
|
|
|
|
// Check if this is a subtitle file with the correct naming pattern
|
|
if subtitleExtensions.contains(fileExtension) &&
|
|
filename.hasPrefix("subtitle-\(assetID).") {
|
|
Logger.shared.log("Found subtitle file for asset \(assetID): \(filename)", type: "Download")
|
|
return file
|
|
}
|
|
}
|
|
|
|
Logger.shared.log("No subtitle file found for asset ID: \(assetID)", type: "Download")
|
|
} catch {
|
|
Logger.shared.log("Error searching for subtitle in persistent storage: \(error.localizedDescription)", type: "Download")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Save assets to UserDefaults
|
|
func saveAssets() {
|
|
DownloadPersistence.save(savedAssets)
|
|
Logger.shared.log("Saved \(savedAssets.count) assets to persistence", type: "Download")
|
|
}
|
|
|
|
/// Save the current state of downloads
|
|
private func saveDownloadState() {
|
|
// Only metadata needs to be saved since the tasks themselves can't be serialized
|
|
let downloadInfo = activeDownloads.map { download -> [String: Any] in
|
|
return [
|
|
"id": download.id.uuidString,
|
|
"url": download.originalURL.absoluteString,
|
|
"type": download.type.rawValue,
|
|
"title": download.title ?? download.originalURL.lastPathComponent
|
|
]
|
|
}
|
|
|
|
UserDefaults.standard.set(downloadInfo, forKey: "activeDownloads")
|
|
Logger.shared.log("Saved download state with \(downloadInfo.count) active downloads", type: "Download")
|
|
}
|
|
|
|
/// Delete an asset
|
|
func deleteAsset(_ asset: DownloadedAsset) {
|
|
do {
|
|
if FileManager.default.fileExists(atPath: asset.localURL.path) {
|
|
try FileManager.default.removeItem(at: asset.localURL)
|
|
}
|
|
if let subtitleURL = asset.localSubtitleURL, FileManager.default.fileExists(atPath: subtitleURL.path) {
|
|
try FileManager.default.removeItem(at: subtitleURL)
|
|
} else {
|
|
if let downloadDir = getPersistentDownloadDirectory() {
|
|
let assetID = asset.id.uuidString
|
|
let subtitleExtensions = ["vtt", "srt", "webvtt"]
|
|
for ext in subtitleExtensions {
|
|
let candidate = downloadDir.appendingPathComponent("subtitle-\(assetID).\(ext)")
|
|
if FileManager.default.fileExists(atPath: candidate.path) {
|
|
try? FileManager.default.removeItem(at: candidate)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
DownloadPersistence.delete(id: asset.id)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.savedAssets = DownloadPersistence.load()
|
|
self?.objectWillChange.send()
|
|
}
|
|
postDownloadNotification(.deleted)
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
/// Remove an asset from the library without deleting the file
|
|
func removeAssetFromLibrary(_ asset: DownloadedAsset) {
|
|
// Only remove the entry from savedAssets
|
|
DownloadPersistence.delete(id: asset.id)
|
|
Logger.shared.log("Removed asset from library (file preserved): \(asset.name)", type: "Download")
|
|
|
|
// Notify observers that the library changed (cache clearing needed)
|
|
postDownloadNotification(.libraryChange)
|
|
}
|
|
|
|
/// Returns the directory for persistent downloads
|
|
func getPersistentDownloadDirectory() -> URL? {
|
|
let fileManager = FileManager.default
|
|
|
|
// Get Application Support directory
|
|
guard let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
|
Logger.shared.log("Cannot access Application Support directory", type: "Download")
|
|
return nil
|
|
}
|
|
|
|
// Create a dedicated subdirectory for our downloads if it doesn't exist
|
|
let downloadDir = appSupportDir.appendingPathComponent("SoraDownloads", isDirectory: true)
|
|
|
|
do {
|
|
if !fileManager.fileExists(atPath: downloadDir.path) {
|
|
try fileManager.createDirectory(at: downloadDir, withIntermediateDirectories: true)
|
|
Logger.shared.log("Created persistent download directory at \(downloadDir.path)", type: "Download")
|
|
}
|
|
return downloadDir
|
|
} catch {
|
|
Logger.shared.log("Error creating download directory: \(error.localizedDescription)", type: "Download")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Checks if an asset file exists before attempting to play it
|
|
/// - Parameter asset: The asset to verify
|
|
/// - Returns: true if the file exists, false otherwise
|
|
func verifyAssetFileExists(_ asset: DownloadedAsset) -> Bool {
|
|
let fileExists = FileManager.default.fileExists(atPath: asset.localURL.path)
|
|
|
|
if !fileExists {
|
|
// Try to find the file in a different location
|
|
if let newLocation = findAssetInPersistentStorage(assetName: asset.name) {
|
|
// Update the asset with the new location
|
|
if let index = savedAssets.firstIndex(where: { $0.id == asset.id }) {
|
|
savedAssets[index] = DownloadedAsset(
|
|
id: asset.id,
|
|
name: asset.name,
|
|
downloadDate: asset.downloadDate,
|
|
originalURL: asset.originalURL,
|
|
localURL: newLocation,
|
|
type: asset.type,
|
|
metadata: asset.metadata,
|
|
subtitleURL: asset.subtitleURL,
|
|
localSubtitleURL: asset.localSubtitleURL
|
|
)
|
|
saveAssets()
|
|
return true
|
|
}
|
|
} else {
|
|
// File is truly missing - remove it from saved assets
|
|
savedAssets.removeAll { $0.id == asset.id }
|
|
saveAssets()
|
|
|
|
// Show an error to the user
|
|
DispatchQueue.main.async {
|
|
DropManager.shared.error("File not found: \(asset.name)")
|
|
}
|
|
}
|
|
}
|
|
|
|
return fileExists
|
|
}
|
|
|
|
/// Determines if a new download will start immediately or be queued
|
|
/// - Returns: true if the download will start immediately, false if it will be queued
|
|
func willDownloadStartImmediately() -> Bool {
|
|
let activeCount = activeDownloads.count
|
|
let slotsAvailable = max(0, maxConcurrentDownloads - activeCount)
|
|
return slotsAvailable > 0
|
|
}
|
|
|
|
/// Checks if an episode is already downloaded or currently being downloaded
|
|
/// - Parameters:
|
|
/// - showTitle: The title of the show (anime title)
|
|
/// - episodeNumber: The episode number
|
|
/// - season: The season number (defaults to 1)
|
|
/// - Returns: Download status indicating if the episode is downloaded, being downloaded, or not downloaded
|
|
func isEpisodeDownloadedOrInProgress(
|
|
showTitle: String,
|
|
episodeNumber: Int,
|
|
season: Int = 1
|
|
) -> EpisodeDownloadStatus {
|
|
// First check if it's already downloaded
|
|
for asset in savedAssets {
|
|
// Skip if not an episode or show title doesn't match
|
|
if asset.type != .episode { continue }
|
|
guard let metadata = asset.metadata,
|
|
let assetShowTitle = metadata.showTitle,
|
|
assetShowTitle.caseInsensitiveCompare(showTitle) == .orderedSame else {
|
|
continue
|
|
}
|
|
|
|
// Check episode number
|
|
let assetEpisode = metadata.episode ?? 0
|
|
let assetSeason = metadata.season ?? 1
|
|
|
|
if assetEpisode == episodeNumber && assetSeason == season {
|
|
return .downloaded(asset)
|
|
}
|
|
}
|
|
|
|
// Then check if it's currently being downloaded (actively downloading)
|
|
for download in activeDownloads {
|
|
// Skip if not an episode or show title doesn't match
|
|
if download.type != .episode { continue }
|
|
guard let metadata = download.metadata,
|
|
let assetShowTitle = metadata.showTitle,
|
|
assetShowTitle.caseInsensitiveCompare(showTitle) == .orderedSame else {
|
|
continue
|
|
}
|
|
|
|
// Check episode number
|
|
let assetEpisode = metadata.episode ?? 0
|
|
let assetSeason = metadata.season ?? 1
|
|
|
|
if assetEpisode == episodeNumber && assetSeason == season {
|
|
return .downloading(download)
|
|
}
|
|
}
|
|
|
|
// Finally check if it's queued for download
|
|
for download in downloadQueue {
|
|
// Skip if not an episode or show title doesn't match
|
|
if download.type != .episode { continue }
|
|
guard let metadata = download.metadata,
|
|
let assetShowTitle = metadata.showTitle,
|
|
assetShowTitle.caseInsensitiveCompare(showTitle) == .orderedSame else {
|
|
continue
|
|
}
|
|
|
|
// Check episode number
|
|
let assetEpisode = metadata.episode ?? 0
|
|
let assetSeason = metadata.season ?? 1
|
|
|
|
if assetEpisode == episodeNumber && assetSeason == season {
|
|
return .downloading(download)
|
|
}
|
|
}
|
|
|
|
// Not downloaded or being downloaded
|
|
return .notDownloaded
|
|
}
|
|
|
|
/// Cancel a queued download
|
|
func cancelQueuedDownload(_ downloadID: UUID) {
|
|
downloadQueue.removeAll { $0.id == downloadID }
|
|
|
|
// Notify of the cancellation
|
|
postDownloadNotification(.statusChange)
|
|
|
|
Logger.shared.log("Cancelled queued download: \(downloadID)", type: "Download")
|
|
}
|
|
|
|
/// Cancel an active download that is currently in progress
|
|
func cancelActiveDownload(_ downloadID: UUID) {
|
|
// First, immediately mark this download as cancelled to prevent any completion processing
|
|
cancelledDownloadIDs.insert(downloadID)
|
|
|
|
// Find the active download and cancel its task
|
|
if let activeDownload = activeDownloads.first(where: { $0.id == downloadID }) {
|
|
let downloadTitle = activeDownload.title ?? activeDownload.originalURL.lastPathComponent
|
|
|
|
if let task = activeDownload.task {
|
|
// M3U8 download - cancel AVAssetDownloadTask
|
|
task.cancel()
|
|
} else if let urlTask = activeDownload.urlSessionTask {
|
|
// MP4 download - cancel URLSessionDownloadTask
|
|
urlTask.cancel()
|
|
}
|
|
|
|
// Show notification
|
|
DropManager.shared.info("Download cancelled: \(downloadTitle)")
|
|
|
|
Logger.shared.log("Cancelled active download: \(downloadTitle)", type: "Download")
|
|
}
|
|
}
|
|
|
|
/// Pause an MP4 download
|
|
func pauseMP4Download(_ downloadID: UUID) {
|
|
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
|
|
Logger.shared.log("MP4 Download not found for pausing: \(downloadID)", type: "Download")
|
|
return
|
|
}
|
|
|
|
let download = activeDownloads[index]
|
|
guard let urlTask = download.urlSessionTask else {
|
|
Logger.shared.log("No URL session task found for MP4 download: \(downloadID)", type: "Download")
|
|
return
|
|
}
|
|
|
|
urlTask.suspend()
|
|
Logger.shared.log("Paused MP4 download: \(download.title ?? download.originalURL.lastPathComponent)", type: "Download")
|
|
|
|
// Notify UI of status change
|
|
postDownloadNotification(.statusChange)
|
|
}
|
|
|
|
/// Resume an MP4 download
|
|
func resumeMP4Download(_ downloadID: UUID) {
|
|
guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
|
|
Logger.shared.log("MP4 Download not found for resuming: \(downloadID)", type: "Download")
|
|
return
|
|
}
|
|
|
|
let download = activeDownloads[index]
|
|
guard let urlTask = download.urlSessionTask else {
|
|
Logger.shared.log("No URL session task found for MP4 download: \(downloadID)", type: "Download")
|
|
return
|
|
}
|
|
|
|
urlTask.resume()
|
|
Logger.shared.log("Resumed MP4 download: \(download.title ?? download.originalURL.lastPathComponent)", type: "Download")
|
|
|
|
// Notify UI of status change
|
|
postDownloadNotification(.statusChange)
|
|
}
|
|
}
|
|
|
|
// MARK: - AVAssetDownloadDelegate
|
|
extension JSController: AVAssetDownloadDelegate {
|
|
|
|
/// Called when a download task finishes downloading the asset
|
|
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {
|
|
guard let downloadID = activeDownloadMap[assetDownloadTask],
|
|
let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
|
|
Logger.shared.log("Download task finished but couldn't find associated download", type: "Download")
|
|
return
|
|
}
|
|
|
|
// Check if this download was cancelled - if so, don't process completion
|
|
if cancelledDownloadIDs.contains(downloadID) {
|
|
Logger.shared.log("Ignoring completion for cancelled download: \(downloadID)", type: "Download")
|
|
// Delete any temporary files that may have been created
|
|
try? FileManager.default.removeItem(at: location)
|
|
return
|
|
}
|
|
|
|
let download = activeDownloads[downloadIndex]
|
|
|
|
// Move the downloaded file to Application Support directory for persistence
|
|
guard let persistentURL = moveToApplicationSupportDirectory(from: location, filename: download.title ?? download.originalURL.lastPathComponent, originalURL: download.originalURL) else {
|
|
Logger.shared.log("Failed to move downloaded file to persistent storage", type: "Download")
|
|
return
|
|
}
|
|
|
|
// Create a new DownloadedAsset with metadata from the active download
|
|
let newAsset = DownloadedAsset(
|
|
name: download.title ?? download.originalURL.lastPathComponent,
|
|
downloadDate: Date(),
|
|
originalURL: download.originalURL,
|
|
localURL: persistentURL,
|
|
type: download.type,
|
|
metadata: download.metadata, // Use the metadata we created when starting the download
|
|
subtitleURL: download.subtitleURL // Store the subtitle URL, but localSubtitleURL will be nil until subtitle is downloaded
|
|
)
|
|
|
|
// Add to saved assets and save
|
|
DownloadPersistence.upsert(newAsset)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.savedAssets = DownloadPersistence.load()
|
|
self?.objectWillChange.send()
|
|
}
|
|
|
|
// If there's a subtitle URL, download it now that the video is saved
|
|
// Also fetch OP/ED skip timestamps in parallel and save simple sidecar JSON next to the video
|
|
if download.metadata?.episode != nil && download.type == .episode {
|
|
fetchSkipTimestampsFor(request: download, persistentURL: persistentURL) { ok in
|
|
if ok {
|
|
Logger.shared.log("[SkipSidecar] Saved OP/ED sidecar for episode \(download.metadata?.episode ?? -1) at: \(persistentURL.path)", type: "Download")
|
|
} else {
|
|
Logger.shared.log("[SkipSidecar] Failed to save sidecar for episode \(download.metadata?.episode ?? -1)", type: "Download")
|
|
}
|
|
}
|
|
}
|
|
|
|
if let subtitleURL = download.subtitleURL {
|
|
downloadSubtitle(subtitleURL: subtitleURL, assetID: newAsset.id.uuidString)
|
|
} else {
|
|
// No subtitle URL, so we can consider the download complete
|
|
// Notify that download completed (cache clearing needed for new file)
|
|
postDownloadNotification(.completed)
|
|
|
|
// If this is an episode, also post a progress update to force UI refresh
|
|
if let episodeNumber = download.metadata?.episode {
|
|
postDownloadNotification(.progress, userInfo: [
|
|
"episodeNumber": episodeNumber,
|
|
"progress": 1.0,
|
|
"status": "completed"
|
|
])
|
|
}
|
|
}
|
|
|
|
// Clean up the download task
|
|
cleanupDownloadTask(assetDownloadTask)
|
|
|
|
Logger.shared.log("Download completed and moved to persistent storage: \(newAsset.name)", type: "Download")
|
|
}
|
|
|
|
/// Moves a downloaded file to Application Support directory to preserve it across app updates
|
|
/// - Parameters:
|
|
/// - location: The original location from the download task
|
|
/// - filename: Name to use for the file
|
|
/// - originalURL: The original download URL to determine proper file extension
|
|
/// - Returns: URL to the new persistent location or nil if move failed
|
|
private func moveToApplicationSupportDirectory(from location: URL, filename: String, originalURL: URL) -> URL? {
|
|
let fileManager = FileManager.default
|
|
|
|
// Get Application Support directory
|
|
guard let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
|
Logger.shared.log("Cannot access Application Support directory", type: "Download")
|
|
return nil
|
|
}
|
|
|
|
// Create a dedicated subdirectory for our downloads if it doesn't exist
|
|
let downloadDir = appSupportDir.appendingPathComponent("SoraDownloads", isDirectory: true)
|
|
|
|
do {
|
|
if !fileManager.fileExists(atPath: downloadDir.path) {
|
|
try fileManager.createDirectory(at: downloadDir, withIntermediateDirectories: true)
|
|
Logger.shared.log("Created persistent download directory at \(downloadDir.path)", type: "Download")
|
|
}
|
|
|
|
// Generate unique filename with UUID to avoid conflicts
|
|
let uniqueID = UUID().uuidString
|
|
let safeFilename = filename.replacingOccurrences(of: "/", with: "-")
|
|
.replacingOccurrences(of: ":", with: "-")
|
|
|
|
// Determine file extension based on the original download URL, not the downloaded file
|
|
let fileExtension: String
|
|
|
|
// Check the original URL to determine if this was an HLS stream or direct MP4
|
|
let originalURLString = originalURL.absoluteString.lowercased()
|
|
let originalPathExtension = originalURL.pathExtension.lowercased()
|
|
|
|
if originalURLString.contains(".m3u8") || originalURLString.contains("/hls/") || originalURLString.contains("m3u8") {
|
|
// This was an HLS stream, keep as .movpkg
|
|
fileExtension = "movpkg"
|
|
Logger.shared.log("Using .movpkg extension for HLS download: \(safeFilename)", type: "Download")
|
|
} else if originalPathExtension == "mp4" || originalURLString.contains(".mp4") || originalURLString.contains("download") {
|
|
// This was a direct MP4 download, use .mp4 extension regardless of what AVAssetDownloadTask created
|
|
fileExtension = "mp4"
|
|
Logger.shared.log("Using .mp4 extension for direct MP4 download: \(safeFilename)", type: "Download")
|
|
} else {
|
|
// Fallback: check the downloaded file extension
|
|
let sourceExtension = location.pathExtension.lowercased()
|
|
if sourceExtension == "movpkg" && originalURLString.contains("m3u8") {
|
|
fileExtension = "movpkg"
|
|
Logger.shared.log("Using .movpkg extension for HLS stream: \(safeFilename)", type: "Download")
|
|
} else {
|
|
fileExtension = "mp4"
|
|
Logger.shared.log("Using .mp4 extension as fallback: \(safeFilename)", type: "Download")
|
|
}
|
|
}
|
|
|
|
Logger.shared.log("Final destination will be: \(safeFilename)-\(uniqueID).\(fileExtension)", type: "Download")
|
|
|
|
let destinationURL = downloadDir.appendingPathComponent("\(safeFilename)-\(uniqueID).\(fileExtension)")
|
|
|
|
// Move the file to the persistent location
|
|
try fileManager.moveItem(at: location, to: destinationURL)
|
|
Logger.shared.log("Successfully moved download to persistent storage: \(destinationURL.path)", type: "Download")
|
|
|
|
return destinationURL
|
|
} catch {
|
|
Logger.shared.log("Error moving download to persistent storage: \(error.localizedDescription)", type: "Download")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Called when a download task encounters an error
|
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
if let error = error {
|
|
// Enhanced error logging
|
|
Logger.shared.log("Download error: \(error.localizedDescription)", type: "Download")
|
|
|
|
// Extract and log the underlying error details
|
|
let nsError = error as NSError
|
|
Logger.shared.log("Error domain: \(nsError.domain), code: \(nsError.code)", type: "Download")
|
|
|
|
if let underlyingError = nsError.userInfo["NSUnderlyingError"] as? NSError {
|
|
Logger.shared.log("Underlying error: \(underlyingError)", type: "Download")
|
|
}
|
|
|
|
for (key, value) in nsError.userInfo {
|
|
Logger.shared.log("Error info - \(key): \(value)", type: "Download")
|
|
}
|
|
|
|
// Check if there's a system network error
|
|
if let urlError = error as? URLError {
|
|
Logger.shared.log("URLError code: \(urlError.code.rawValue)", type: "Download")
|
|
|
|
// Handle cancellation specifically
|
|
if urlError.code == .cancelled {
|
|
Logger.shared.log("Download was cancelled by user", type: "Download")
|
|
handleDownloadCancellation(task)
|
|
return
|
|
} else if urlError.code == .notConnectedToInternet || urlError.code == .networkConnectionLost {
|
|
Logger.shared.log("Network error: \(urlError.localizedDescription)", type: "Download")
|
|
|
|
DispatchQueue.main.async {
|
|
DropManager.shared.error("Network error: \(urlError.localizedDescription)")
|
|
}
|
|
} else if urlError.code == .userAuthenticationRequired || urlError.code == .userCancelledAuthentication {
|
|
Logger.shared.log("Authentication error: \(urlError.localizedDescription)", type: "Download")
|
|
|
|
DispatchQueue.main.async {
|
|
DropManager.shared.error("Authentication error: Check headers")
|
|
}
|
|
}
|
|
} else if error.localizedDescription.contains("403") {
|
|
// Specific handling for 403 Forbidden errors
|
|
Logger.shared.log("403 Forbidden error - Server rejected the request", type: "Download")
|
|
DispatchQueue.main.async {
|
|
DropManager.shared.error("Access denied (403): The server refused access to this content")
|
|
}
|
|
} else {
|
|
DispatchQueue.main.async {
|
|
DropManager.shared.error("Download failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
cleanupDownloadTask(task)
|
|
}
|
|
|
|
/// Handle download cancellation - clean up without treating as completion
|
|
private func handleDownloadCancellation(_ task: URLSessionTask) {
|
|
guard let downloadID = activeDownloadMap[task] else {
|
|
Logger.shared.log("Cancelled download task not found in active downloads", type: "Download")
|
|
cleanupDownloadTask(task)
|
|
return
|
|
}
|
|
|
|
// Mark this download as cancelled to prevent completion processing
|
|
cancelledDownloadIDs.insert(downloadID)
|
|
|
|
// Find the download object to get its title
|
|
let downloadTitle = activeDownloads.first { $0.id == downloadID }?.title ?? "Unknown"
|
|
|
|
// Check if there's a partially downloaded file that needs to be deleted
|
|
if let assetDownloadTask = task as? AVAssetDownloadTask {
|
|
// For AVAssetDownloadTask, we need to check if any partial files were created
|
|
// and delete them to prevent them from being considered completed downloads
|
|
deletePartiallyDownloadedAsset(downloadID: downloadID)
|
|
}
|
|
|
|
// Show user notification
|
|
DropManager.shared.info("Download cancelled: \(downloadTitle)")
|
|
|
|
// Clean up the download task (this removes it from activeDownloads and activeDownloadMap)
|
|
cleanupDownloadTask(task)
|
|
|
|
// Notify observers of cancellation (no cache clearing needed)
|
|
postDownloadNotification(.statusChange)
|
|
|
|
Logger.shared.log("Successfully handled cancellation for: \(downloadTitle)", type: "Download")
|
|
}
|
|
|
|
/// Delete any partially downloaded assets for a cancelled download
|
|
private func deletePartiallyDownloadedAsset(downloadID: UUID) {
|
|
// Check if the asset was already saved to our permanent collection
|
|
// and remove it if it was (this prevents cancelled downloads from appearing as completed)
|
|
if let savedAssetIndex = savedAssets.firstIndex(where: { savedAsset in
|
|
// We can't directly match by download ID since savedAssets don't store it,
|
|
// so we'll match by checking if this asset was just added (within last few seconds)
|
|
// and if the download was in progress
|
|
let wasRecentlyAdded = Date().timeIntervalSince(savedAsset.downloadDate) < 30 // Within 30 seconds
|
|
return wasRecentlyAdded
|
|
}) {
|
|
let assetToDelete = savedAssets[savedAssetIndex]
|
|
Logger.shared.log("Removing cancelled download from saved assets: \(assetToDelete.name)", type: "Download")
|
|
|
|
// Delete the actual file if it exists
|
|
if FileManager.default.fileExists(atPath: assetToDelete.localURL.path) {
|
|
do {
|
|
try FileManager.default.removeItem(at: assetToDelete.localURL)
|
|
Logger.shared.log("Deleted partially downloaded file: \(assetToDelete.localURL.path)", type: "Download")
|
|
} catch {
|
|
Logger.shared.log("Error deleting partially downloaded file: \(error.localizedDescription)", type: "Download")
|
|
}
|
|
}
|
|
|
|
// Remove from saved assets
|
|
savedAssets.remove(at: savedAssetIndex)
|
|
saveAssets()
|
|
|
|
// Notify observers that an asset was deleted
|
|
postDownloadNotification(.deleted)
|
|
}
|
|
}
|
|
|
|
/// Update progress of download task
|
|
func urlSession(_ session: URLSession,
|
|
assetDownloadTask: AVAssetDownloadTask,
|
|
didLoad timeRange: CMTimeRange,
|
|
totalTimeRangesLoaded loadedTimeRanges: [NSValue],
|
|
timeRangeExpectedToLoad: CMTimeRange) {
|
|
|
|
// Do a quick check to see if task is still registered
|
|
guard let downloadID = activeDownloadMap[assetDownloadTask] else {
|
|
Logger.shared.log("Received progress for unknown download task", type: "Download")
|
|
return
|
|
}
|
|
|
|
// Calculate download progress
|
|
var totalProgress: Double = 0
|
|
|
|
// Calculate the total progress by summing all loaded time ranges and dividing by expected time range
|
|
for value in loadedTimeRanges {
|
|
let loadedTimeRange = value.timeRangeValue
|
|
let duration = loadedTimeRange.duration.seconds
|
|
let expectedDuration = timeRangeExpectedToLoad.duration.seconds
|
|
|
|
// Only add if the expected duration is valid (greater than 0)
|
|
if expectedDuration > 0 {
|
|
totalProgress += (duration / expectedDuration)
|
|
}
|
|
}
|
|
|
|
// Clamp total progress between 0 and 1
|
|
let finalProgress = min(max(totalProgress, 0.0), 1.0)
|
|
|
|
// Update the download object with the new progress
|
|
updateDownloadProgress(task: assetDownloadTask, progress: finalProgress)
|
|
}
|
|
}
|
|
|
|
// MARK: - URLSessionTaskDelegate
|
|
extension JSController: URLSessionTaskDelegate {
|
|
/// Called when a redirect is received
|
|
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
|
|
// Log information about the redirect
|
|
Logger.shared.log("==== REDIRECT DETECTED ====", type: "Download")
|
|
Logger.shared.log("Redirecting to: \(request.url?.absoluteString ?? "unknown")", type: "Download")
|
|
Logger.shared.log("Redirect status code: \(response.statusCode)", type: "Download")
|
|
|
|
// Don't try to access originalRequest for AVAssetDownloadTask
|
|
if !(task is AVAssetDownloadTask), let originalRequest = task.originalRequest {
|
|
Logger.shared.log("Original URL: \(originalRequest.url?.absoluteString ?? "unknown")", type: "Download")
|
|
Logger.shared.log("Original Headers: \(originalRequest.allHTTPHeaderFields ?? [:])", type: "Download")
|
|
|
|
// Create a modified request that preserves ALL original headers
|
|
var modifiedRequest = request
|
|
|
|
// Add all original headers to the new request
|
|
for (key, value) in originalRequest.allHTTPHeaderFields ?? [:] {
|
|
// Only add if not already present in the redirect request
|
|
if modifiedRequest.value(forHTTPHeaderField: key) == nil {
|
|
Logger.shared.log("Adding missing header: \(key): \(value)", type: "Download")
|
|
modifiedRequest.addValue(value, forHTTPHeaderField: key)
|
|
}
|
|
}
|
|
|
|
Logger.shared.log("Final redirect headers: \(modifiedRequest.allHTTPHeaderFields ?? [:])", type: "Download")
|
|
|
|
// Allow the redirect with our modified request
|
|
completionHandler(modifiedRequest)
|
|
} else {
|
|
// For AVAssetDownloadTask, just accept the redirect as is
|
|
Logger.shared.log("Accepting redirect for AVAssetDownloadTask without header modification", type: "Download")
|
|
completionHandler(request)
|
|
}
|
|
}
|
|
|
|
/// Handle authentication challenges
|
|
func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
|
Logger.shared.log("==== AUTH CHALLENGE ====", type: "Download")
|
|
Logger.shared.log("Authentication method: \(challenge.protectionSpace.authenticationMethod)", type: "Download")
|
|
Logger.shared.log("Host: \(challenge.protectionSpace.host)", type: "Download")
|
|
|
|
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
|
|
// Handle SSL/TLS certificate validation
|
|
if let serverTrust = challenge.protectionSpace.serverTrust {
|
|
let credential = URLCredential(trust: serverTrust)
|
|
Logger.shared.log("Accepting server trust for host: \(challenge.protectionSpace.host)", type: "Download")
|
|
completionHandler(.useCredential, credential)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Default to performing authentication without credentials
|
|
Logger.shared.log("Using default handling for authentication challenge", type: "Download")
|
|
completionHandler(.performDefaultHandling, nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Download Types
|
|
/// Struct to represent an active download in JSController
|
|
struct JSActiveDownload: Identifiable, Equatable {
|
|
let id: UUID
|
|
let originalURL: URL
|
|
var progress: Double
|
|
let task: AVAssetDownloadTask?
|
|
let urlSessionTask: URLSessionDownloadTask?
|
|
let type: DownloadType
|
|
var metadata: AssetMetadata?
|
|
var title: String?
|
|
var imageURL: URL?
|
|
var subtitleURL: URL?
|
|
var queueStatus: DownloadQueueStatus
|
|
var asset: AVURLAsset?
|
|
var headers: [String: String]
|
|
var module: ScrapingModule? // Add module property to store ScrapingModule
|
|
let aniListID: Int?
|
|
let malID: Int?
|
|
let isFiller: Bool?
|
|
|
|
// Computed property to get the current task state
|
|
var taskState: URLSessionTask.State {
|
|
if let avTask = task {
|
|
return avTask.state
|
|
} else if let urlTask = urlSessionTask {
|
|
return urlTask.state
|
|
} else {
|
|
return .suspended
|
|
}
|
|
}
|
|
|
|
// Computed property to get the underlying task for control operations
|
|
var underlyingTask: URLSessionTask? {
|
|
return task ?? urlSessionTask
|
|
}
|
|
|
|
// Implement Equatable
|
|
static func == (lhs: JSActiveDownload, rhs: JSActiveDownload) -> Bool {
|
|
return lhs.id == rhs.id &&
|
|
lhs.originalURL == rhs.originalURL &&
|
|
lhs.progress == rhs.progress &&
|
|
lhs.type == rhs.type &&
|
|
lhs.title == rhs.title &&
|
|
lhs.imageURL == rhs.imageURL &&
|
|
lhs.subtitleURL == rhs.subtitleURL &&
|
|
lhs.queueStatus == rhs.queueStatus
|
|
}
|
|
|
|
init(
|
|
id: UUID = UUID(),
|
|
originalURL: URL,
|
|
progress: Double = 0,
|
|
task: AVAssetDownloadTask? = nil,
|
|
urlSessionTask: URLSessionDownloadTask? = nil,
|
|
queueStatus: DownloadQueueStatus = .queued,
|
|
type: DownloadType = .movie,
|
|
metadata: AssetMetadata? = nil,
|
|
title: String? = nil,
|
|
imageURL: URL? = nil,
|
|
subtitleURL: URL? = nil,
|
|
asset: AVURLAsset? = nil,
|
|
headers: [String: String] = [:],
|
|
module: ScrapingModule? = nil,
|
|
aniListID: Int? = nil,
|
|
malID: Int? = nil,
|
|
isFiller: Bool? = nil
|
|
) {
|
|
self.id = id
|
|
self.originalURL = originalURL
|
|
self.progress = progress
|
|
self.task = task
|
|
self.urlSessionTask = urlSessionTask
|
|
self.type = type
|
|
self.metadata = metadata
|
|
self.title = title
|
|
self.imageURL = imageURL
|
|
self.subtitleURL = subtitleURL
|
|
self.queueStatus = queueStatus
|
|
self.asset = asset
|
|
self.headers = headers
|
|
self.module = module // Store the module
|
|
self.aniListID = aniListID
|
|
self.malID = malID
|
|
self.isFiller = isFiller
|
|
}
|
|
}
|
|
|
|
/// Represents the download status of an episode
|
|
enum EpisodeDownloadStatus: Equatable {
|
|
/// Episode is not downloaded and not being downloaded
|
|
case notDownloaded
|
|
/// Episode is currently being downloaded
|
|
case downloading(JSActiveDownload)
|
|
/// Episode is already downloaded
|
|
case downloaded(DownloadedAsset)
|
|
|
|
/// Returns true if the episode is either downloaded or being downloaded
|
|
var isDownloadedOrInProgress: Bool {
|
|
switch self {
|
|
case .notDownloaded:
|
|
return false
|
|
case .downloading, .downloaded:
|
|
return true
|
|
}
|
|
}
|
|
|
|
static func == (lhs: EpisodeDownloadStatus, rhs: EpisodeDownloadStatus) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.notDownloaded, .notDownloaded):
|
|
return true
|
|
case (.downloading(let lhsDownload), .downloading(let rhsDownload)):
|
|
return lhsDownload.id == rhsDownload.id
|
|
case (.downloaded(let lhsAsset), .downloaded(let rhsAsset)):
|
|
return lhsAsset.id == rhsAsset.id
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Represents the download queue status of a download
|
|
enum DownloadQueueStatus: Equatable {
|
|
/// Download is queued and not started
|
|
case queued
|
|
/// Download is currently being processed
|
|
case downloading
|
|
/// Download has been completed
|
|
case completed
|
|
}
|
|
|
|
// MARK: - AniSkip Sidecar (OP/ED) Fetch
|
|
extension JSController {
|
|
/// Fetches OP & ED skip timestamps (AniSkip) and writes a minimal sidecar JSON next to the persisted video.
|
|
/// Uses MAL ID only (AniList is not used).
|
|
func fetchSkipTimestampsFor(request: JSActiveDownload,
|
|
persistentURL: URL,
|
|
completion: @escaping (Bool) -> Void) {
|
|
// Use MAL ID only
|
|
guard let malID = request.malID else {
|
|
Logger.shared.log("[SkipSidecar] No MAL ID available for AniSkip v2 request", type: "Download")
|
|
completion(false)
|
|
return
|
|
}
|
|
guard let episodeNumber = request.metadata?.episode else {
|
|
Logger.shared.log("[SkipSidecar] Missing episode number for AniSkip v2 request", type: "Download")
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
// Build v2 URL (op,ed only)
|
|
let mal = malID
|
|
let type = "op,ed"
|
|
|
|
guard let url = URL(string: "https://api.aniskip.com/v2/skip-times/\(mal)/\(episodeNumber)?types=\(type)&episodeLength=0") else {
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
URLSession.shared.dataTask(with: url) { data, _, error in
|
|
if let e = error {
|
|
Logger.shared.log("[SkipSidecar] AniSkip v2 (MAL) fetch error: \(e.localizedDescription)", type: "Download")
|
|
completion(false)
|
|
return
|
|
}
|
|
guard let data = data else {
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
struct Resp: Decodable { let found: Bool; let results: [Res]? }
|
|
struct Res: Decodable { let skip_type: String; let interval: Interval }
|
|
struct Interval: Decodable { let start_time: Double; let end_time: Double }
|
|
|
|
var opRange: (Double, Double)? = nil
|
|
var edRange: (Double, Double)? = nil
|
|
|
|
if let r = try? JSONDecoder().decode(Resp.self, from: data), r.found, let arr = r.results {
|
|
for item in arr {
|
|
switch item.skip_type.lowercased() {
|
|
case "op": opRange = (item.interval.start_time, item.interval.end_time)
|
|
case "ed": edRange = (item.interval.start_time, item.interval.end_time)
|
|
default: break
|
|
}
|
|
}
|
|
}
|
|
|
|
if opRange == nil && edRange == nil {
|
|
completion(false)
|
|
return
|
|
}
|
|
|
|
// Sidecar path: next to the persisted video file
|
|
let dir = persistentURL.deletingLastPathComponent()
|
|
let baseName = persistentURL.deletingPathExtension().lastPathComponent
|
|
let sidecar = dir.appendingPathComponent(baseName + ".skip.json")
|
|
|
|
var payload: [String: Any] = [
|
|
"source": "aniskip",
|
|
"idType": "mal",
|
|
"malId": mal,
|
|
"episode": episodeNumber,
|
|
"createdAt": ISO8601DateFormatter().string(from: Date())
|
|
]
|
|
if let op = opRange { payload["op"] = ["start": op.0, "end": op.1] }
|
|
if let ed = edRange { payload["ed"] = ["start": ed.0, "end": ed.1] }
|
|
|
|
do {
|
|
let json = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted])
|
|
try json.write(to: sidecar, options: .atomic)
|
|
Logger.shared.log("[SkipSidecar] Wrote sidecar at: \(sidecar.path)", type: "Download")
|
|
completion(true)
|
|
} catch {
|
|
Logger.shared.log("[SkipSidecar] Sidecar write error: \(error.localizedDescription)", type: "Download")
|
|
completion(false)
|
|
}
|
|
}.resume()
|
|
}
|
|
}
|