From 874c0004181be8876c290b3809f5946336357b75 Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Tue, 21 Jul 2026 14:33:14 -0700 Subject: [PATCH 1/2] metadata cleanup pass for web sources --- scrobble.xcodeproj/project.pbxproj | 8 ++ scrobble/Services/NowPlayingFetcher.swift | 7 +- scrobble/Services/Scrobbler.swift | 19 ++- scrobble/Services/TrackMetadataCleaner.swift | 132 ++++++++++++++++++ scrobbleTests/TrackMetadataCleanerTests.swift | 93 ++++++++++++ 5 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 scrobble/Services/TrackMetadataCleaner.swift create mode 100644 scrobbleTests/TrackMetadataCleanerTests.swift diff --git a/scrobble.xcodeproj/project.pbxproj b/scrobble.xcodeproj/project.pbxproj index 1894d1e..f7bc963 100644 --- a/scrobble.xcodeproj/project.pbxproj +++ b/scrobble.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ A6F213042C8C021900E1D23B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A6F213032C8C021900E1D23B /* Assets.xcassets */; }; A6F213072C8C021900E1D23B /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A6F213062C8C021900E1D23B /* Preview Assets.xcassets */; }; A6F2130F2C8C027C00E1D23B /* Scrobbler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F2130E2C8C027C00E1D23B /* Scrobbler.swift */; }; + A6CLEANER0002 /* TrackMetadataCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6CLEANER0001 /* TrackMetadataCleaner.swift */; }; A6F213112C8C037500E1D23B /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F213102C8C037500E1D23B /* PreferencesView.swift */; }; A6F213132C8C038F00E1D23B /* LastFmManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F213122C8C038F00E1D23B /* LastFmManager.swift */; }; A6F213192C8C076600E1D23B /* ScriptingBridge.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F213152C8C05E800E1D23B /* ScriptingBridge.framework */; }; @@ -66,6 +67,7 @@ A6TESTBF0005 /* DesignTokensTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0005 /* DesignTokensTests.swift */; }; A6TESTBF0006 /* ScrobbleDelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0006 /* ScrobbleDelayTests.swift */; }; A6TESTBF0007 /* UpdateStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0007 /* UpdateStateTests.swift */; }; + A6CLEANER0004 /* TrackMetadataCleanerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6CLEANER0003 /* TrackMetadataCleanerTests.swift */; }; A6TESTBF0008 /* CodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0008 /* CodableTests.swift */; }; A6TESTBF0009 /* CryptoUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0009 /* CryptoUtilsTests.swift */; }; A6UPDATE0001 /* UpdateState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6UPDATE0002 /* UpdateState.swift */; }; @@ -162,6 +164,8 @@ A6TESTFR0005 /* DesignTokensTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokensTests.swift; sourceTree = ""; }; A6TESTFR0006 /* ScrobbleDelayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrobbleDelayTests.swift; sourceTree = ""; }; A6TESTFR0007 /* UpdateStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateStateTests.swift; sourceTree = ""; }; + A6CLEANER0001 /* TrackMetadataCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackMetadataCleaner.swift; sourceTree = ""; }; + A6CLEANER0003 /* TrackMetadataCleanerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackMetadataCleanerTests.swift; sourceTree = ""; }; A6TESTFR0008 /* CodableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableTests.swift; sourceTree = ""; }; A6TESTFR0009 /* CryptoUtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoUtilsTests.swift; sourceTree = ""; }; A6TESTFR000A /* scrobbleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scrobbleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -197,6 +201,7 @@ isa = PBXGroup; children = ( A6F2130E2C8C027C00E1D23B /* Scrobbler.swift */, + A6CLEANER0001 /* TrackMetadataCleaner.swift */, A61F9BCE2E85E04A00CC1661 /* ScrobblingService.swift */, A6F213122C8C038F00E1D23B /* LastFmManager.swift */, A64304B82D52EBAE001998B6 /* LastFmManagerDesktop.swift */, @@ -359,6 +364,7 @@ A6TESTFR0005 /* DesignTokensTests.swift */, A6TESTFR0006 /* ScrobbleDelayTests.swift */, A6TESTFR0007 /* UpdateStateTests.swift */, + A6CLEANER0003 /* TrackMetadataCleanerTests.swift */, A6TESTFR0008 /* CodableTests.swift */, A6TESTFR0009 /* CryptoUtilsTests.swift */, ); @@ -503,6 +509,7 @@ A6KEYCHAIN01 /* KeychainHelper.swift in Sources */, A6F213112C8C037500E1D23B /* PreferencesView.swift in Sources */, A6F2130F2C8C027C00E1D23B /* Scrobbler.swift in Sources */, + A6CLEANER0002 /* TrackMetadataCleaner.swift in Sources */, A622991B2EE7D69100592239 /* MenuButtonsView.swift in Sources */, A61F9BCF2E85E04A00CC1661 /* ScrobblingService.swift in Sources */, A6F213002C8C021800E1D23B /* scrobbleApp.swift in Sources */, @@ -527,6 +534,7 @@ A6TESTBF0005 /* DesignTokensTests.swift in Sources */, A6TESTBF0006 /* ScrobbleDelayTests.swift in Sources */, A6TESTBF0007 /* UpdateStateTests.swift in Sources */, + A6CLEANER0004 /* TrackMetadataCleanerTests.swift in Sources */, A6TESTBF0008 /* CodableTests.swift in Sources */, A6TESTBF0009 /* CryptoUtilsTests.swift in Sources */, ); diff --git a/scrobble/Services/NowPlayingFetcher.swift b/scrobble/Services/NowPlayingFetcher.swift index 8e6cc23..8452d0b 100644 --- a/scrobble/Services/NowPlayingFetcher.swift +++ b/scrobble/Services/NowPlayingFetcher.swift @@ -31,6 +31,7 @@ final class NowPlayingFetcher { var currentTrackArtist: String = "" var currentTrackAlbum: String = "" var currentApplication: String = "" + var currentBundleIdentifier: String? = nil var currentArtwork: NSImage? = nil var currentArtworkBase64: String? = nil var isPlaying: Bool = false @@ -68,6 +69,7 @@ final class NowPlayingFetcher { self.currentTrackArtist = trackInfo.payload.artist ?? "" self.currentTrackAlbum = trackInfo.payload.album ?? "" self.currentApplication = trackInfo.payload.applicationName ?? "" + self.currentBundleIdentifier = trackInfo.payload.bundleIdentifier self.currentArtwork = trackInfo.payload.artwork self.currentArtworkBase64 = trackInfo.payload.artworkDataBase64 self.isPlaying = trackInfo.payload.isPlaying ?? false @@ -105,6 +107,7 @@ final class NowPlayingFetcher { currentTrackArtist = "" currentTrackAlbum = "" currentApplication = "" + currentBundleIdentifier = nil currentArtwork = nil currentArtworkBase64 = nil isPlaying = false @@ -154,8 +157,8 @@ final class NowPlayingFetcher { Log.debug("Forcing UI update with current state", category: .scrobble) } - func fetchCurrentTrackInfo() -> (isPlaying: Bool, title: String, artist: String, album: String, duration: TimeInterval, application: String, artwork: NSImage?, artworkBase64: String?) { - return (isPlaying: isPlaying, title: currentTrackTitle, artist: currentTrackArtist, album: currentTrackAlbum, duration: currentTrackDuration, application: currentApplication, artwork: currentArtwork, artworkBase64: currentArtworkBase64) + func fetchCurrentTrackInfo() -> (isPlaying: Bool, title: String, artist: String, album: String, duration: TimeInterval, application: String, bundleIdentifier: String?, artwork: NSImage?, artworkBase64: String?) { + return (isPlaying: isPlaying, title: currentTrackTitle, artist: currentTrackArtist, album: currentTrackAlbum, duration: currentTrackDuration, application: currentApplication, bundleIdentifier: currentBundleIdentifier, artwork: currentArtwork, artworkBase64: currentArtworkBase64) } func fetchCurrentArtwork() -> NSImage? { diff --git a/scrobble/Services/Scrobbler.swift b/scrobble/Services/Scrobbler.swift index aab721f..cc1c065 100644 --- a/scrobble/Services/Scrobbler.swift +++ b/scrobble/Services/Scrobbler.swift @@ -305,7 +305,16 @@ class Scrobbler { // unreadable within minutes. private func checkNowPlaying() async { if let trackInfo = getCurrentTrackInfoViaFetcher() { - let trackString = "\(trackInfo.artist) - \(trackInfo.name)" + // Web sources (browsers) relay whatever the page put in the Media + // Session API — clean known junk before anything downstream sees it. + let track = TrackMetadataCleaner.cleanIfWebSource( + title: trackInfo.name, + artist: trackInfo.artist, + album: trackInfo.album, + bundleIdentifier: trackInfo.bundleIdentifier, + applicationName: trackInfo.application + ) + let trackString = "\(track.artist) - \(track.title)" // Music is playing - prevent App Nap beginBackgroundActivity() @@ -322,9 +331,9 @@ class Scrobbler { hasScrobbledCurrentSession = false // Update Now Playing (Async) - await updateNowPlaying(artist: trackInfo.artist, title: trackInfo.name, album: trackInfo.album) + await updateNowPlaying(artist: track.artist, title: track.title, album: track.album) - setupScrobbleTimer(artist: trackInfo.artist, title: trackInfo.name, album: trackInfo.album) + setupScrobbleTimer(artist: track.artist, title: track.title, album: track.album) } } else { if currentTrack != "No track playing" { @@ -341,7 +350,7 @@ class Scrobbler { // Called from the poll loop — must stay silent in the steady state. - private func getCurrentTrackInfoViaFetcher() -> (name: String, artist: String, album: String, duration: TimeInterval?, application: String, artwork: NSImage?)? { + private func getCurrentTrackInfoViaFetcher() -> (name: String, artist: String, album: String, duration: TimeInterval?, application: String, bundleIdentifier: String?, artwork: NSImage?)? { guard let fetcher = _mediaRemoteFetcher else { return nil } @@ -351,7 +360,7 @@ class Scrobbler { guard trackInfo.isPlaying, !trackInfo.title.isEmpty, !trackInfo.artist.isEmpty else { return nil } - return (name: trackInfo.title, artist: trackInfo.artist, album: trackInfo.album, duration: trackInfo.duration, application: trackInfo.application, artwork: trackInfo.artwork) + return (name: trackInfo.title, artist: trackInfo.artist, album: trackInfo.album, duration: trackInfo.duration, application: trackInfo.application, bundleIdentifier: trackInfo.bundleIdentifier, artwork: trackInfo.artwork) } private func scrobbleTrack(artist: String, title: String, album: String) { diff --git a/scrobble/Services/TrackMetadataCleaner.swift b/scrobble/Services/TrackMetadataCleaner.swift new file mode 100644 index 0000000..c3ee1fe --- /dev/null +++ b/scrobble/Services/TrackMetadataCleaner.swift @@ -0,0 +1,132 @@ +// +// TrackMetadataCleaner.swift +// scrobble +// +// Conservative cleanup for track metadata from web sources (#12). +// Browsers publish whatever the page sets via the Media Session API, so +// YouTube embeds arrive as e.g. artist "Gone - Topic", title +// "Gone - Bin Days (Official Video)". Native music apps are never touched. +// +// Rules are deliberately suffix/prefix-anchored: mid-title segments like +// "(feat. X)", "(Live)", or "(Remix)" carry meaning and are preserved. +// + +import Foundation + +enum TrackMetadataCleaner { + + // MARK: - Web source detection + + private static let browserBundleIDs: Set = [ + "com.apple.safari", + "com.apple.safaritechnologypreview", + "com.google.chrome", + "com.google.chrome.canary", + "org.chromium.chromium", + "org.mozilla.firefox", + "com.microsoft.edgemac", + "com.brave.browser", + "company.thebrowser.browser", // Arc + "com.vivaldi.vivaldi", + "com.operasoftware.opera", + "app.zen-browser.zen", + ] + + private static let browserNames: Set = [ + "safari", "chrome", "google chrome", "chromium", "firefox", + "microsoft edge", "brave browser", "arc", "vivaldi", "opera", "zen", + ] + + static func isWebSource(bundleIdentifier: String?, applicationName: String) -> Bool { + if let bundleIdentifier, !bundleIdentifier.isEmpty { + return browserBundleIDs.contains(bundleIdentifier.lowercased()) + } + return browserNames.contains(applicationName.lowercased()) + } + + // MARK: - Cleaning + + /// Applies web cleanup only when the source is a browser; otherwise + /// returns the fields untouched. + static func cleanIfWebSource( + title: String, artist: String, album: String, + bundleIdentifier: String?, applicationName: String + ) -> (title: String, artist: String, album: String) { + guard isWebSource(bundleIdentifier: bundleIdentifier, applicationName: applicationName) else { + return (title, artist, album) + } + return clean(title: title, artist: artist, album: album) + } + + static func clean(title: String, artist: String, album: String) -> (title: String, artist: String, album: String) { + let cleanedArtist = cleanArtist(artist) + var cleanedTitle = stripJunkSuffixes(from: title) + cleanedTitle = stripArtistPrefix(from: cleanedTitle, artist: cleanedArtist) + + // If a rule reduced a field to nothing, the original was better than + // an empty scrobble field. + return ( + title: cleanedTitle.isEmpty ? title : cleanedTitle, + artist: cleanedArtist.isEmpty ? artist : cleanedArtist, + album: album + ) + } + + /// YouTube auto-generated channels ("Artist - Topic") and VEVO channels + /// ("ArtistVEVO"). + private static func cleanArtist(_ artist: String) -> String { + var cleaned = artist.trimmingCharacters(in: .whitespaces) + + for separator in [" - topic", " – topic", " — topic"] { + if cleaned.lowercased().hasSuffix(separator) { + cleaned = String(cleaned.dropLast(separator.count)) + .trimmingCharacters(in: .whitespaces) + break + } + } + + if cleaned.hasSuffix("VEVO"), cleaned.count > 4 { + cleaned = String(cleaned.dropLast(4)).trimmingCharacters(in: .whitespaces) + } + + return cleaned + } + + /// Trailing bracketed decorations only — "(Official Video)", "[HD]", … + /// Loops so stacked suffixes like "(Official Video) [HD]" fully unwind. + private static let junkTitleSuffix = try! NSRegularExpression( + pattern: #"\s*[\(\[](?:official\s+)?(?:music\s+video|lyric\s+video|video|audio|lyrics|visuali[sz]er|hd|hq|4k)[\)\]]\s*$"#, + options: [.caseInsensitive] + ) + + private static func stripJunkSuffixes(from title: String) -> String { + var cleaned = title.trimmingCharacters(in: .whitespaces) + while true { + let range = NSRange(cleaned.startIndex..., in: cleaned) + guard let match = junkTitleSuffix.firstMatch(in: cleaned, range: range), + let matchRange = Range(match.range, in: cleaned) else { + break + } + cleaned.removeSubrange(matchRange) + cleaned = cleaned.trimmingCharacters(in: .whitespaces) + } + return cleaned + } + + /// "Artist - Title" video naming: strip the artist prefix from the title + /// only when it exactly matches the (cleaned) artist field. + private static func stripArtistPrefix(from title: String, artist: String) -> String { + guard !artist.isEmpty else { return title } + let lowerTitle = title.lowercased() + let lowerArtist = artist.lowercased() + + for separator in [" - ", " – ", " — ", " | "] { + let prefix = lowerArtist + separator + if lowerTitle.hasPrefix(prefix), title.count > prefix.count { + return String(title.dropFirst(prefix.count)) + .trimmingCharacters(in: .whitespaces) + } + } + return title + } +} diff --git a/scrobbleTests/TrackMetadataCleanerTests.swift b/scrobbleTests/TrackMetadataCleanerTests.swift new file mode 100644 index 0000000..a5af3e2 --- /dev/null +++ b/scrobbleTests/TrackMetadataCleanerTests.swift @@ -0,0 +1,93 @@ +import Testing +@testable import scrobble + +@Suite("TrackMetadataCleaner") +struct TrackMetadataCleanerTests { + + // The exact scenario from issue #12: YouTube embed metadata. + @Test("Issue #12: Topic channel artist and artist-prefixed title") + func issueExample() { + let result = TrackMetadataCleaner.clean( + title: "Gone - Bin Days (Official Video)", + artist: "Gone - Topic", + album: "" + ) + #expect(result.title == "Bin Days") + #expect(result.artist == "Gone") + } + + @Test("Strips ' - Topic' suffix from artist") + func topicSuffix() { + #expect(TrackMetadataCleaner.clean(title: "T", artist: "Boards of Canada - Topic", album: "").artist == "Boards of Canada") + #expect(TrackMetadataCleaner.clean(title: "T", artist: "Burial – Topic", album: "").artist == "Burial") + } + + @Test("Strips VEVO suffix from artist") + func vevoSuffix() { + #expect(TrackMetadataCleaner.clean(title: "T", artist: "RihannaVEVO", album: "").artist == "Rihanna") + // "VEVO" alone must not become empty + #expect(TrackMetadataCleaner.clean(title: "T", artist: "VEVO", album: "").artist == "VEVO") + } + + @Test("Strips trailing junk brackets from title, including stacked ones") + func junkTitleSuffixes() { + #expect(TrackMetadataCleaner.clean(title: "Song (Official Music Video)", artist: "A", album: "").title == "Song") + #expect(TrackMetadataCleaner.clean(title: "Song [Official Audio]", artist: "A", album: "").title == "Song") + #expect(TrackMetadataCleaner.clean(title: "Song (Lyric Video)", artist: "A", album: "").title == "Song") + #expect(TrackMetadataCleaner.clean(title: "Song (Official Video) [HD]", artist: "A", album: "").title == "Song") + #expect(TrackMetadataCleaner.clean(title: "Song (Visualizer)", artist: "A", album: "").title == "Song") + } + + @Test("Preserves meaningful segments and mid-title brackets") + func meaningfulSegmentsPreserved() { + #expect(TrackMetadataCleaner.clean(title: "Song (Live)", artist: "A", album: "").title == "Song (Live)") + #expect(TrackMetadataCleaner.clean(title: "Song (feat. B)", artist: "A", album: "").title == "Song (feat. B)") + #expect(TrackMetadataCleaner.clean(title: "Song (Remix)", artist: "A", album: "").title == "Song (Remix)") + // "(Audio)" mid-title is not a suffix — untouched + #expect(TrackMetadataCleaner.clean(title: "Song (Audio) Part 2", artist: "A", album: "").title == "Song (Audio) Part 2") + } + + @Test("Strips artist prefix from title only on exact artist match") + func artistPrefix() { + #expect(TrackMetadataCleaner.clean(title: "Gone - Bin Days", artist: "Gone", album: "").title == "Bin Days") + // Different artist in the prefix — leave the title alone + #expect(TrackMetadataCleaner.clean(title: "Someone Else - Bin Days", artist: "Gone", album: "").title == "Someone Else - Bin Days") + // Hyphenated titles without the artist stay intact + #expect(TrackMetadataCleaner.clean(title: "T-Shirt Weather", artist: "Circa Waves", album: "").title == "T-Shirt Weather") + } + + @Test("Never produces empty fields — falls back to the original") + func emptyFallback() { + let result = TrackMetadataCleaner.clean(title: "(Official Video)", artist: "", album: "") + #expect(result.title == "(Official Video)") + } + + @Test("Album passes through unchanged") + func albumUntouched() { + #expect(TrackMetadataCleaner.clean(title: "T", artist: "A", album: "Some Album").album == "Some Album") + } + + @Test("Web source detection by bundle identifier") + func webSourceDetection() { + #expect(TrackMetadataCleaner.isWebSource(bundleIdentifier: "com.apple.Safari", applicationName: "Safari")) + #expect(TrackMetadataCleaner.isWebSource(bundleIdentifier: "com.google.Chrome", applicationName: "Google Chrome")) + #expect(!TrackMetadataCleaner.isWebSource(bundleIdentifier: "com.apple.Music", applicationName: "Music")) + #expect(!TrackMetadataCleaner.isWebSource(bundleIdentifier: "com.spotify.client", applicationName: "Spotify")) + // Name fallback when bundle id is missing + #expect(TrackMetadataCleaner.isWebSource(bundleIdentifier: nil, applicationName: "Safari")) + #expect(!TrackMetadataCleaner.isWebSource(bundleIdentifier: nil, applicationName: "Music")) + } + + @Test("Native sources pass through cleanIfWebSource untouched") + func nativeGating() { + let result = TrackMetadataCleaner.cleanIfWebSource( + title: "Song - Topic Mix (Official Video)", + artist: "Artist - Topic", + album: "", + bundleIdentifier: "com.apple.Music", + applicationName: "Music" + ) + #expect(result.title == "Song - Topic Mix (Official Video)") + #expect(result.artist == "Artist - Topic") + } +} From 8178e3d9e802f0bbb0cadc63932e771cb1ebb888 Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Tue, 21 Jul 2026 14:37:38 -0700 Subject: [PATCH 2/2] clean + bump --- scrobble.xcodeproj/project.pbxproj | 42 ++++++++++---------- scrobble/Services/TrackMetadataCleaner.swift | 10 +---- 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/scrobble.xcodeproj/project.pbxproj b/scrobble.xcodeproj/project.pbxproj index f7bc963..5a68734 100644 --- a/scrobble.xcodeproj/project.pbxproj +++ b/scrobble.xcodeproj/project.pbxproj @@ -14,7 +14,6 @@ A622991D2EE7D72900592239 /* ServicesStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A622991C2EE7D72900592239 /* ServicesStatusView.swift */; }; A62299202EE80F2C00592239 /* LabeledStepper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A622991F2EE80F2C00592239 /* LabeledStepper.swift */; }; A63A2B192D2717A1000BC92E /* FriendsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63A2B182D2717A1000BC92E /* FriendsView.swift */; }; - A6FFILTER0002 /* FriendsFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6FFILTER0001 /* FriendsFilterView.swift */; }; A63A2B1B2D271A95000BC92E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63A2B1A2D271A95000BC92E /* ContentView.swift */; }; A64304B92D52EBAE001998B6 /* LastFmManagerDesktop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64304B82D52EBAE001998B6 /* LastFmManagerDesktop.swift */; }; A64304BB2D52EC87001998B6 /* LastFMAuthSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64304BA2D52EC87001998B6 /* LastFMAuthSheetView.swift */; }; @@ -30,6 +29,8 @@ A6A580A82E78DB7F0079DC91 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = A6A580A72E78DB7F0079DC91 /* MediaRemoteAdapter */; }; A6A580A92E78DB9F0079DC91 /* MediaRemoteAdapter in Embed Frameworks */ = {isa = PBXBuildFile; productRef = A6A580A72E78DB7F0079DC91 /* MediaRemoteAdapter */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; A6CC84552EE8470C00236D47 /* MarqueeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6CC84542EE8470C00236D47 /* MarqueeText.swift */; }; + A6CLEANER0002 /* TrackMetadataCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6CLEANER0001 /* TrackMetadataCleaner.swift */; }; + A6CLEANER0004 /* TrackMetadataCleanerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6CLEANER0003 /* TrackMetadataCleanerTests.swift */; }; A6COMPAT012EFBAA0000001 /* GlassEffect+Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6COMPAT012EFBAA0000002 /* GlassEffect+Compat.swift */; }; A6COMPAT012EFBAA0000003 /* LastFMWebAuthViewLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6COMPAT012EFBAA0000004 /* LastFMWebAuthViewLegacy.swift */; }; A6DESIGN012EFBAA0000001 /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6DESIGN012EFBAA0000002 /* DesignTokens.swift */; }; @@ -38,7 +39,6 @@ A6F213042C8C021900E1D23B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A6F213032C8C021900E1D23B /* Assets.xcassets */; }; A6F213072C8C021900E1D23B /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A6F213062C8C021900E1D23B /* Preview Assets.xcassets */; }; A6F2130F2C8C027C00E1D23B /* Scrobbler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F2130E2C8C027C00E1D23B /* Scrobbler.swift */; }; - A6CLEANER0002 /* TrackMetadataCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6CLEANER0001 /* TrackMetadataCleaner.swift */; }; A6F213112C8C037500E1D23B /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F213102C8C037500E1D23B /* PreferencesView.swift */; }; A6F213132C8C038F00E1D23B /* LastFmManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F213122C8C038F00E1D23B /* LastFmManager.swift */; }; A6F213192C8C076600E1D23B /* ScriptingBridge.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F213152C8C05E800E1D23B /* ScriptingBridge.framework */; }; @@ -48,6 +48,7 @@ A6F2FEEF2EE79E47003826F7 /* Secrets.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = A6F2FEED2EE79E47003826F7 /* Secrets.xcconfig */; }; A6F2FEF22EE7A429003826F7 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F2FEF12EE7A429003826F7 /* Logger.swift */; }; A6F954FC2EED236000501378 /* LaunchAtLoginSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F954FB2EED236000501378 /* LaunchAtLoginSettingsView.swift */; }; + A6FFILTER0002 /* FriendsFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6FFILTER0001 /* FriendsFilterView.swift */; }; A6HIG00001 /* AppSelectionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6HIG00002 /* AppSelectionButton.swift */; }; A6HIG00003 /* FeatureRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6HIG00004 /* FeatureRow.swift */; }; A6HIG00005 /* ServiceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6HIG00006 /* ServiceRow.swift */; }; @@ -67,7 +68,6 @@ A6TESTBF0005 /* DesignTokensTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0005 /* DesignTokensTests.swift */; }; A6TESTBF0006 /* ScrobbleDelayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0006 /* ScrobbleDelayTests.swift */; }; A6TESTBF0007 /* UpdateStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0007 /* UpdateStateTests.swift */; }; - A6CLEANER0004 /* TrackMetadataCleanerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6CLEANER0003 /* TrackMetadataCleanerTests.swift */; }; A6TESTBF0008 /* CodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0008 /* CodableTests.swift */; }; A6TESTBF0009 /* CryptoUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6TESTFR0009 /* CryptoUtilsTests.swift */; }; A6UPDATE0001 /* UpdateState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6UPDATE0002 /* UpdateState.swift */; }; @@ -109,7 +109,6 @@ A622991C2EE7D72900592239 /* ServicesStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicesStatusView.swift; sourceTree = ""; }; A622991F2EE80F2C00592239 /* LabeledStepper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabeledStepper.swift; sourceTree = ""; }; A63A2B182D2717A1000BC92E /* FriendsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsView.swift; sourceTree = ""; }; - A6FFILTER0001 /* FriendsFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsFilterView.swift; sourceTree = ""; }; A63A2B1A2D271A95000BC92E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A64304B82D52EBAE001998B6 /* LastFmManagerDesktop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastFmManagerDesktop.swift; sourceTree = ""; tabWidth = 3; }; A64304BA2D52EC87001998B6 /* LastFMAuthSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastFMAuthSheetView.swift; sourceTree = ""; }; @@ -125,6 +124,8 @@ A6A580A22E78D8480079DC91 /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; }; A6A580A42E78D9340079DC91 /* NowPlayingFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingFetcher.swift; sourceTree = ""; }; A6CC84542EE8470C00236D47 /* MarqueeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarqueeText.swift; sourceTree = ""; }; + A6CLEANER0001 /* TrackMetadataCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackMetadataCleaner.swift; sourceTree = ""; }; + A6CLEANER0003 /* TrackMetadataCleanerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackMetadataCleanerTests.swift; sourceTree = ""; }; A6COMPAT012EFBAA0000002 /* GlassEffect+Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlassEffect+Compat.swift"; sourceTree = ""; }; A6COMPAT012EFBAA0000004 /* LastFMWebAuthViewLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastFMWebAuthViewLegacy.swift; sourceTree = ""; }; A6DESIGN012EFBAA0000002 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = ""; }; @@ -145,6 +146,7 @@ A6F2FEED2EE79E47003826F7 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; A6F2FEF12EE7A429003826F7 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; A6F954FB2EED236000501378 /* LaunchAtLoginSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginSettingsView.swift; sourceTree = ""; }; + A6FFILTER0001 /* FriendsFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendsFilterView.swift; sourceTree = ""; }; A6HIG00002 /* AppSelectionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSelectionButton.swift; sourceTree = ""; }; A6HIG00004 /* FeatureRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureRow.swift; sourceTree = ""; }; A6HIG00006 /* ServiceRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceRow.swift; sourceTree = ""; }; @@ -164,8 +166,6 @@ A6TESTFR0005 /* DesignTokensTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokensTests.swift; sourceTree = ""; }; A6TESTFR0006 /* ScrobbleDelayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrobbleDelayTests.swift; sourceTree = ""; }; A6TESTFR0007 /* UpdateStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateStateTests.swift; sourceTree = ""; }; - A6CLEANER0001 /* TrackMetadataCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackMetadataCleaner.swift; sourceTree = ""; }; - A6CLEANER0003 /* TrackMetadataCleanerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackMetadataCleanerTests.swift; sourceTree = ""; }; A6TESTFR0008 /* CodableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodableTests.swift; sourceTree = ""; }; A6TESTFR0009 /* CryptoUtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoUtilsTests.swift; sourceTree = ""; }; A6TESTFR000A /* scrobbleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scrobbleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -197,19 +197,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - A6SERVICES0001 /* Services */ = { - isa = PBXGroup; - children = ( - A6F2130E2C8C027C00E1D23B /* Scrobbler.swift */, - A6CLEANER0001 /* TrackMetadataCleaner.swift */, - A61F9BCE2E85E04A00CC1661 /* ScrobblingService.swift */, - A6F213122C8C038F00E1D23B /* LastFmManager.swift */, - A64304B82D52EBAE001998B6 /* LastFmManagerDesktop.swift */, - A6A580A42E78D9340079DC91 /* NowPlayingFetcher.swift */, - ); - path = Services; - sourceTree = ""; - }; A622991E2EE80F1600592239 /* View Components */ = { isa = PBXGroup; children = ( @@ -354,6 +341,19 @@ path = Onboarding; sourceTree = ""; }; + A6SERVICES0001 /* Services */ = { + isa = PBXGroup; + children = ( + A6F2130E2C8C027C00E1D23B /* Scrobbler.swift */, + A6CLEANER0001 /* TrackMetadataCleaner.swift */, + A61F9BCE2E85E04A00CC1661 /* ScrobblingService.swift */, + A6F213122C8C038F00E1D23B /* LastFmManager.swift */, + A64304B82D52EBAE001998B6 /* LastFmManagerDesktop.swift */, + A6A580A42E78D9340079DC91 /* NowPlayingFetcher.swift */, + ); + path = Services; + sourceTree = ""; + }; A6TESTGR0001 /* scrobbleTests */ = { isa = PBXGroup; children = ( @@ -687,7 +687,7 @@ CODE_SIGN_ENTITLEMENTS = scrobble/scrobble.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"scrobble/Preview Content\""; ENABLE_APP_SANDBOX = NO; @@ -737,7 +737,7 @@ CODE_SIGN_ENTITLEMENTS = scrobble/scrobble.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"scrobble/Preview Content\""; ENABLE_APP_SANDBOX = NO; diff --git a/scrobble/Services/TrackMetadataCleaner.swift b/scrobble/Services/TrackMetadataCleaner.swift index c3ee1fe..3195287 100644 --- a/scrobble/Services/TrackMetadataCleaner.swift +++ b/scrobble/Services/TrackMetadataCleaner.swift @@ -2,13 +2,7 @@ // TrackMetadataCleaner.swift // scrobble // -// Conservative cleanup for track metadata from web sources (#12). -// Browsers publish whatever the page sets via the Media Session API, so -// YouTube embeds arrive as e.g. artist "Gone - Topic", title -// "Gone - Bin Days (Official Video)". Native music apps are never touched. -// -// Rules are deliberately suffix/prefix-anchored: mid-title segments like -// "(feat. X)", "(Live)", or "(Remix)" carry meaning and are preserved. +// Created by Brett Henderson on 7/21/26. // import Foundation @@ -26,7 +20,7 @@ enum TrackMetadataCleaner { "org.mozilla.firefox", "com.microsoft.edgemac", "com.brave.browser", - "company.thebrowser.browser", // Arc + "company.thebrowser.browser", "com.vivaldi.vivaldi", "com.operasoftware.opera", "app.zen-browser.zen",