Multi-disc (#927): capture real disc number at media-server scan time
Every library track was stored with disc_number=1 because the Jellyfin/Plex/Navidrome scan parsed the track number but never the disc field. Multi-disc albums collapsed onto disc 1, so disc-2+ tracks were mis-filed (shown under disc 1) and flagged 'missing' — the frontend title-fallback band-aid couldn't recover it (breaks on iTunes title mismatches). Now the shared insert_or_update_media_track reads the disc number (Jellyfin .discNumber=ParentIndexNumber, Navidrome .discNumber, Plex .parentIndex), floors to >=1, and stores it in the INSERT + UPDATE. The disc_number column is ensured on init (it was only added by a migration that doesn't run on fresh installs, so the new INSERT would have hard-failed for new users). The enhanced album view already carries disc_number through (SELECT * -> dict), so the display fixes itself once the column is populated — a re-scan backfills existing libraries. Seam-tested across Jellyfin/Navidrome/Plex shapes + the floor-to-1 + re-scan-update cases.
This commit is contained in:
parent
3c33e31985
commit
086d153d77
4 changed files with 95 additions and 5 deletions
|
|
@ -105,6 +105,7 @@ class JellyfinTrack:
|
||||||
self.title = jellyfin_data.get('Name', 'Unknown Track')
|
self.title = jellyfin_data.get('Name', 'Unknown Track')
|
||||||
self.duration = jellyfin_data.get('RunTimeTicks', 0) // 10000 # Convert from ticks to milliseconds
|
self.duration = jellyfin_data.get('RunTimeTicks', 0) // 10000 # Convert from ticks to milliseconds
|
||||||
self.trackNumber = jellyfin_data.get('IndexNumber')
|
self.trackNumber = jellyfin_data.get('IndexNumber')
|
||||||
|
self.discNumber = jellyfin_data.get('ParentIndexNumber') # multi-disc: disc number
|
||||||
self.year = jellyfin_data.get('ProductionYear')
|
self.year = jellyfin_data.get('ProductionYear')
|
||||||
self.userRating = jellyfin_data.get('UserData', {}).get('Rating')
|
self.userRating = jellyfin_data.get('UserData', {}).get('Rating')
|
||||||
self.addedAt = self._parse_date(jellyfin_data.get('DateCreated'))
|
self.addedAt = self._parse_date(jellyfin_data.get('DateCreated'))
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ class NavidromeTrack:
|
||||||
self.title = navidrome_data.get('title', 'Unknown Track')
|
self.title = navidrome_data.get('title', 'Unknown Track')
|
||||||
self.duration = navidrome_data.get('duration', 0) * 1000 # Convert to milliseconds
|
self.duration = navidrome_data.get('duration', 0) * 1000 # Convert to milliseconds
|
||||||
self.trackNumber = navidrome_data.get('track')
|
self.trackNumber = navidrome_data.get('track')
|
||||||
|
self.discNumber = navidrome_data.get('discNumber') # multi-disc: disc number
|
||||||
self.year = navidrome_data.get('year')
|
self.year = navidrome_data.get('year')
|
||||||
self.userRating = navidrome_data.get('userRating')
|
self.userRating = navidrome_data.get('userRating')
|
||||||
self.addedAt = self._parse_date(navidrome_data.get('created'))
|
self.addedAt = self._parse_date(navidrome_data.get('created'))
|
||||||
|
|
|
||||||
|
|
@ -1171,6 +1171,13 @@ class MusicDatabase:
|
||||||
if track_cols and 'year' not in track_cols:
|
if track_cols and 'year' not in track_cols:
|
||||||
cursor.execute("ALTER TABLE tracks ADD COLUMN year INTEGER")
|
cursor.execute("ALTER TABLE tracks ADD COLUMN year INTEGER")
|
||||||
logger.info("Repaired missing year column on tracks table (#910)")
|
logger.info("Repaired missing year column on tracks table (#910)")
|
||||||
|
# #927 — multi-disc fix: the scan now writes a real disc_number, but the column
|
||||||
|
# was only ever added by a separate migration that doesn't run on fresh installs,
|
||||||
|
# so the new INSERT/UPDATE would hard-fail with "no column named disc_number".
|
||||||
|
# Same shape as the year repair above: additive, defaults to 1, ensured on every DB.
|
||||||
|
if track_cols and 'disc_number' not in track_cols:
|
||||||
|
cursor.execute("ALTER TABLE tracks ADD COLUMN disc_number INTEGER DEFAULT 1")
|
||||||
|
logger.info("Repaired missing disc_number column on tracks table (#927)")
|
||||||
|
|
||||||
cursor.execute("PRAGMA table_info(albums)")
|
cursor.execute("PRAGMA table_info(albums)")
|
||||||
album_cols = {c[1] for c in cursor.fetchall()}
|
album_cols = {c[1] for c in cursor.fetchall()}
|
||||||
|
|
@ -6635,6 +6642,19 @@ class MusicDatabase:
|
||||||
track_id = str(track_obj.ratingKey)
|
track_id = str(track_obj.ratingKey)
|
||||||
title = track_obj.title
|
title = track_obj.title
|
||||||
track_number = getattr(track_obj, 'trackNumber', None)
|
track_number = getattr(track_obj, 'trackNumber', None)
|
||||||
|
# Multi-disc: capture the disc number so multi-disc albums don't all
|
||||||
|
# collapse onto disc 1 (which mis-files disc-2+ tracks and flags them
|
||||||
|
# "missing"). Jellyfin/Navidrome wrappers set .discNumber; plexapi's Track
|
||||||
|
# exposes .parentIndex. Floor to >=1 — a missing/0 disc is disc 1.
|
||||||
|
_raw_disc = getattr(track_obj, 'discNumber', None)
|
||||||
|
if _raw_disc is None:
|
||||||
|
_raw_disc = getattr(track_obj, 'parentIndex', None)
|
||||||
|
try:
|
||||||
|
disc_number = int(_raw_disc)
|
||||||
|
if disc_number < 1:
|
||||||
|
disc_number = 1
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
disc_number = 1
|
||||||
duration = getattr(track_obj, 'duration', None)
|
duration = getattr(track_obj, 'duration', None)
|
||||||
|
|
||||||
# Get file path and media info (Plex-specific, Jellyfin may not have these)
|
# Get file path and media info (Plex-specific, Jellyfin may not have these)
|
||||||
|
|
@ -6726,9 +6746,9 @@ class MusicDatabase:
|
||||||
if is_new_track:
|
if is_new_track:
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO tracks
|
INSERT INTO tracks
|
||||||
(id, album_id, artist_id, title, track_number, duration, file_path, bitrate, file_size, server_source, track_artist, musicbrainz_recording_id, updated_at)
|
(id, album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, musicbrainz_recording_id, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
""", (track_id, album_id, artist_id, title, track_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid))
|
""", (track_id, album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid))
|
||||||
else:
|
else:
|
||||||
# Update server-provided fields only — preserves spotify_track_id, deezer_id,
|
# Update server-provided fields only — preserves spotify_track_id, deezer_id,
|
||||||
# isrc, bpm, and all other enrichment data. file_size uses
|
# isrc, bpm, and all other enrichment data. file_size uses
|
||||||
|
|
@ -6737,7 +6757,7 @@ class MusicDatabase:
|
||||||
# an existing value.
|
# an existing value.
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
UPDATE tracks
|
UPDATE tracks
|
||||||
SET album_id = ?, artist_id = ?, title = ?, track_number = ?,
|
SET album_id = ?, artist_id = ?, title = ?, track_number = ?, disc_number = ?,
|
||||||
duration = ?, file_path = ?, bitrate = ?,
|
duration = ?, file_path = ?, bitrate = ?,
|
||||||
file_size = COALESCE(?, file_size),
|
file_size = COALESCE(?, file_size),
|
||||||
server_source = ?,
|
server_source = ?,
|
||||||
|
|
@ -6745,7 +6765,7 @@ class MusicDatabase:
|
||||||
musicbrainz_recording_id = COALESCE(?, musicbrainz_recording_id),
|
musicbrainz_recording_id = COALESCE(?, musicbrainz_recording_id),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""", (album_id, artist_id, title, track_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid, track_id))
|
""", (album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid, track_id))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
|
||||||
68
tests/test_media_track_disc_number.py
Normal file
68
tests/test_media_track_disc_number.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Multi-disc fix (#927): the media-server scan must store the real disc number.
|
||||||
|
|
||||||
|
Every track was stored with disc_number=1 because the Jellyfin/Plex/Navidrome scan never
|
||||||
|
read the disc field — so multi-disc albums collapsed onto disc 1, mis-filing disc-2+ tracks
|
||||||
|
and flagging them "missing". insert_or_update_media_track now reads the disc number off the
|
||||||
|
track object (Jellyfin/Navidrome `.discNumber`, Plex `.parentIndex`), floored to >=1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from database.music_database import MusicDatabase
|
||||||
|
|
||||||
|
|
||||||
|
def _db(tmp_path):
|
||||||
|
db = MusicDatabase(database_path=str(tmp_path / "t.db"))
|
||||||
|
with db._get_connection() as c:
|
||||||
|
c.execute("INSERT OR IGNORE INTO artists (id, name) VALUES ('art1', 'Evan Call')")
|
||||||
|
c.execute("INSERT OR IGNORE INTO albums (id, artist_id, title) VALUES ('alb1', 'art1', 'Frieren OST')")
|
||||||
|
c.commit()
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def _track(rating_key, title, track_number, **kw):
|
||||||
|
return SimpleNamespace(ratingKey=rating_key, title=title, trackNumber=track_number,
|
||||||
|
duration=200000, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _disc_of(db, track_id):
|
||||||
|
with db._get_connection() as c:
|
||||||
|
row = c.execute("SELECT disc_number, track_number FROM tracks WHERE id = ?", (track_id,)).fetchone()
|
||||||
|
return (row['disc_number'], row['track_number'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_jellyfin_navidrome_disc_number_stored(tmp_path):
|
||||||
|
db = _db(tmp_path)
|
||||||
|
# .discNumber is what the Jellyfin (ParentIndexNumber) + Navidrome (discNumber) wrappers set.
|
||||||
|
db.insert_or_update_media_track(_track('t-d2', 'Waltz for Stark and Fern', 34, discNumber=2), 'alb1', 'art1', 'jellyfin')
|
||||||
|
db.insert_or_update_media_track(_track('t-d1', 'The Magic Within', 32, discNumber=1), 'alb1', 'art1', 'jellyfin')
|
||||||
|
assert _disc_of(db, 't-d2') == (2, 34)
|
||||||
|
assert _disc_of(db, 't-d1') == (1, 32)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plex_parent_index_used_as_disc(tmp_path):
|
||||||
|
db = _db(tmp_path)
|
||||||
|
# plexapi Track has no .discNumber — disc comes from .parentIndex.
|
||||||
|
db.insert_or_update_media_track(_track('t-plex', 'Disc 2 Track', 5, parentIndex=2), 'alb1', 'art1', 'plex')
|
||||||
|
assert _disc_of(db, 't-plex') == (2, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_or_bad_disc_floors_to_one(tmp_path):
|
||||||
|
db = _db(tmp_path)
|
||||||
|
db.insert_or_update_media_track(_track('t-none', 'No Disc', 1), 'alb1', 'art1', 'jellyfin') # no disc attr
|
||||||
|
db.insert_or_update_media_track(_track('t-zero', 'Zero Disc', 2, discNumber=0), 'alb1', 'art1', 'jellyfin')
|
||||||
|
db.insert_or_update_media_track(_track('t-str', 'Junk Disc', 3, discNumber='x'), 'alb1', 'art1', 'jellyfin')
|
||||||
|
assert _disc_of(db, 't-none')[0] == 1
|
||||||
|
assert _disc_of(db, 't-zero')[0] == 1
|
||||||
|
assert _disc_of(db, 't-str')[0] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_path_backfills_disc_on_rescan(tmp_path):
|
||||||
|
db = _db(tmp_path)
|
||||||
|
# First scan (old behavior simulated): no disc -> 1. Re-scan with the real disc -> updated.
|
||||||
|
db.insert_or_update_media_track(_track('t-x', 'Track', 7), 'alb1', 'art1', 'jellyfin')
|
||||||
|
assert _disc_of(db, 't-x') == (1, 7)
|
||||||
|
db.insert_or_update_media_track(_track('t-x', 'Track', 7, discNumber=3), 'alb1', 'art1', 'jellyfin')
|
||||||
|
assert _disc_of(db, 't-x') == (3, 7)
|
||||||
Loading…
Reference in a new issue