|
| 1 | +import Foundation |
| 2 | + |
| 3 | +// MARK: - MixTrackEntry |
| 4 | + |
| 5 | +/// A single sub-track within a long mix video (e.g., a DJ set or compilation). |
| 6 | +/// Each entry maps a time range within the video to an artist and title, |
| 7 | +/// enabling per-sub-track scrobbling instead of scrobbling the entire mix as one track. |
| 8 | +struct MixTrackEntry: Identifiable, Hashable { |
| 9 | + let id: UUID |
| 10 | + /// Start time in seconds from the beginning of the video. |
| 11 | + let startTime: TimeInterval |
| 12 | + /// End time in seconds. Nil means "until the next entry or end of video." |
| 13 | + let endTime: TimeInterval? |
| 14 | + /// Track title (without the artist prefix). |
| 15 | + let title: String |
| 16 | + /// Artist name, if parsed from the chapter title or description. |
| 17 | + /// Falls back to the mix uploader name when parsing can't separate artist from title. |
| 18 | + let artist: String? |
| 19 | + /// Where this entry was parsed from. |
| 20 | + let source: Source |
| 21 | + |
| 22 | + enum Source: String, Hashable { |
| 23 | + case chapters |
| 24 | + case description |
| 25 | + } |
| 26 | + |
| 27 | + /// Duration of this sub-track in seconds, if end time is known. |
| 28 | + var duration: TimeInterval? { |
| 29 | + guard let endTime else { return nil } |
| 30 | + return endTime - self.startTime |
| 31 | + } |
| 32 | + |
| 33 | + init( |
| 34 | + id: UUID = UUID(), |
| 35 | + startTime: TimeInterval, |
| 36 | + endTime: TimeInterval?, |
| 37 | + title: String, |
| 38 | + artist: String?, |
| 39 | + source: Source |
| 40 | + ) { |
| 41 | + self.id = id |
| 42 | + self.startTime = startTime |
| 43 | + self.endTime = endTime |
| 44 | + self.title = title |
| 45 | + self.artist = artist |
| 46 | + self.source = source |
| 47 | + } |
| 48 | + |
| 49 | + /// Creates a MixTrackEntry from a YouTubeChapter title by splitting on a common dash separator. |
| 50 | + /// If the title has no dash, the full title is used as the track title |
| 51 | + /// and the artist is left nil (caller should provide a fallback). |
| 52 | + init(fromChapterTitle title: String, startTime: TimeInterval, endTime: TimeInterval?) { |
| 53 | + let parsed = Self.parseArtistTitle(from: title) |
| 54 | + self.id = UUID() |
| 55 | + self.startTime = startTime |
| 56 | + self.endTime = endTime |
| 57 | + self.source = .chapters |
| 58 | + self.artist = parsed.artist |
| 59 | + self.title = parsed.title |
| 60 | + } |
| 61 | + |
| 62 | + /// Splits a chapter/description label like "Artist - Title" into its parts, trimming whitespace. |
| 63 | + /// Recognizes spaced ASCII plus en-dash, em-dash, and minus-sign separators with or without |
| 64 | + /// spaces. A bare ASCII hyphen stays ambiguous (`Part-1` is not necessarily artist/title). If |
| 65 | + /// no separator is found, the whole string is the title and the artist is nil (the caller |
| 66 | + /// supplies a fallback). Shared by the chapter and description tracklist tiers. |
| 67 | + static func parseArtistTitle(from raw: String) -> (artist: String?, title: String) { |
| 68 | + let spacedSeparators = [" - ", " – ", " — ", " − "] |
| 69 | + let unspacedUnicodeSeparators = ["–", "—", "−"] |
| 70 | + let spacedRange = spacedSeparators |
| 71 | + .compactMap { raw.range(of: $0) } |
| 72 | + .min { $0.lowerBound < $1.lowerBound } |
| 73 | + let fallbackRange = unspacedUnicodeSeparators |
| 74 | + .compactMap { raw.range(of: $0) } |
| 75 | + .min { $0.lowerBound < $1.lowerBound } |
| 76 | + guard let dashRange = spacedRange ?? fallbackRange else { |
| 77 | + return (nil, raw.trimmingCharacters(in: .whitespaces)) |
| 78 | + } |
| 79 | + let artist = raw[..<dashRange.lowerBound].trimmingCharacters(in: .whitespaces) |
| 80 | + let title = raw[dashRange.upperBound...].trimmingCharacters(in: .whitespaces) |
| 81 | + return (artist.isEmpty ? nil : artist, title) |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +// MARK: - MixTracklist |
| 86 | + |
| 87 | +/// A parsed tracklist for a long mix video, containing sub-tracks with timestamps. |
| 88 | +/// Used by `ScrobblingCoordinator` to scrobble individual tracks within a mix |
| 89 | +/// instead of scrobbling the entire video as a single entry. |
| 90 | +struct MixTracklist: Hashable { |
| 91 | + /// The YouTube video ID this tracklist belongs to. |
| 92 | + let videoId: String |
| 93 | + /// Sub-tracks sorted by start time. |
| 94 | + let entries: [MixTrackEntry] |
| 95 | + /// Where the tracklist was parsed from. |
| 96 | + let source: MixTrackEntry.Source |
| 97 | + /// Whether an explicit Tracklist/Setlist heading established description-list intent. |
| 98 | + let hasExplicitTracklistSignal: Bool |
| 99 | + |
| 100 | + init( |
| 101 | + videoId: String, |
| 102 | + entries: [MixTrackEntry], |
| 103 | + source: MixTrackEntry.Source, |
| 104 | + hasExplicitTracklistSignal: Bool = false |
| 105 | + ) { |
| 106 | + self.videoId = videoId |
| 107 | + let preservesHeadedDescriptionTitles = source == .description && hasExplicitTracklistSignal |
| 108 | + self.entries = entries |
| 109 | + .filter { |
| 110 | + !$0.title.isEmpty |
| 111 | + && ($0.artist != nil |
| 112 | + || preservesHeadedDescriptionTitles |
| 113 | + || !Self.isGenericNavigationTitle($0.title)) |
| 114 | + } |
| 115 | + .sorted { $0.startTime < $1.startTime } |
| 116 | + self.source = source |
| 117 | + self.hasExplicitTracklistSignal = hasExplicitTracklistSignal |
| 118 | + } |
| 119 | + |
| 120 | + /// Greatest timestamp established by the tracklist itself. This is a lower bound for the |
| 121 | + /// parent video's duration: a chapter starting or ending after a threshold proves the video is |
| 122 | + /// at least that long even when the player has not published its duration yet. |
| 123 | + var knownDurationLowerBound: TimeInterval? { |
| 124 | + self.entries |
| 125 | + .map { max($0.startTime, $0.endTime ?? $0.startTime) } |
| 126 | + .max() |
| 127 | + } |
| 128 | + |
| 129 | + /// Find the entry active at a given playback position (seconds from start). |
| 130 | + /// Returns the last entry whose startTime is <= progress, or nil if progress |
| 131 | + /// is before the first entry. |
| 132 | + func entry(at progress: TimeInterval) -> MixTrackEntry? { |
| 133 | + // Binary search for the last entry with startTime <= progress |
| 134 | + guard let firstEntry = self.entries.first, progress >= firstEntry.startTime else { return nil } |
| 135 | + |
| 136 | + var low = 0 |
| 137 | + var high = self.entries.count - 1 |
| 138 | + var result = 0 |
| 139 | + |
| 140 | + while low <= high { |
| 141 | + let mid = (low + high) / 2 |
| 142 | + if self.entries[mid].startTime <= progress { |
| 143 | + result = mid |
| 144 | + low = mid + 1 |
| 145 | + } else { |
| 146 | + high = mid - 1 |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + let entry = self.entries[result] |
| 151 | + if let endTime = entry.endTime, progress >= endTime { |
| 152 | + return nil |
| 153 | + } |
| 154 | + return entry |
| 155 | + } |
| 156 | + |
| 157 | + /// Resolves duration from an explicit end, the next entry, or the parent video's final bound. |
| 158 | + func effectiveDuration(for entry: MixTrackEntry, videoDuration: TimeInterval?) -> TimeInterval? { |
| 159 | + guard let endTime = self.effectiveEndTime(for: entry, videoDuration: videoDuration) else { return nil } |
| 160 | + return endTime - entry.startTime |
| 161 | + } |
| 162 | + |
| 163 | + /// Resolves an entry's playback boundary from its explicit end, next entry, or parent video. |
| 164 | + func effectiveEndTime(for entry: MixTrackEntry, videoDuration: TimeInterval?) -> TimeInterval? { |
| 165 | + if let endTime = entry.endTime { |
| 166 | + return endTime |
| 167 | + } |
| 168 | + |
| 169 | + if let index = self.entries.firstIndex(where: { $0.id == entry.id }), |
| 170 | + self.entries.indices.contains(index + 1) |
| 171 | + { |
| 172 | + let nextStart = self.entries[index + 1].startTime |
| 173 | + if nextStart > entry.startTime { |
| 174 | + return nextStart |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + guard self.entries.last?.id == entry.id, |
| 179 | + let videoDuration, |
| 180 | + videoDuration > entry.startTime |
| 181 | + else { return nil } |
| 182 | + |
| 183 | + return videoDuration |
| 184 | + } |
| 185 | + |
| 186 | + /// Minimum entry and identified-track count for a tracklist to be treated as a mix. |
| 187 | + /// Requiring structured artist/title labels avoids scrobbling generic intro/verse/outro or |
| 188 | + /// podcast-navigation chapters as if they were individual songs. |
| 189 | + static let minEntryCount = 3 |
| 190 | + |
| 191 | + /// Whether this tracklist has enough entries and a sufficiently strong artist/title signal. |
| 192 | + var isMix: Bool { |
| 193 | + guard self.entries.count >= Self.minEntryCount else { return false } |
| 194 | + if self.source == .description, self.hasExplicitTracklistSignal { |
| 195 | + return true |
| 196 | + } |
| 197 | + let parsedArtistCount = self.entries.count { $0.artist != nil } |
| 198 | + return parsedArtistCount * 2 >= self.entries.count |
| 199 | + } |
| 200 | + |
| 201 | + private static func isGenericNavigationTitle(_ title: String) -> Bool { |
| 202 | + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) |
| 203 | + let normalized = trimmed.lowercased() |
| 204 | + let exactLabels: Set = [ |
| 205 | + "intro", "introduction", "outro", "verse", "chorus", "bridge", "interlude", |
| 206 | + "opening", "closing", "credits", "main section", "q&a", "qa", |
| 207 | + ] |
| 208 | + if exactLabels.contains(normalized) { |
| 209 | + return true |
| 210 | + } |
| 211 | + for prefix in [ |
| 212 | + "chapter ", "chapter-", "part ", "part-", "section ", "section-", |
| 213 | + "segment ", "segment-", "topic ", "topic-", |
| 214 | + ] where normalized.hasPrefix(prefix) { |
| 215 | + let suffix = trimmed.dropFirst(prefix.count).trimmingCharacters(in: .whitespaces) |
| 216 | + if Int(suffix) != nil { |
| 217 | + return true |
| 218 | + } |
| 219 | + if !suffix.isEmpty, |
| 220 | + suffix == suffix.uppercased(), |
| 221 | + suffix.range( |
| 222 | + of: #"^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$"#, |
| 223 | + options: .regularExpression |
| 224 | + ) != nil |
| 225 | + { |
| 226 | + return true |
| 227 | + } |
| 228 | + } |
| 229 | + return false |
| 230 | + } |
| 231 | +} |
0 commit comments