fix: retain Zoko opening and ending metadata through playback
This commit is contained in:
@@ -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() }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user