diff --git a/Sources/Kaset/Views/MiniPlayerWebView+Coordinator.swift b/Sources/Kaset/Views/MiniPlayerWebView+Coordinator.swift index cbdd2f543..9fe3ae404 100644 --- a/Sources/Kaset/Views/MiniPlayerWebView+Coordinator.swift +++ b/Sources/Kaset/Views/MiniPlayerWebView+Coordinator.swift @@ -24,6 +24,26 @@ extension SingletonPlayerWebView { return decoded } + /// Sanitizes the thumbnail URL reported by the playback bridge. + /// + /// `img.src` resolves relative to the document, so a player-bar image that has not + /// yet been assigned a source reports the YouTube Music page URL. That value fetches + /// successfully as HTML and then fails to decode, so it must never reach `currentTrack`. + nonisolated static func playbackBridgeThumbnailURLString(from value: Any?) -> String { + guard let raw = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty, + let components = URLComponents(string: raw), + let scheme = components.scheme?.lowercased(), + scheme == "https" || scheme == "http", + let host = components.host, + !host.isEmpty, + !components.path.isEmpty, + components.path != "/" + else { return "" } + + return raw + } + // MARK: - Coordinator final class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { @@ -601,7 +621,7 @@ private extension SingletonPlayerWebView.Coordinator { let hasReadyMedia = body["hasReadyMedia"] as? Bool ?? false let title = body["title"] as? String ?? "" let artist = body["artist"] as? String ?? "" - let thumbnailUrl = body["thumbnailUrl"] as? String ?? "" + let thumbnailUrl = SingletonPlayerWebView.playbackBridgeThumbnailURLString(from: body["thumbnailUrl"]) let trackChanged = body["trackChanged"] as? Bool ?? false let likeStatus = Self.likeStatus(from: body["likeStatus"] as? String) let hasVideo = body["hasVideo"] as? Bool ?? false diff --git a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift index 0d53bd5a3..eb363e1c3 100644 --- a/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift +++ b/Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift @@ -679,7 +679,11 @@ extension SingletonPlayerWebView { // Get the thumbnail URL from the image element if (thumbEl) { - thumbnailUrl = thumbEl.src || thumbEl.getAttribute('src') || ''; + // `img.src` resolves against the document base URL, so an empty or + // missing attribute reports the YouTube Music page URL instead of ''. + // Gate on the literal attribute before trusting the resolved value. + const rawThumbSrc = (thumbEl.getAttribute('src') || '').trim(); + thumbnailUrl = rawThumbSrc ? (thumbEl.src || rawThumbSrc) : ''; } // Extract like status from the like button renderer diff --git a/Tests/KasetTests/AutoplayRecoveryTests.swift b/Tests/KasetTests/AutoplayRecoveryTests.swift index ed4b0abdd..72215e025 100644 --- a/Tests/KasetTests/AutoplayRecoveryTests.swift +++ b/Tests/KasetTests/AutoplayRecoveryTests.swift @@ -778,4 +778,28 @@ struct MusicPlaybackBridgeDecodingTests { #expect(SingletonPlayerWebView.finitePlaybackBridgeDouble(from: true) == nil) #expect(SingletonPlayerWebView.finitePlaybackBridgeDouble(from: "1.75") == nil) } + + @Test("Playback bridge rejects the document URL reported by an unset thumbnail element") + func rejectsDocumentURLThumbnail() { + #expect(SingletonPlayerWebView.playbackBridgeThumbnailURLString(from: "https://music.youtube.com/").isEmpty) + #expect(SingletonPlayerWebView.playbackBridgeThumbnailURLString(from: "https://music.youtube.com").isEmpty) + } + + @Test("Playback bridge rejects empty and malformed thumbnail sources") + func rejectsInvalidThumbnails() { + #expect(SingletonPlayerWebView.playbackBridgeThumbnailURLString(from: "").isEmpty) + #expect(SingletonPlayerWebView.playbackBridgeThumbnailURLString(from: " ").isEmpty) + #expect(SingletonPlayerWebView.playbackBridgeThumbnailURLString(from: nil).isEmpty) + #expect(SingletonPlayerWebView.playbackBridgeThumbnailURLString(from: 42).isEmpty) + #expect(SingletonPlayerWebView.playbackBridgeThumbnailURLString(from: "data:image/png;base64,AAAA").isEmpty) + } + + @Test("Playback bridge preserves real artwork sources") + func preservesArtworkThumbnails() { + let artwork = "https://lh3.googleusercontent.com/abc=w544-h544-l90-rj" + #expect(SingletonPlayerWebView.playbackBridgeThumbnailURLString(from: artwork) == artwork) + + let fallback = "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg" + #expect(SingletonPlayerWebView.playbackBridgeThumbnailURLString(from: fallback) == fallback) + } }