fix: retain Zoko opening and ending metadata through playback

This commit is contained in:
2026-10-25 23:01:38 +01:00
parent d8fb648c90
commit 41aa0abd56
11 changed files with 341 additions and 24 deletions
+1
View File
@@ -182,6 +182,7 @@ final class MiruroTransport: @unchecked Sendable {
var prepared = ResolvedSource(url: url.absoluteString, headers: "", identity: source.identity,
title: source.title, subtitles: source.subtitles)
prepared.playbackLifetime = owner
prepared.intervals = source.intervals
return prepared
} catch {
if Task.isCancelled || error is CancellationError { throw CancellationError() }
+10 -1
View File
@@ -31,18 +31,27 @@ struct ResolvedSubtitle: Decodable, Sendable {
}
}
// Optional episode-relative provider timings; playback validates against media duration.
struct ProviderInterval: Sendable, Equatable {
enum Kind: Sendable { case intro, outro }
let kind: Kind
let start: Double
let end: Double
}
struct ResolvedSource: Sendable {
let url: String
let headers: String
let identity: String
let title: String
let subtitles: [ResolvedSubtitle]
var intervals: [ProviderInterval] = []
var requirements: MiruroPlaybackRequirements = .init()
var playbackLifetime: (any Sendable)? = nil
func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource {
ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles,
requirements: requirements, playbackLifetime: playbackLifetime)
intervals: intervals, requirements: requirements, playbackLifetime: playbackLifetime)
}
struct Document: Decodable {
+51 -6
View File
@@ -974,9 +974,16 @@ final class PlayerModel: ObservableObject {
skipOffer = nil
}
let parts = source?.url.absoluteString.split(separator: ":") ?? []
let episode = parts.count == 3 && parts[0] == "miruro" ? Int(parts[2]) : nil
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode)
guard let source, let episode else { return }
let episode = parts.count == 3 && (parts[0] == "miruro" || parts[0] == "anilist") ? Int(parts[2]) : nil
let providerIntervals: [ProviderInterval]
if case .resolved(let resolved, _) = source, parts.first == "anilist" {
providerIntervals = resolved.intervals
} else {
providerIntervals = []
}
let generation = metadataLifecycle.replace(identity: source?.url.absoluteString, episode: episode,
providerIntervals: providerIntervals)
guard let source, let episode, parts[0] == "miruro" else { return }
let identity = source.url.absoluteString
let mediaID = String(parts[1])
let resolver = miruroMetadataResolver
@@ -2142,14 +2149,20 @@ final class PlayerModel: ObservableObject {
private struct MetadataLifecycle {
private(set) var identity: String?
private(set) var metadata: MiruroMetadata?
private var providerIntervals: [MiruroInterval] = []
private var episode: Int?
private var generation: UInt64 = 0
mutating func replace(identity: String?, episode: Int?) -> UInt64 {
mutating func replace(identity: String?, episode: Int?, providerIntervals: [ProviderInterval] = []) -> UInt64 {
generation &+= 1
self.identity = identity
self.episode = episode
metadata = nil
self.providerIntervals = episode.map { episode in
providerIntervals.map {
MiruroInterval(kind: $0.kind == .intro ? .intro : .outro, start: $0.start, end: $0.end, episode: episode)
}
} ?? []
return generation
}
@@ -2160,8 +2173,14 @@ final class PlayerModel: ObservableObject {
}
func visibleIntervals(duration: Double) -> [MiruroInterval] {
guard let episode, let metadata else { return [] }
return metadata.visibleIntervals(episode: episode, duration: duration)
guard let episode else { return [] }
if !providerIntervals.isEmpty {
guard duration.isFinite, duration > 0 else { return [] }
return providerIntervals.filter {
$0.start.isFinite && $0.end.isFinite && $0.start >= 0 && $0.end > $0.start && $0.end <= duration
}
}
return metadata?.visibleIntervals(episode: episode, duration: duration) ?? []
}
}
@@ -2208,6 +2227,32 @@ final class PlayerModel: ObservableObject {
precondition(!lifecycle.accept(metadata, identity: a, generation: second), "YouTube rejects earlier metadata")
_ = lifecycle.replace(identity: nil, episode: nil)
precondition(lifecycle.metadata == nil, "Close clears metadata")
let providerRows: [ProviderInterval] = [
.init(kind: .intro, start: 0.25, end: 1.75),
.init(kind: .outro, start: 90.5, end: 100),
.init(kind: .intro, start: -1, end: 1),
.init(kind: .outro, start: 10, end: 10),
.init(kind: .intro, start: .nan, end: 3),
.init(kind: .outro, start: 2, end: .infinity),
]
let providerGeneration = lifecycle.replace(identity: "anilist:1:1", episode: 1, providerIntervals: providerRows)
let expected = [
MiruroInterval(kind: .intro, start: 0.25, end: 1.75, episode: 1),
MiruroInterval(kind: .outro, start: 90.5, end: 100, episode: 1),
]
for duration in [0.0, -1, .nan, .infinity] {
precondition(lifecycle.visibleIntervals(duration: duration).isEmpty, "Unknown/nonfinite provider duration hides bands")
}
precondition(lifecycle.visibleIntervals(duration: 100) == expected, "Provider fractions and exact-duration end survive mapping")
precondition(lifecycle.visibleIntervals(duration: 95) == [expected[0]], "Provider ranges outside actual duration are rejected, not clipped")
precondition(lifecycle.visibleIntervals(duration: 100) == expected, "Provider raw rows survive duration changes")
_ = lifecycle.replace(identity: "anilist:1:2", episode: 2)
precondition(lifecycle.visibleIntervals(duration: 100).isEmpty, "Missing provider timing clears preceding episode")
_ = lifecycle.replace(identity: "anilist:1:1", episode: 1)
precondition(!lifecycle.accept(metadata, identity: "anilist:1:1", generation: providerGeneration),
"Provider replacement retains same-identity generation protection")
_ = lifecycle.replace(identity: nil, episode: nil)
precondition(lifecycle.visibleIntervals(duration: 100).isEmpty, "Close clears provider bands")
}
#endif
+39 -2
View File
@@ -82,7 +82,8 @@ enum ZokoResolver {
guard enrichTitle, let name = metadata.name else { return source }
try Task.checkCancellation()
return ResolvedSource(url: source.url, headers: source.headers, identity: source.identity,
title: "\(name) · Episode \(episode) (sub)", subtitles: source.subtitles, requirements: source.requirements)
title: "\(name) · Episode \(episode) (sub)", subtitles: source.subtitles,
intervals: source.intervals, requirements: source.requirements, playbackLifetime: source.playbackLifetime)
}
static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource {
@@ -106,8 +107,37 @@ enum ZokoResolver {
}
struct Config: Decodable {
struct Subtitle: Decodable { let src: String; let lang: String; let label: String; let `default`: Bool? }
struct Timing: Decodable {
let start: Double
let end: Double
enum CodingKeys: CodingKey { case start, end }
init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
start = try values.decode(Double.self, forKey: .start)
end = try values.decode(Double.self, forKey: .end)
guard start.isFinite, end.isFinite, start >= 0, end > start else { throw ZokoError.malformed }
}
}
struct Skip: Decodable {
let intro: Timing?
let outro: Timing?
enum CodingKeys: CodingKey { case intro, outro }
init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
intro = try? values.decode(Timing.self, forKey: .intro)
outro = try? values.decode(Timing.self, forKey: .outro)
}
}
let src: String
let subtitles: [Subtitle]
let skip: Skip?
enum CodingKeys: CodingKey { case src, subtitles, skip }
init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
src = try values.decode(String.self, forKey: .src)
subtitles = try values.decode([Subtitle].self, forKey: .subtitles)
skip = try? values.decode(Skip.self, forKey: .skip)
}
}
let config: Config
do { config = try JSONDecoder().decode(Config.self, from: decoded) }
@@ -119,9 +149,16 @@ enum ZokoResolver {
isDefault: row.default ?? false, headers: ["Referer": "https://zokoanime.video/"])
}
guard ResolvedSource.validSubtitles(subtitles) else { throw ZokoError.malformed }
var intervals: [ProviderInterval] = []
if let intro = config.skip?.intro {
intervals.append(ProviderInterval(kind: .intro, start: intro.start, end: intro.end))
}
if let outro = config.skip?.outro {
intervals.append(ProviderInterval(kind: .outro, start: outro.start, end: outro.end))
}
return ResolvedSource(url: config.src, headers: referer, identity: "anilist:\(mediaID):\(episode)",
title: "AniList \(mediaID) · Episode \(episode) (sub)", subtitles: subtitles,
requirements: .init(zoko: true))
intervals: intervals, requirements: .init(zoko: true))
}
struct Catalog: Decodable {