Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions Sources/MediaRemoteDataSource/MediaRemoteDataSourceImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ public final class MediaRemoteDataSourceImpl: @unchecked Sendable {
@Dependency(\.processGateway) private var gateway

private let state = StreamStateBox()
private let decodeBase64: @Sendable (String) -> Data?

public init() {}
public convenience init() {
self.init(decodeBase64: { Data(base64Encoded: $0) })
}

init(decodeBase64: @escaping @Sendable (String) -> Data?) {
self.decodeBase64 = decodeBase64
}
}

extension MediaRemoteDataSourceImpl: MediaRemoteDataSource {
Expand Down Expand Up @@ -39,7 +46,7 @@ extension MediaRemoteDataSourceImpl: MediaRemoteDataSource {
NowPlaying(
title: json["title"] as? String,
artist: json["artist"] as? String,
artworkData: (json["artwork_base64"] as? String).flatMap { Data(base64Encoded: $0) },
artworkData: artworkData(from: json["artwork_base64"] as? String),
duration: json["duration"] as? Double,
rawElapsed: json["elapsed"] as? Double,
playbackRate: json["rate"] as? Double ?? 1.0,
Expand Down Expand Up @@ -90,10 +97,27 @@ extension MediaRemoteDataSourceImpl {
state.isPolling = false
state.iterator = nextIterator
}

/// Decodes the artwork base64 payload, reusing the previous result while the
/// payload is unchanged. The helper re-broadcasts the full now-playing payload
/// on every event, so without memoization the (potentially megabyte-scale)
/// artwork would be re-decoded on each elapsed-time tick (#270).
private func artworkData(from base64: String?) -> Data? {
guard let base64 else { return nil }
state.lock.lock()
defer { state.lock.unlock() }
if state.lastArtworkBase64 == base64 { return state.lastArtworkData }
let decoded = decodeBase64(base64)
state.lastArtworkBase64 = base64
state.lastArtworkData = decoded
return decoded
}
}

private final class StreamStateBox: @unchecked Sendable {
let lock = NSLock()
var iterator: AsyncStream<String>.AsyncIterator?
var isPolling = false
var lastArtworkBase64: String?
var lastArtworkData: Data?
}
2 changes: 1 addition & 1 deletion Sources/VersionHandler/Resources/version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.13.10
2.13.11
110 changes: 108 additions & 2 deletions Tests/MediaRemoteDataSourceTests/MediaRemoteDataSourceImplTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,89 @@ struct MediaRemoteDataSourceImplTests {
}
}

@Test("artwork base64 is decoded once while the payload is unchanged")
func artworkDecodedOnceForUnchangedBase64() async throws {
let artwork = Data("artwork-bytes".utf8).base64EncodedString()
let gateway = StreamingGateway(streamPlans: [
[
Self.jsonLine(title: "Song", artist: "Artist", hasInfo: true, artworkBase64: artwork),
Self.jsonLine(title: "Song", artist: "Artist", hasInfo: true, artworkBase64: artwork),
]
])
let decoder = CountingDecoder()

await withDependencies {
$0.processGateway = gateway
} operation: {
let dataSource = MediaRemoteDataSourceImpl(decodeBase64: decoder.decode)
let artworks = [await dataSource.poll(), await dataSource.poll()].map {
result -> Data? in
guard case .info(let nowPlaying) = result else {
Issue.record("Expected .info, got \(result)")
return nil
}
return nowPlaying.artworkData
}

#expect(artworks == [Data("artwork-bytes".utf8), Data("artwork-bytes".utf8)])
#expect(decoder.count == 1)
}
}

@Test("artwork base64 is re-decoded when the payload changes")
func artworkRedecodedForChangedBase64() async throws {
let firstArtwork = Data("first-artwork".utf8).base64EncodedString()
let secondArtwork = Data("second-artwork".utf8).base64EncodedString()
let gateway = StreamingGateway(streamPlans: [
[
Self.jsonLine(
title: "Song", artist: "Artist", hasInfo: true, artworkBase64: firstArtwork),
Self.jsonLine(
title: "Song", artist: "Artist", hasInfo: true, artworkBase64: secondArtwork),
]
])
let decoder = CountingDecoder()

await withDependencies {
$0.processGateway = gateway
} operation: {
let dataSource = MediaRemoteDataSourceImpl(decodeBase64: decoder.decode)
let artworks = [await dataSource.poll(), await dataSource.poll()].map {
result -> Data? in
guard case .info(let nowPlaying) = result else {
Issue.record("Expected .info, got \(result)")
return nil
}
return nowPlaying.artworkData
}

#expect(artworks == [Data("first-artwork".utf8), Data("second-artwork".utf8)])
#expect(decoder.count == 2)
}
}

@Test("artwork decode is skipped when the payload has no artwork")
func artworkDecodeSkippedForMissingArtwork() async throws {
let gateway = StreamingGateway(streamPlans: [
[Self.jsonLine(title: "Song", artist: "Artist", hasInfo: true)]
])
let decoder = CountingDecoder()

await withDependencies {
$0.processGateway = gateway
} operation: {
let dataSource = MediaRemoteDataSourceImpl(decodeBase64: decoder.decode)
let result = await dataSource.poll()

guard case .info(let nowPlaying) = result else {
Issue.record("Expected .info, got \(result)")
return
}
#expect(nowPlaying.artworkData == nil)
#expect(decoder.count == 0)
}
}

@Test("poll spawns the helper via the Apple-signed swift interpreter")
func pollInvokesInterpretMode() async throws {
let gateway = StreamingGateway(streamPlans: [
Expand Down Expand Up @@ -177,7 +260,9 @@ struct MediaRemoteDataSourceImplTests {
}

extension MediaRemoteDataSourceImplTests {
fileprivate static func jsonLine(title: String, artist: String, hasInfo: Bool) -> String {
fileprivate static func jsonLine(
title: String, artist: String, hasInfo: Bool, artworkBase64: String? = nil
) -> String {
let payload: [String: Any] = [
"has_info": hasInfo,
"title": title,
Expand All @@ -187,11 +272,32 @@ extension MediaRemoteDataSourceImplTests {
"rate": 1.0,
"timestamp": 10.0,
]
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return "" }
let payloadWithArtwork = artworkBase64.map {
payload.merging(["artwork_base64": $0]) { _, new in new }
}
guard
let data = try? JSONSerialization.data(withJSONObject: payloadWithArtwork ?? payload)
else { return "" }
return String(decoding: data, as: UTF8.self)
}
}

private final class CountingDecoder: @unchecked Sendable {
private let lock = NSLock()
private var decodeCallCount = 0

var count: Int {
lock.withLock { decodeCallCount }
}

@Sendable func decode(_ base64: String) -> Data? {
lock.withLock {
decodeCallCount += 1
return Data(base64Encoded: base64)
}
}
}

private struct CapturedCommand: Sendable {
let executable: String
let arguments: [String]
Expand Down
Loading