Skip to content

Commit f11d6ed

Browse files
committed
feat(subsonic): capability-driven sidebar refresh (Phase 19 step 16)
Subsonic capability changes now propagate to the sidebar without a relaunch. When a server upgrade unlocks Podcasts, Internet Radio, or Bookmarks, the relevant sections appear immediately. - SubsonicCapabilities: add hasSameCapabilityFlags(as:) that ignores fetchedAt so only real flag changes trigger a redraw. - SubsonicService: persist freshly fetched capabilities to the database via SubsonicServerStore.updateCapabilities; emit on AsyncStream<UUID> capabilityUpdates only when persisted flags actually changed. refreshCapabilities now bypasses the SwiftSonic client cache so a real network refetch happens. - SubsonicServerStore: add updateCapabilities passthrough to the Persistence repository. - UI module: add SubsonicCapabilityChangeObserving protocol; have LibraryViewModel subscribe and reload its sidebar listing on each emission. Task is cancelled in deinit. - App layer: SubsonicCapabilityObserver bridges SubsonicService into the UI protocol so the UI module stays decoupled from Subsonic. - Tests: 11 new tests covering flag comparison, store passthrough, and the full SubsonicService emission contract (initial persist, identical flags do not re-emit, flag change re-emits, cached load no-op); LibraryViewModel sidebar test verifying capability events drive reload. Subsonic suite grows from 23 to 34 tests.
1 parent 7cb7681 commit f11d6ed

10 files changed

Lines changed: 585 additions & 8 deletions

File tree

App/BocanApp.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,8 @@ struct BocanApp: App {
407407
subsonicSidebarListing: subsonicListing,
408408
subsonicDataSource: subsonicService,
409409
subsonicCoverArtProvider: SubsonicCoverArtProvider(service: subsonicService),
410-
subsonicAnnotationDelivery: subsonicAnnotations
410+
subsonicAnnotationDelivery: subsonicAnnotations,
411+
subsonicCapabilityObserver: SubsonicCapabilityObserver(service: subsonicService)
411412
)
412413
self.libraryViewModel = lvm
413414
self.subsonicSettingsViewModel = SubsonicSettingsViewModel(
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Foundation
2+
import Subsonic
3+
import UI
4+
5+
// MARK: - SubsonicCapabilityObserver
6+
7+
/// App-layer adapter bridging `SubsonicService.capabilityUpdates` to the UI
8+
/// module's `SubsonicCapabilityChangeObserving` protocol (Phase 19 step 16).
9+
///
10+
/// The Subsonic module isn't a dependency of UI, so this thin adapter is the
11+
/// glue point: it exposes the actor-owned `AsyncStream<UUID>` to consumers
12+
/// that only know the UI-level protocol.
13+
struct SubsonicCapabilityObserver: SubsonicCapabilityChangeObserving {
14+
let service: SubsonicService
15+
16+
func capabilityChanges() -> AsyncStream<UUID> {
17+
AsyncStream { continuation in
18+
let task = Task {
19+
for await id in await self.service.capabilityUpdates {
20+
continuation.yield(id)
21+
}
22+
continuation.finish()
23+
}
24+
continuation.onTermination = { _ in task.cancel() }
25+
}
26+
}
27+
}

Bocan.xcodeproj/project.pbxproj

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
26915B734070F50653BB0FB0 /* NoticesWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A48BC953E0F409019C6F855F /* NoticesWindowView.swift */; };
2828
2951251A22331F41815417C1 /* dmg-background.png in Resources */ = {isa = PBXBuildFile; fileRef = 5176B1403032D73D9D52B246 /* dmg-background.png */; };
2929
2F10190EBC449B79B6806730 /* Playback in Frameworks */ = {isa = PBXBuildFile; productRef = E05BB587A6FC66933F79D585 /* Playback */; };
30+
30FF0315CF8C0B599D8A50AC /* SubsonicCapabilityObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D68C949E587C48064DA7121 /* SubsonicCapabilityObserver.swift */; };
3031
360ACF651075DD2E38C74853 /* dmg-background@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = E3C3D9C828BB06085A4E0EA5 /* dmg-background@2x.png */; };
3132
396E0BCD608011A124CEB9BD /* HelpWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F9290FC0C85BA0735CCF7F2 /* HelpWindowView.swift */; };
3233
4048626C7584950E6D201081 /* LaunchSanity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DE49AE8BEADA5F7A3D8406 /* LaunchSanity.swift */; };
@@ -131,6 +132,7 @@
131132
8A1792042403A11B4E098A3A /* BocanCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BocanCommands.swift; sourceTree = "<group>"; };
132133
8B64EE7F110DC23AF67E1AFA /* AppLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLoggerTests.swift; sourceTree = "<group>"; };
133134
8C85F460580B049360716FE5 /* Observability */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Observability; path = Modules/Observability; sourceTree = SOURCE_ROOT; };
135+
8D68C949E587C48064DA7121 /* SubsonicCapabilityObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubsonicCapabilityObserver.swift; sourceTree = "<group>"; };
134136
8DE7AE70CABE9E93BDBE5955 /* NOTICES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = NOTICES.md; sourceTree = "<group>"; };
135137
8F7E8AA1C53EF99B1B37A2B8 /* RuleBuilderViewValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleBuilderViewValidationTests.swift; sourceTree = "<group>"; };
136138
92F911DF68929D5E17148259 /* VisualizerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualizerViewModelTests.swift; sourceTree = "<group>"; };
@@ -166,7 +168,7 @@
166168
EF6CEBB0A8EA190CE746448B /* libavcodec.62.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; path = libavcodec.62.dylib; sourceTree = "<group>"; };
167169
F401487E6DE506B5348C7F94 /* Library */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Library; path = Modules/Library; sourceTree = SOURCE_ROOT; };
168170
FD18A2E69E819F5845E511F3 /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateController.swift; sourceTree = "<group>"; };
169-
"TEMP_23A652FC-EF14-4E73-AAFE-A5D1D6B23A25" /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = "<group>"; };
171+
"TEMP_3A538497-78BF-4FA3-9EC3-B30B66A6E8B1" /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = "<group>"; };
170172
/* End PBXFileReference section */
171173

172174
/* Begin PBXFrameworksBuildPhase section */
@@ -372,6 +374,7 @@
372374
A48BC953E0F409019C6F855F /* NoticesWindowView.swift */,
373375
3D8E8EC191BDB21827F3BBAE /* RootView.swift */,
374376
C4262BE7B022BB4DC4BDA75C /* SingleInstance.swift */,
377+
8D68C949E587C48064DA7121 /* SubsonicCapabilityObserver.swift */,
375378
E13AA0034D3321486DB8390B /* SubsonicScrobbleDelivery.swift */,
376379
62CA9772727FF5A9FBC5ED1A /* SubsonicStoreSidebarListing.swift */,
377380
);
@@ -420,10 +423,10 @@
420423
path = AppTests;
421424
sourceTree = "<group>";
422425
};
423-
"TEMP_87A67758-678F-42DF-897B-86006F01354E" /* bocan-music */ = {
426+
"TEMP_25732EA7-2F9E-40A0-B01D-1CC4463F381A" /* bocan-music */ = {
424427
isa = PBXGroup;
425428
children = (
426-
"TEMP_23A652FC-EF14-4E73-AAFE-A5D1D6B23A25" /* Secrets.xcconfig */,
429+
"TEMP_3A538497-78BF-4FA3-9EC3-B30B66A6E8B1" /* Secrets.xcconfig */,
427430
);
428431
path = "bocan-music";
429432
sourceTree = "<group>";
@@ -654,6 +657,7 @@
654657
26915B734070F50653BB0FB0 /* NoticesWindowView.swift in Sources */,
655658
DDC6FD5FC258C4D93149F2AF /* RootView.swift in Sources */,
656659
8898D5F54BEE976BF1FB21AD /* SingleInstance.swift in Sources */,
660+
30FF0315CF8C0B599D8A50AC /* SubsonicCapabilityObserver.swift in Sources */,
657661
A1569BAE625F7393B7C52666 /* SubsonicScrobbleDelivery.swift in Sources */,
658662
1B8195C2FABED50EAFF33686 /* SubsonicStoreSidebarListing.swift in Sources */,
659663
FB5FF711E8BD55C735CF5F0D /* UpdateController.swift in Sources */,
@@ -705,7 +709,7 @@
705709
};
706710
3E79DA1DC07FE3812FB54C0A /* Debug */ = {
707711
isa = XCBuildConfiguration;
708-
baseConfigurationReference = "TEMP_23A652FC-EF14-4E73-AAFE-A5D1D6B23A25" /* Secrets.xcconfig */;
712+
baseConfigurationReference = "TEMP_3A538497-78BF-4FA3-9EC3-B30B66A6E8B1" /* Secrets.xcconfig */;
709713
buildSettings = {
710714
ALWAYS_SEARCH_USER_PATHS = NO;
711715
ARCHS = arm64;
@@ -841,7 +845,7 @@
841845
};
842846
D3B7E2D650BF9AC5F77082BA /* Release */ = {
843847
isa = XCBuildConfiguration;
844-
baseConfigurationReference = "TEMP_23A652FC-EF14-4E73-AAFE-A5D1D6B23A25" /* Secrets.xcconfig */;
848+
baseConfigurationReference = "TEMP_3A538497-78BF-4FA3-9EC3-B30B66A6E8B1" /* Secrets.xcconfig */;
845849
buildSettings = {
846850
ALWAYS_SEARCH_USER_PATHS = NO;
847851
ARCHS = arm64;

Modules/Subsonic/Sources/Subsonic/SubsonicCapabilities.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,24 @@ public struct SubsonicCapabilities: Sendable, Codable, Hashable {
129129
default: break
130130
}
131131
}
132+
133+
// MARK: - Flag comparison
134+
135+
/// Returns `true` when the user-visible capability flags match `other`,
136+
/// ignoring `fetchedAt`. Used by the capability refresh path to decide
137+
/// whether a sidebar redraw is needed.
138+
public func hasSameCapabilityFlags(as other: SubsonicCapabilities) -> Bool {
139+
self.serverType == other.serverType
140+
&& self.serverVersion == other.serverVersion
141+
&& self.apiVersion == other.apiVersion
142+
&& self.isOpenSubsonic == other.isOpenSubsonic
143+
&& self.supportsLyricsBySongId == other.supportsLyricsBySongId
144+
&& self.supportsApiKey == other.supportsApiKey
145+
&& self.supportsPodcasts == other.supportsPodcasts
146+
&& self.supportsInternetRadio == other.supportsInternetRadio
147+
&& self.supportsBookmarks == other.supportsBookmarks
148+
&& self.supportsJukebox == other.supportsJukebox
149+
&& self.supportsShares == other.supportsShares
150+
&& self.supportsRandomSongsByGenre == other.supportsRandomSongsByGenre
151+
}
132152
}

Modules/Subsonic/Sources/Subsonic/SubsonicServerStore.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,20 @@ public actor SubsonicServerStore {
100100
return self.fromDTO(dto)
101101
}
102102

103+
/// Persists a fresh capability snapshot for a server without rewriting the
104+
/// full record. Used by the capability refresh path (Phase 19 step 16).
105+
public func updateCapabilities(
106+
serverID: UUID,
107+
capabilitiesJSON: Data?,
108+
lastConnectedAt: Date = Date()
109+
) async throws {
110+
try await self.repository.updateCapabilities(
111+
id: serverID,
112+
capabilitiesJSON: capabilitiesJSON,
113+
lastConnectedAt: lastConnectedAt
114+
)
115+
}
116+
103117
// MARK: - Credential access
104118

105119
/// Reads the credential secret from the Keychain for `serverID`.

Modules/Subsonic/Sources/Subsonic/SubsonicService.swift

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,23 @@ public actor SubsonicService {
133133
private var clients: [UUID: ClientEntry] = [:]
134134
private let store: SubsonicServerStore
135135
private let log = AppLogger.make(.subsonic)
136+
private var (capabilityStream, capabilityContinuation) = AsyncStream<UUID>.makeStream()
136137

137138
// MARK: - Init
138139

139140
public init(store: SubsonicServerStore) {
140141
self.store = store
141142
}
142143

144+
/// Broadcasts server IDs whose advertised capabilities have changed since
145+
/// the previously persisted snapshot. The UI subscribes here to redraw
146+
/// the sidebar when a server upgrade unlocks new sections (Phase 19 step
147+
/// 16). Multiple subscribers are not supported — wrap in a fan-out if you
148+
/// need more than one consumer.
149+
public var capabilityUpdates: AsyncStream<UUID> {
150+
self.capabilityStream
151+
}
152+
143153
// MARK: - Client pool management
144154

145155
/// Rebuilds the entire client pool from the current server list.
@@ -193,7 +203,12 @@ public actor SubsonicService {
193203
do {
194204
let raw = try await client.loadCapabilities()
195205
let caps = SubsonicCapabilities.from(raw)
206+
let previous = try? await self.persistedCapabilities(serverID: serverID)
196207
self.clients[serverID]?.capabilities = caps
208+
try? await self.persistCapabilities(caps, serverID: serverID)
209+
if previous?.hasSameCapabilityFlags(as: caps) != true {
210+
self.capabilityContinuation.yield(serverID)
211+
}
197212
self.log.info(
198213
"subsonic.capabilities.loaded",
199214
[
@@ -210,9 +225,29 @@ public actor SubsonicService {
210225
}
211226

212227
/// Forces a fresh capability fetch, bypassing the staleness check.
228+
/// Also bypasses the SwiftSonic client's own capability cache so a real
229+
/// network refetch happens — required for capability-change detection
230+
/// after a server upgrade (Phase 19 step 16).
213231
public func refreshCapabilities(serverID: UUID) async throws -> SubsonicCapabilities {
232+
let client = try self.requireClient(serverID)
214233
self.clients[serverID]?.capabilities = nil
215-
return try await self.loadCapabilities(serverID: serverID)
234+
do {
235+
let raw = try await client.refreshCapabilities()
236+
let caps = SubsonicCapabilities.from(raw)
237+
let previous = try? await self.persistedCapabilities(serverID: serverID)
238+
self.clients[serverID]?.capabilities = caps
239+
try? await self.persistCapabilities(caps, serverID: serverID)
240+
if previous?.hasSameCapabilityFlags(as: caps) != true {
241+
self.capabilityContinuation.yield(serverID)
242+
}
243+
self.log.info(
244+
"subsonic.capabilities.refreshed",
245+
["id": serverID.uuidString, "type": caps.serverType ?? "unknown"]
246+
)
247+
return caps
248+
} catch let e as SwiftSonicError {
249+
throw SubsonicError.transport(e)
250+
}
216251
}
217252

218253
// MARK: - Browsing
@@ -470,6 +505,21 @@ public actor SubsonicService {
470505
return entry.client
471506
}
472507

508+
/// Reads the persisted capability snapshot for a server, or `nil` if none
509+
/// has been stored. Used to detect real capability changes before emitting
510+
/// on `capabilityUpdates`.
511+
private func persistedCapabilities(serverID: UUID) async throws -> SubsonicCapabilities? {
512+
guard let server = try await self.store.fetch(id: serverID),
513+
let data = server.cachedCapabilitiesJSON else { return nil }
514+
return try? JSONDecoder().decode(SubsonicCapabilities.self, from: data)
515+
}
516+
517+
/// Persists a fresh capability snapshot to the store.
518+
private func persistCapabilities(_ caps: SubsonicCapabilities, serverID: UUID) async throws {
519+
let data = try JSONEncoder().encode(caps)
520+
try await self.store.updateCapabilities(serverID: serverID, capabilitiesJSON: data)
521+
}
522+
473523
private func buildClient(for server: SubsonicServer) async throws {
474524
let secret = try await self.store.secret(for: server.id)
475525

@@ -522,4 +572,13 @@ public actor SubsonicService {
522572
capabilities: existing?.capabilities
523573
)
524574
}
575+
576+
// MARK: - Test hooks
577+
578+
/// Test-only seam: register a preconstructed `SwiftSonicClient` for a
579+
/// server without going through Keychain-backed `buildClient`. Production
580+
/// code must continue to use `reloadClients` / `refreshClient`.
581+
func _registerClientForTesting(_ client: SwiftSonicClient, serverID: UUID) {
582+
self.clients[serverID] = ClientEntry(client: client, capabilities: nil)
583+
}
525584
}

0 commit comments

Comments
 (0)