This commit is contained in:
scigward 2025-08-19 23:22:11 +03:00
parent 970dbf2654
commit acb62d7b78
6 changed files with 155 additions and 9 deletions

View file

@ -300,6 +300,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
asset = AVURLAsset(url: url)
// Try to load OP/ED skip sidecar for local files
self.loadLocalSkipSidecar(for: url)
} else {
Logger.shared.log("Loading remote URL: \(url.absoluteString)", type: "Debug")
var request = URLRequest(url: url)
@ -2648,6 +2650,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
asset = AVURLAsset(url: url)
// Try to load OP/ED skip sidecar for local files
self.loadLocalSkipSidecar(for: url)
} else {
Logger.shared.log("Switching to remote URL: \(url.absoluteString)", type: "Debug")
var request = URLRequest(url: url)
@ -3864,3 +3868,39 @@ class GradientBlurButton: UIButton {
super.removeFromSuperview()
}
}
/// Load OP/ED skip data from a simple sidecar JSON saved next to the local video (if present)
private func loadLocalSkipSidecar(for fileURL: URL) {
let fm = FileManager.default
var dir = fileURL.deletingLastPathComponent()
var base = fileURL.deletingPathExtension().lastPathComponent
var isDir: ObjCBool = false
if fm.fileExists(atPath: fileURL.path, isDirectory: &isDir), isDir.boolValue {
// HLS package directory: look in parent dir, use directory name
dir = fileURL.deletingLastPathComponent()
base = fileURL.lastPathComponent
}
let sidecar = dir.appendingPathComponent(base + ".skip.json")
do {
let data = try Data(contentsOf: sidecar)
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let op = (json["op"] as? [String: Any]), let s = op["start"] as? Double, let e = op["end"] as? Double {
let range = CMTimeRange(start: CMTime(seconds: s, preferredTimescale: 600), end: CMTime(seconds: e, preferredTimescale: 600))
self.skipIntervals.op = range
print("[Player] Loaded local OP: \(s)-\(e)")
}
if let ed = (json["ed"] as? [String: Any]), let s = ed["start"] as? Double, let e = ed["end"] as? Double {
let range = CMTimeRange(start: CMTime(seconds: s, preferredTimescale: 600), end: CMTime(seconds: e, preferredTimescale: 600))
self.skipIntervals.ed = range
print("[Player] Loaded local ED: \(s)-\(e)")
}
DispatchQueue.main.async {
self.updateSkipButtonsVisibility()
}
}
} catch {
print("[Player] No local skip sidecar found or failed to load: \(error.localizedDescription)")
}
}

View file

@ -20,10 +20,13 @@ struct DownloadRequest {
let episode: Int?
let subtitleURL: URL?
let showPosterURL: URL?
let aniListID: Int?
let malID: Int?
let isFiller: Bool?
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) {
episode: Int? = nil, subtitleURL: URL? = nil, showPosterURL: URL? = nil, aniListID: Int? = nil, malID: Int? = nil, isFiller: Bool? = nil) {
self.url = url
self.headers = headers
self.title = title
@ -34,6 +37,9 @@ struct DownloadRequest {
self.episode = episode
self.subtitleURL = subtitleURL
self.showPosterURL = showPosterURL
self.aniListID = aniListID
self.malID = malID
self.isFiller = isFiller
}
}
@ -60,7 +66,8 @@ extension JSController {
let request = DownloadRequest(
url: url, headers: headers, title: title, imageURL: imageURL,
isEpisode: isEpisode, showTitle: showTitle, season: season,
episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL
episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL,
aniListID: aniListID, malID: malID, isFiller: isFiller
)
logDownloadStart(request: request)
@ -130,7 +137,8 @@ extension JSController {
let request = DownloadRequest(
url: url, headers: headers, title: title, imageURL: imageURL,
isEpisode: isEpisode, showTitle: showTitle, season: season,
episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL
episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL,
aniListID: aniListID, malID: malID, isFiller: isFiller
)
downloadMP4(request: request, completionHandler: completionHandler)

View file

@ -333,7 +333,10 @@ extension JSController {
subtitleURL: queuedDownload.subtitleURL,
asset: asset,
headers: queuedDownload.headers,
module: queuedDownload.module
module: queuedDownload.module,
aniListID: queuedDownload.aniListID,
malID: queuedDownload.malID,
isFiller: queuedDownload.isFiller
)
// Add to active downloads
@ -1214,6 +1217,17 @@ extension JSController: AVAssetDownloadDelegate {
}
// 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.isEpisode {
fetchSkipTimestampsFor(request: download, persistentURL: persistentURL) { ok in
if ok {
print("[SkipSidecar] Saved OP/ED sidecar for episode \(download.metadata?.episode ?? -1) at: \(persistentURL.path)")
} else {
print("[SkipSidecar] Failed to save sidecar for episode \(download.metadata?.episode ?? -1)")
}
}
}
if let subtitleURL = download.subtitleURL {
downloadSubtitle(subtitleURL: subtitleURL, assetID: newAsset.id.uuidString)
} else {
@ -1543,6 +1557,9 @@ struct JSActiveDownload: Identifiable, Equatable {
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 {
@ -1646,4 +1663,73 @@ enum DownloadQueueStatus: Equatable {
case downloading
/// Download has been completed
case completed
}
// MARK: - Skip Sidecar (OP/ED) Fetch
/// Fetches OP & ED skip timestamps (AniSkip) and writes a minimal sidecar JSON next to the persisted video.
/// Uses MAL id for fillers when available; falls back to AniList otherwise.
private func fetchSkipTimestampsFor(request: JSActiveDownload, persistentURL: URL, completion: @escaping (Bool)->Void)
{
// Determine preferred ID
let epNumber = request.metadata?.episode ?? 0
let useMAL = (request.isFiller == true) && (request.malID != nil)
let idType = useMAL ? "mal" : "anilist"
guard let seriesID = useMAL ? request.malID : request.aniListID else {
print("[SkipSidecar] Missing series ID for AniSkip (MAL/AniList)")
completion(false)
return
}
// Single AniSkip v1 call for both OP/ED
let url = URL(string: "https://api.aniskip.com/v1/skip-times/\(seriesID)/\(epNumber)?types=op&types=ed")!
URLSession.shared.dataTask(with: url) { data, _, error in
if let e = error {
print("[SkipSidecar] AniSkip fetch error: \(e.localizedDescription)")
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 {
if item.skip_type == "op" { opRange = (item.interval.start_time, item.interval.end_time) }
if item.skip_type == "ed" { edRange = (item.interval.start_time, item.interval.end_time) }
}
}
if opRange == nil && edRange == nil { completion(false); return }
// Determine sidecar path
let fm = FileManager.default
var dir = persistentURL.deletingLastPathComponent()
var baseName = persistentURL.deletingPathExtension().lastPathComponent
var isDir: ObjCBool = false
if fm.fileExists(atPath: persistentURL.path, isDirectory: &isDir), isDir.boolValue {
dir = persistentURL.deletingLastPathComponent()
baseName = persistentURL.lastPathComponent
}
let sidecar = dir.appendingPathComponent(baseName + ".skip.json")
var payload: [String: Any] = [
"source": "aniskip",
"idType": idType,
"episode": epNumber,
"createdAt": ISO8601DateFormatter().string(from: Date())
]
if let aid = request.aniListID { payload["anilistId"] = aid }
if let mid = request.malID { payload["malId"] = mid }
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 data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted])
try data.write(to: sidecar, options: .atomic)
print("[SkipSidecar] Wrote sidecar at: \(sidecar.path)")
completion(true)
} catch {
print("[SkipSidecar] Sidecar write error: \(error.localizedDescription)")
completion(false)
}
}.resume()
}
}

View file

@ -53,7 +53,10 @@ extension JSController {
episode: episode,
subtitleURL: subtitleURL,
showPosterURL: showPosterURL,
completionHandler: completionHandler
aniListID: aniListID,
malID: malID,
isFiller: isFiller,
aniListID: aniListID, malID: malID, isFiller: isFiller, completionHandler: completionHandler
)
}else {
Logger.shared.log("Using MP4 download method")
@ -68,7 +71,10 @@ extension JSController {
episode: episode,
subtitleURL: subtitleURL,
showPosterURL: showPosterURL,
completionHandler: completionHandler
aniListID: aniListID,
malID: malID,
isFiller: isFiller,
aniListID: aniListID, malID: malID, isFiller: isFiller, completionHandler: completionHandler
)
}
}

View file

@ -14,6 +14,7 @@ struct EpisodeCell: View {
let episodeIndex: Int
let episode: String
let episodeID: Int
let malID: Int? = nil
let progress: Double
let itemID: Int
let totalEpisodes: Int?
@ -49,6 +50,7 @@ struct EpisodeCell: View {
@State private var dragState: DragState = .inactive
@State private var retryAttempts: Int = 0
private var malIDFromParent: Int? { malID }
private let maxRetryAttempts: Int = 3
private let initialBackoffDelay: TimeInterval = 1.0
@ -702,7 +704,10 @@ private extension EpisodeCell {
season: 1,
episode: episodeID + 1,
subtitleURL: subtitleURL,
showPosterURL: showPosterImageURL
showPosterURL: showPosterImageURL,
aniListID: itemID,
malID: malIDFromParent,
isFiller: isFiller
) { success, message in
if success {
Logger.shared.log("Started download for Episode \(self.episodeID + 1): \(self.episode)", type: "Download")

View file

@ -711,7 +711,8 @@ struct MediaInfoView: View {
},
tmdbID: tmdbID,
seasonNumber: season,
fillerEpisodes: jikanFillerSet
fillerEpisodes: jikanFillerSet,
malID: matchedMalID
)
.disabled(isFetchingEpisode)
}