Skip to content

S97 — music hierarchy: make the music_* tables authoritative, never write media_items.parent_id for music#570

Merged
detain merged 4 commits into
masterfrom
s97-music-hierarchy-music-tables-authoritative
Jul 27, 2026
Merged

S97 — music hierarchy: make the music_* tables authoritative, never write media_items.parent_id for music#570
detain merged 4 commits into
masterfrom
s97-music-hierarchy-music-tables-authoritative

Conversation

@detain

@detain detain commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Decision this implements

Option B — the music_* tables are authoritative. media_items.parent_id is NEVER written for music.
Design settled 2026-07-27; full record in plan_updates_worklog.md, investigation in steps/S97.worklog.md.

Why not the parent_id chain:

  • media_items.parent_id is a nullable CHAR(36) with a plain index and no FK, while
    music_tracks.album_id/artist_id are INT UNSIGNED NOT NULL with enforced FKs + CASCADE. The chain is
    lossier by construction, so it cannot be made authoritative.
  • The only backfill S122's skip index permits is SQL derived from music_*parent_id would be a
    cache, not a source of truth.
  • The S95 same-INSERT constraint is unsatisfiable on the adoption path — an adopted orphan already
    exists with parent_id IS NULL.
  • Writing it would regress the generic surfaces: ?topLevel=1 would make albums and tracks vanish from
    the grid/total/A-Z rail, and shuffle would return unplayable album UUIDs.

Premise verified on prod, read-only: media_items artist 4,656 / album 10,966 / track 61,105 — matching
music_* exactly — with 0 of 76,727 carrying a parent_id. No migration, no backfill, no rescan, and the
S122 skip index is untouched.

Deleted (the non-authoritative path)

  • findAdoptableAlbumMediaItemId()'s OR mi.parent_id = ? branch + its $artistMediaItemId param.
    🔴 AND mi.parent_id IS NULL is kept and re-derived as an enforced invariant — it now fails safe and
    acts as the regression tripwire. The "if you ever DO parent music it must be the SAME INSERT" warning is
    carried forward in the docblock.
  • upsertTrack()'s dead $albumMediaItemId param + unset().
  • MusicLibraryManager::getArtists()/getAlbums()/getTracks(); ContentDirectoryService; unrouted
    MediaItemController::children().
  • Corrected the false comment claiming music album/artist rows are not scanner-created — they are.

Rewired onto music_*

Shuffle (was able to return unplayable album UUIDs), DLNA audio root + drill-down, and music search.

Two premises from the brief that were WRONG and are corrected here

  • Search: artist/album names were already searchable via media_items.name. The real defects were a
    mirror column nothing keeps in step with music_*, and orphaned mirror rows served as hits leading
    nowhere. Both fixed; the measurement is pinned in searchMusicContainers()'s docblock.
  • DLNA: the "76,727 flat objects" framing was wrong — getAllByType() caps at 100. The actual defect is
    that the advertised childCount and the deliverable list disagreed by three orders of magnitude.

Neither re-opens the option-B verdict.

Verification

  • Tests: 7673 (master baseline 7658; +15 fully accounted: −6 deleted, +21 added)
  • PHPStan [OK] No errors (698/698); phpcs PSR-12 clean
  • The repo's "Psalm Static Analysis" job is a NO-OP — psalm is not in composer.json, so
    composer install never installs it and the step is wrapped in if [ -f vendor/bin/psalm ]. Its green is
    meaningless. Filed separately; not introduced by this PR.

Out of scope

phlix-ui redirect of music libraries to /app/music (separate repo); S145 (upsertTrack() never
updating music_tracks.album_id/artist_id) — deliberately a separate step, behaviour left byte-identical.

detain and others added 2 commits July 27, 2026 00:11
The music_* tables are the ONE authoritative music hierarchy;
media_items.parent_id is NEVER written for artist/album/track. Decision
recorded in plan_updates_worklog.md (2026-07-27) and steps/S97.worklog.md;
verified read-only on production: artist 4,656 / album 10,966 /
track 61,105, and 0 of 76,727 carry a parent_id.

Deleted, because making one path authoritative means removing the other:

- MusicLibraryScanner::findAdoptableAlbumMediaItemId(): the
  `OR mi.parent_id = ?` branch and the $artistMediaItemId parameter, plus
  upsertAlbum()'s now-dead parameter and the flushAlbum() local that fed
  it. `AND mi.parent_id IS NULL` is KEPT and re-documented as an ENFORCED
  INVARIANT rather than a no-op filter, with the new bound derived
  deliberately: correctness needs "the orphan I adopt is interchangeable
  with a row I would mint now", and under this verdict title + library +
  unreferenced is already the complete identity of an album media_items
  row, so the parent check is pure defence that fails SAFE and is the
  tripwire against a regression re-introducing music parenting.
- upsertTrack()'s $albumMediaItemId parameter and its unset() — it was
  never read; a track is linked by music_tracks.album_id (NOT NULL,
  enforced FK, ON DELETE CASCADE).
- MusicLibraryManager::getArtists()/getAlbums()/getTracks() — the
  metadata_json readers S99 orphaned; zero production callers.
- Dlna\ContentDirectoryService — the six unwired music stubs returning
  hardcoded 'Unknown Artist' WERE the whole class, and the class had zero
  references anywhere in the repo (the live one is Dlna\ContentDirectory).

Tests: the album-adoption test is re-documented as the invariant test and
renamed accordingly, and a new statement-level regression test asserts
that no INSERT/UPDATE/REPLACE a scan issues so much as mentions
parent_id, while the adoption SELECT must still carry the invariant. The
MusicSchemaConnection double now models the narrowed predicate exactly,
so deleting it from the SQL still fails a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The consumers that reached for a music hierarchy were reaching for
media_items.parent_id, which is never written for music. They now read
music_*, which is.

MusicLibraryService gains the hierarchy readers they need
(getArtistMediaItemIds, getArtistsWithMediaItemCount,
getAlbumMediaItemIdsForArtist, getTrackMediaItemIdsForAlbum,
getTrackMediaItemIdsForArtist), all clamped to MAX_EMBEDDED_ROWS for the
same buffered-response reason as the existing batched reads. It is now a
container entry (MediaServicesProvider) instead of being new-ed inline,
since three call sites need it.

- Shuffle (MediaItemController): an album/artist resolves to TRACK
  media_items ids — the ids /media/{id}/stream can actually serve.
  Previously findByParent() returned nothing and it 404'd; a
  parent_id-based hierarchy would have returned unplayable container ids
  instead. The parental cap is re-applied to the resolved tracks, so the
  new path is not a hole in the cap. A null service keeps the old 404.
- DLNA (LibraryBridge): CATEGORY_TYPES['audio'] narrows from
  ['music','audio','album','artist','track','audiobook'] to
  ['artist','audio','audiobook'] — the true top level. The old list
  advertised 76,727+ children (countAllByType summed over six types)
  while getLibraryItems() could only return getAllByType()'s default page
  of 100 per type, so the promised childCount and the delivered list
  disagreed by three orders of magnitude, and albums/tracks sat beside
  their own artists with no way to descend. Artists are now enumerated
  from music_artists (so the count and the listing share one predicate
  and orphaned mirror rows are not advertised), and artist -> albums ->
  tracks drill-down comes from music_*. Non-music containers still use
  parent_id, which is where their hierarchy really lives.
- Search (ItemRepository): artist/album are matched on the authoritative
  music_artists.name / music_albums.title and excluded from the
  media_items half, so the match no longer runs against an unmaintained
  mirror column and orphaned artist/album rows stop being served as hits
  that lead nowhere. Two indexed statements rather than one OR-ed query,
  because an OR across MATCH..AGAINST and a joined LIKE cannot use the
  FULLTEXT index. Failure in the music half degrades to "no music hits".

Also fixes the false comment at MediaItemController's CONTAINER_TYPES,
which claimed music album/artist rows are not scanner-created; they are,
and the production counts prove it. MediaItemController::children() is
deleted — it was never registered on any route, and its three parental
tests are replaced by parental coverage of the new music shuffle path.

CORRECTION, measured read-only on production: the brief's claim that
"only track filenames are searchable; artist and album names are not" is
false in both halves. The scanner stamps artist names and album titles
into media_items.name, a track's name is its tag title, and both
MATCH(name) AGAINST('Eminem') and the LIKE form returned 135 artist +
74 album + 37 track rows. The real defects are the mirror column and the
orphan rows, which is what this change fixes; recorded in the
searchMusicContainers() docblock so the wrong premise is not re-derived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@codacy-production

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 3 medium · 34 minor

Alerts:
⚠ 37 issues (≤ 0 issues of at least minor severity)

Results:
37 new issues

Category Results
Documentation 7 minor
ErrorProne 2 medium
CodeStyle 26 minor
Complexity 1 medium
Comprehensibility 1 minor

View in Codacy

🟢 Metrics 0 duplication

Metric Results
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.03960% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.81%. Comparing base (5c0fdea) to head (665cabb).

Files with missing lines Patch % Lines
src/Media/Library/ItemRepository.php 96.55% 2 Missing ⚠️
src/Server/Core/Application.php 66.66% 2 Missing ⚠️
...rc/Server/Http/Controllers/MediaItemController.php 93.54% 2 Missing ⚠️
src/Dlna/LibraryBridge.php 97.91% 1 Missing ⚠️
src/Media/Music/MusicLibraryService.php 97.87% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master     #570      +/-   ##
============================================
+ Coverage     65.40%   65.81%   +0.40%     
+ Complexity    21061    21055       -6     
============================================
  Files           658      657       -1     
  Lines         66311    66241      -70     
============================================
+ Hits          43372    43596     +224     
+ Misses        22939    22645     -294     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

detain and others added 2 commits July 27, 2026 01:19
…livers

BLOCKING 1. getLibraryChildCount('audio') returned an unbounded COUNT(*) from
music_artists (4,656 on prod) while getLibraryItems('audio') stops at
MusicLibraryService::MAX_EMBEDDED_ROWS (2,000), so a renderer was promised 2.3x
what Browse handed it — and two docblocks asserted the two agreed. The count is
now clamped to that same constant, and both claims are replaced with a plain
statement of the residual bound: artists past MAX_EMBEDDED_ROWS are NOT
reachable over DLNA, because getLibraryItems() takes no offset and
ContentDirectory::browseChildren() slices an already-truncated list in PHP. Real
paging is a separate step and is not attempted here.

NB2. ItemRepository::mergeSearchResults() concatenated the music half after the
media_items half and cut at $limit, so a non-music half that filled the page
returned zero artists/albums. The second half now gets a reserved slice of up to
half the page, with the first half keeping whatever it does not use.

NB3. LibraryBridge::getMusicChildren() ran a findById() on EVERY drill-down just
to learn the type, including series/season ones that fell through to
findByParent(). ContentDirectory::browse() has already resolved and cached that
row, so it now hands the type over — one query saved per DLNA browse.

NB4. The audio-root listing could build one IN (...) with 2,000 placeholders.
Id resolution is chunked at 500 (the batch ceiling DuplicateFinder already uses);
findByIds() re-orders to the ids it was given, so the result is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tags once per page

NB1: tests/Unit/Dlna/LibraryBridgeTest.php — the assertion message claimed the
advertised childCount as a whole never exceeds what getLibraryItems() can
return. True for the artist branch the test exercises (the stub zeroes
audio/audiobook), false for the non-artist branches, where getLibraryChildCount()
still uses the unbounded countAllByType() against getAllByType()'s LIMIT 100
default. Message and docblock now name the ARTIST term explicitly and point the
residual defect at S147; the test is not widened and the non-artist branches are
untouched.

NB2: findByIdsChunked() called findByIds() per chunk, and findByIds() ends in
filterItemsByTags(), which issues two profile_tags queries per call when a
profile is set — 8 queries for a 2,000-id audio root instead of 2. findByIds()
gains an opt-out flag (default true, so all existing call sites are unchanged)
and the chunked reader applies filterItemsByTags() once over the concatenated
rows. Rows, row order and the 2,000 total bound are identical: the filter is an
order-preserving per-item predicate over the same tag sets, so one pass drops
exactly what the per-chunk passes dropped.

Tests: 7681, Assertions: 49936, Skipped: 129. PHPStan [OK] No errors.
phpcs --standard=PSR12 -n src/ clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@detain
detain merged commit 3aaaac7 into master Jul 27, 2026
16 of 17 checks passed
@detain
detain deleted the s97-music-hierarchy-music-tables-authoritative branch July 27, 2026 05:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant