Deepening opportunities: where a shallow shape (interface nearly as
complex as its implementation) or a smeared concern could become a
deep module β a lot of behaviour behind a small
interface, placed at a clean seam, and
testable through that interface. Framed in the /codebase-design
vocabulary (module Β· interface Β· depth Β· seam Β· adapter Β· leverage Β· locality) and the domain names
from CLAUDE.md (no CONTEXT.md exists yet).
MainWindow.cpp Β· 74 methods Β· 25 project includes Β· 115 connect()SettingsDialog.cpp Β· 11-arg ctor Β· QSettings keys in 14 filesA UI class belongs here β but dissolved into it are whole subsystems that aren't UI: the two-phase import coordination (enumerate β hydrate β write m3u8 β notify), the worker-thread ownership for the library + probe, and the Subsonic-sync lifecycle. The tell is 25 project includes: MainWindow depends on nearly the whole codebase because it is the wiring harness. It changes for a dozen unrelated reasons (Divergent Change).
Shed the non-UI coordinators into their own deep modules β an import
coordinator owning :339β389, a library host owning the
thread wiring, a Subsonic sync host owning :2272β2305.
MainWindow keeps layout + user-intent routing and talks to each through a narrow seam.
Two contradictory persistence contracts coexist. Getter-based settings
(folders(), restoreQueue()β¦) are read by the host on Accept; other controls
write QSettings themselves. So after one dialog round-trip, MainWindow must
re-read playback/preferHq and playback/ignoreTitles back out to push
them into the controller (:1402β1406). Keys like ui/restoreQueue are
duplicated 2β3Γ as bare strings; a typo in one site fails silently. This is
Primitive Obsession at codebase scale β a domain concept (a setting)
smeared as loose strings instead of a type.
One deep Settings module owns every key and its default; both the dialog
and the app read/write through it. The QSettings backend hides behind that single seam
(so an in-memory adapter becomes possible for tests). The 11-arg ctor collapses when the dialog reads
settings itself rather than being handed them positionally.
QSettings file.QSettings adapter and an in-memory test adapter
genuinely vary across this seam β so it isn't speculative; it's earned.
subsonic-cover: token parsed twice
The window parses the subsonic-cover:<server>:<id> token, SHA-1s it to a cache
filename, checks the disk cache, resolves the server, builds the auth URL via
SubsonicClient::coverArtUrl(), issues a raw QNetworkAccessManager::get, and
writes the bytes β all Subsonic-client work implemented in the UI, guarded by an ad-hoc
m_currentCoverToken race flag. The token format is a shared secret with no single
owner. "How does a Subsonic cover appear?" forces a bounce across MainWindow β SubsonicClient β
the DB's art_url token β Feature Envy across a leaky seam.
Move cover resolution behind the Subsonic seam: the token, cache path, URL, and fetch become one module's business. The window asks for "the cover for this track" and receives a path β it stops knowing the token grammar or the auth scheme.
QNetworkAccessManager in the UI.
Pairs naturally with SubsonicClient's split-brain: it already mixes a static server
config store (servers()/saveServers()) with an async sync client β the
same module wanting to be two.
The remote-play plan is spread across m_autoNext, m_prefetchUrl,
m_prefetchStream, m_prefetchTriggered, m_history,
m_historyPos, m_index β invariants enforced only by prose comments.
playInternal branches 5 ways (remote / subsonic / prefetched / in-flight-prefetch /
fresh), each poking prefetch state differently; decideAutoNext rolls the shuffle choice
"once here so it isn't repeated by the prefetch and the actual advance." This
is exactly where real bugs live (gapless prefetch, double-rolled shuffle) β and it's reachable
only by driving QMediaPlayer::MediaStatus transitions through a worker thread. Meanwhile
the pure helpers that got extracted (applyPreferHq, qualityTier) are
the parts that need testing least. Testability was extracted where the bugs aren't.
Pull the queue/history/prefetch decision into a deep module that takes the current state + an
event ("track ended", "prefetch resolved") and returns what to do next β no
QMediaPlayer, no threads. The controller keeps the I/O; the planner keeps the logic.
Four modules all mean "run something async, hand back results," each re-deriving the same shell:
an in-flight guard, a supersede-previous rule, a status(QString) progress signal, and a
(done, failed) terminal pair. Importer and SubsonicClient
independently re-invent "job queue + in-flight counter + emit-once-drained." That's
Duplicated Code at the shape level.
Note too that RemoteResolver is borderline shallow: its whole .cpp is 43 lines β
set the URL, run yt-dlp -g, split stdout, emit the first line. Its header even calls
ytDlpPath() "a thin wrapper." Its interface is nearly as big as its body; it earns only a
little keep (DRM-message mapping + pageUrl guard).
QProcess job and a paged Subsonic REST sync are genuinely different insides.
Forcing them under one abstraction risks Speculative Generality β a base
that fits none well. Worth exploring whether a small shared "async job" helper removes real
duplication, but don't build the grand unified fetcher on spec.
Live (PlayerController::onMetaDataChanged) and headless
(MetadataProbe) funnel into applyResolvedMetadata via a
updateNowPlaying flag β 5 modules / 2 threads for one concept, with a DB-schema fact
(WHERE path='') leaking up into a UI method.
MusicLibrary::m_scanning (re-entrancy hack for the
event-loop-pumping scan) and MediaEngine::m_pending{Volume,DeviceId,Visualizer} (constructβmoveβinit
bridge) both concentrate a real threading concern β they earn keep, but are the seams most
likely to grow subtle bugs and have no test surface.
Spectrum.h hides a from-scratch FFT + Hann window + log
banding behind push/bands/reset. ShaderArt.h seals QRhi + the private-QShader
trick behind 4 setters. These are the reference for "deep."
#ifdefThe visualizer's own modules are deep; only its wiring
(HAVE_VISUALIZER across MainWindow, SettingsDialog, PlayerController) is smeared. Largely
dissolves once Candidate 01 sheds the visualizer host.
Candidate 01 (splitting MainWindow) is the biggest friction, but it's a large, sprawling refactor touching everything β high risk with zero tests to catch regressions. Invert the order: extract the playback planner (Candidate 04) first. It's the smallest seam with the highest payoff β it wraps the code where real bugs actually hide (gapless prefetch, double-rolled shuffle), and unlike everything else it becomes unit-testable without an event loop. That gives you the first tests in the codebase, right around the trickiest logic β which then de-risks the larger MainWindow teardown.
Sequence: 04 (planner + first tests) β 02 (a Settings module, another genuinely-earned test seam, and it unblocks the SettingsDialog shrink) β 03 (close the Subsonic cover leak) β 01 (shed coordinators from MainWindow, now with tests underneath). Hold 05 until duplication actually hurts β it's speculative today.
Diagnosis only β no interfaces proposed yet. Pick one and we'll walk the design tree together.