Skip to content

Commit f334553

Browse files
committed
Merge remote-tracking branch 'upstream/main' into pr/drag-drop-playlist
# Conflicts: # Tests/KasetTests/LibraryMutationActionsTests.swift
2 parents 6d50659 + 6f33a1e commit f334553

49 files changed

Lines changed: 7471 additions & 172 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Sources/APIExplorer/main.swift‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,54 @@ func rendererHistogram(_ data: [String: Any], limit: Int = 25) -> String {
523523
return output
524524
}
525525

526+
// MARK: - PlaylistSetVideoIdSourceCounts
527+
528+
private struct PlaylistSetVideoIdSourceCounts {
529+
var playlistItemData = 0
530+
var playlistEditEndpoint = 0
531+
}
532+
533+
private func countPlaylistSetVideoIdSources(in value: Any, counts: inout PlaylistSetVideoIdSourceCounts) {
534+
if let dictionary = value as? [String: Any] {
535+
if let playlistItemData = dictionary["playlistItemData"] as? [String: Any],
536+
let setVideoId = playlistItemData["playlistSetVideoId"] as? String,
537+
!setVideoId.isEmpty
538+
{
539+
counts.playlistItemData += 1
540+
}
541+
542+
if let editEndpoint = dictionary["playlistEditEndpoint"] as? [String: Any],
543+
let actions = editEndpoint["actions"] as? [[String: Any]]
544+
{
545+
counts.playlistEditEndpoint += actions.count { action in
546+
action["action"] as? String == "ACTION_REMOVE_VIDEO"
547+
&& (action["setVideoId"] as? String)?.isEmpty == false
548+
}
549+
}
550+
551+
for nestedValue in dictionary.values {
552+
countPlaylistSetVideoIdSources(in: nestedValue, counts: &counts)
553+
}
554+
} else if let array = value as? [Any] {
555+
for item in array {
556+
countPlaylistSetVideoIdSources(in: item, counts: &counts)
557+
}
558+
}
559+
}
560+
561+
private func playlistSetVideoIdSourceSummary(_ data: [String: Any]) -> String {
562+
var counts = PlaylistSetVideoIdSourceCounts()
563+
countPlaylistSetVideoIdSources(in: data, counts: &counts)
564+
guard counts.playlistItemData > 0 || counts.playlistEditEndpoint > 0 else { return "" }
565+
566+
return """
567+
568+
🧩 Playlist occurrence ID sources:
569+
• playlistItemData.playlistSetVideoId: \(counts.playlistItemData)
570+
• playlistEditEndpoint ACTION_REMOVE_VIDEO setVideoId: \(counts.playlistEditEndpoint)
571+
"""
572+
}
573+
526574
// MARK: - ChapterProbeItem
527575

528576
private struct ChapterProbeItem: Hashable {
@@ -851,6 +899,8 @@ func analyzeResponse(_ data: [String: Any], verbose: Bool = false) -> String {
851899
output += playlistSummary
852900
}
853901

902+
output += playlistSetVideoIdSourceSummary(data)
903+
854904
output += chapterProbeSummary(data)
855905

856906
output += rendererHistogram(data)

‎Sources/Kaset/AppDelegate.swift‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
1010
/// Reference to the PlayerService for dock menu actions.
1111
/// Set by KasetApp after initialization.
1212
weak var playerService: PlayerService?
13+
weak var scrobblingCoordinator: ScrobblingCoordinator?
1314

1415
/// Reference to the main window for reliable reopen behavior.
1516
/// Using strong reference to prevent deallocation when window is hidden.
1617
private var mainWindow: NSWindow?
1718

1819
/// Tracks when the app is quitting so we can allow window closures.
1920
private var isTerminating = false
21+
private var isPreparingTermination = false
2022

2123
func applicationDidFinishLaunching(_: Notification) {
2224
DiagnosticsLogger.app.info("AppDelegate: applicationDidFinishLaunching")
@@ -49,9 +51,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
4951
DiagnosticsLogger.player.info("Application will terminate - saved queue for persistence")
5052
}
5153

52-
func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply {
54+
func applicationShouldTerminate(_ application: NSApplication) -> NSApplication.TerminateReply {
5355
self.isTerminating = true
54-
return .terminateNow
56+
guard let scrobblingCoordinator else { return .terminateNow }
57+
guard !self.isPreparingTermination else { return .terminateLater }
58+
59+
self.isPreparingTermination = true
60+
Task { @MainActor in
61+
await scrobblingCoordinator.prepareForTermination()
62+
self.playerService?.saveQueueForPersistence()
63+
application.reply(toApplicationShouldTerminate: true)
64+
}
65+
return .terminateLater
5566
}
5667

5768
/// Registers for system sleep and wake notifications to handle playback appropriately.

‎Sources/Kaset/KasetApp.swift‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,13 @@ struct KasetApp: App {
135135
_notificationService = State(initialValue: NotificationService(playerService: player))
136136
_accountService = State(initialValue: account)
137137

138-
// Create scrobbling coordinator
138+
// Create scrobbling coordinator with mix tracklist parser
139139
let lastFMService = LastFMService(credentialStore: KeychainCredentialStore())
140+
let mixTracklistParser = MixTracklistParser(youTubeClient: youtubeClient)
140141
let scrobblingCoordinator = ScrobblingCoordinator(
141142
playerService: player,
142-
services: [lastFMService]
143+
services: [lastFMService],
144+
mixTracklistParser: mixTracklistParser
143145
)
144146
scrobblingCoordinator.restoreAuthState()
145147
scrobblingCoordinator.startMonitoring()
@@ -148,6 +150,7 @@ struct KasetApp: App {
148150
// Wire up PlayerService to AppDelegate immediately (not in onAppear)
149151
// This ensures playerService is available for lifecycle events like queue restoration
150152
self.appDelegate.playerService = player
153+
self.appDelegate.scrobblingCoordinator = scrobblingCoordinator
151154

152155
if UITestConfig.isUITestMode {
153156
DiagnosticsLogger.ui.info("App launched in UI Test mode")
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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

Comments
 (0)