From 5166e94cb237a3c6db751945cf08f80f78d4719d Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 12:10:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?perf(#270):=20artwork=20base64=20=E3=81=8C?= =?UTF-8?q?=E6=9C=AA=E5=A4=89=E5=8C=96=E3=81=AA=E3=82=89=E3=83=87=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E6=B8=88=E3=81=BF=20Data=20=E3=82=92?= =?UTF-8?q?=E5=86=8D=E5=88=A9=E7=94=A8=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit media-remote-helper はイベント毎にフルペイロードを再送するため、同一 トラックの elapsed 更新でも高解像度アートワークのフルデコードが毎回 走っていた。直前の base64 文字列と比較し、一致時はデコード済み Data を 返すメモ化を StreamStateBox(lock 保護)に追加。デコード関数は init 注入 にしてテストで呼び出し回数を検証できるようにした。 --- .../MediaRemoteDataSourceImpl.swift | 28 ++++- .../MediaRemoteDataSourceImplTests.swift | 110 +++++++++++++++++- 2 files changed, 134 insertions(+), 4 deletions(-) diff --git a/Sources/MediaRemoteDataSource/MediaRemoteDataSourceImpl.swift b/Sources/MediaRemoteDataSource/MediaRemoteDataSourceImpl.swift index f0378d8..7c4310b 100644 --- a/Sources/MediaRemoteDataSource/MediaRemoteDataSourceImpl.swift +++ b/Sources/MediaRemoteDataSource/MediaRemoteDataSourceImpl.swift @@ -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 { @@ -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, @@ -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.AsyncIterator? var isPolling = false + var lastArtworkBase64: String? + var lastArtworkData: Data? } diff --git a/Tests/MediaRemoteDataSourceTests/MediaRemoteDataSourceImplTests.swift b/Tests/MediaRemoteDataSourceTests/MediaRemoteDataSourceImplTests.swift index 572f742..dc1f8d5 100644 --- a/Tests/MediaRemoteDataSourceTests/MediaRemoteDataSourceImplTests.swift +++ b/Tests/MediaRemoteDataSourceTests/MediaRemoteDataSourceImplTests.swift @@ -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: [ @@ -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, @@ -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] From 3162e452b50ad78bba786cade3629bd3d69a5222 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 12:10:58 +0900 Subject: [PATCH 2/3] chore: bump version to 2.13.10 --- Sources/VersionHandler/Resources/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index 6ad4f1e..3caff04 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.13.9 +2.13.10 From e7b7c50e2885d56c39a6c7915061788920abc355 Mon Sep 17 00:00:00 2001 From: GeneralD Date: Fri, 12 Jun 2026 14:31:10 +0900 Subject: [PATCH 3/3] chore: bump version to 2.13.11 --- Sources/VersionHandler/Resources/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index 3caff04..3c5ed78 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.13.10 +2.13.11