dhowden/tag's readTFrame splits ID3v2 null-separated multi-value text frames and rejoins them with the EMPTY string, so a file tagged "Alternative Rock" + "Rock" was stored as "Alternative RockRock". It also leaves bare numeric ID3v1 references unresolved, which is why the library showed genres like "4017" and "526617". This corrupted more than the browse axis added in #367: taste_profile.sql reads tracks.genre directly, so the welded tokens were entering the taste profile's tag vocabulary, and recommendation.sql/discover.sql were comparing them as single opaque tags. Genre counts were wrong everywhere. ffprobe is not a fix — ffmpeg's read_ttag calls decode_str once with no loop, keeping only the first value. Truncating multi-genre tags would blunt the similarity signal genre mainly feeds. So the TCON frame is now parsed directly (ID3v2.2/2.3/2.4, all four text encodings, per-frame and tag-level unsynchronisation, numeric and parenthesised ID3v1 references); everything else still comes from dhowden/tag. Values are stored ";"-delimited, which the read side already splits on, so no query changes. Existing rows are repaired without an operator-run rebuild: migration 0054 adds tracks.tag_read_version DEFAULT 0, below the scanner's current tagReadVersion, so the next scan re-reads tags it would otherwise skip on mtime. Such a re-read reuses the stored duration instead of re-running ffprobe, keeping a repair pass tag-read-bound rather than one fork+exec per file. Bumping the constant is how a future extraction fix reaches an existing library. Only ID3v2 is in scope — dhowden welds nowhere else. The Vorbis/MP4 repeated-field question is #2500, unproven and deliberately not built.
140 lines
4.9 KiB
SQL
140 lines
4.9 KiB
SQL
-- name: UpsertTrack :one
|
|
-- file_path is the canonical identity for library scan; mbid is secondary.
|
|
INSERT INTO tracks (
|
|
title, album_id, artist_id, track_number, disc_number,
|
|
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre,
|
|
tag_read_version
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
ON CONFLICT (file_path) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
album_id = EXCLUDED.album_id,
|
|
artist_id = EXCLUDED.artist_id,
|
|
track_number = EXCLUDED.track_number,
|
|
disc_number = EXCLUDED.disc_number,
|
|
duration_ms = EXCLUDED.duration_ms,
|
|
file_size = EXCLUDED.file_size,
|
|
file_format = EXCLUDED.file_format,
|
|
bitrate = EXCLUDED.bitrate,
|
|
mbid = EXCLUDED.mbid,
|
|
genre = EXCLUDED.genre,
|
|
-- Stamped on update too, so a tag-repair pass marks rows as done and the
|
|
-- next scan can short-circuit them again (#2499).
|
|
tag_read_version = EXCLUDED.tag_read_version,
|
|
updated_at = now()
|
|
RETURNING *;
|
|
|
|
-- name: ListTracksMissingMbidWithPath :many
|
|
-- Track recording-MBID backfill: tracks with NULL mbid that still have
|
|
-- a file to re-read. $1 caps the batch (mirrors the album backfill).
|
|
SELECT id, file_path
|
|
FROM tracks
|
|
WHERE mbid IS NULL
|
|
ORDER BY id
|
|
LIMIT $1;
|
|
|
|
-- name: SetTrackMbidIfNull :exec
|
|
-- Heal a track's recording MBID only while still NULL — idempotent, so
|
|
-- re-running the backfill is a no-op for already-healed rows.
|
|
UPDATE tracks
|
|
SET mbid = $2, updated_at = now()
|
|
WHERE id = $1 AND mbid IS NULL;
|
|
|
|
-- name: GetTrackByID :one
|
|
SELECT * FROM tracks WHERE id = $1;
|
|
|
|
-- name: GetTrackByPath :one
|
|
SELECT * FROM tracks WHERE file_path = $1;
|
|
|
|
-- name: ListTracksByAlbum :many
|
|
-- $1 = album_id, $2 = user_id. Pass pgtype.UUID{Valid: false} (NULL)
|
|
-- to skip the per-user quarantine filter; the NOT EXISTS clause on
|
|
-- a NULL user_id never matches a row, so every track passes through.
|
|
SELECT * FROM tracks
|
|
WHERE album_id = $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
|
)
|
|
ORDER BY disc_number NULLS LAST, track_number NULLS LAST;
|
|
|
|
-- name: CountTracksByAlbum :one
|
|
SELECT count(*) FROM tracks WHERE album_id = $1;
|
|
|
|
-- name: SearchTracks :many
|
|
-- $1 = title query, $2 = user_id (NULL to skip quarantine filter),
|
|
-- $3 = limit, $4 = offset.
|
|
SELECT * FROM tracks
|
|
WHERE title ILIKE '%' || $1::text || '%'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
|
)
|
|
ORDER BY title
|
|
LIMIT $3 OFFSET $4;
|
|
|
|
-- name: CountTracksMatching :one
|
|
-- $1 = title query, $2 = user_id (NULL to skip quarantine filter).
|
|
SELECT COUNT(*) FROM tracks
|
|
WHERE title ILIKE '%' || $1::text || '%'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
|
);
|
|
|
|
-- name: ListArtistTracksForUser :many
|
|
-- M6a: every track for the artist across their albums, with album_title
|
|
-- and artist_name joined. Honors per-user lidarr_quarantine. Used by
|
|
-- /api/artists/{id}/tracks for the artist-card play affordance, which
|
|
-- shuffles client-side. Ordering matches album/track natural order so
|
|
-- the shuffle has a deterministic input.
|
|
SELECT sqlc.embed(t),
|
|
albums.title AS album_title,
|
|
artists.name AS artist_name
|
|
FROM tracks t
|
|
JOIN albums ON albums.id = t.album_id
|
|
JOIN artists ON artists.id = t.artist_id
|
|
WHERE t.artist_id = $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $2 AND q.track_id = t.id
|
|
)
|
|
ORDER BY albums.release_date NULLS LAST, albums.sort_title,
|
|
t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id;
|
|
|
|
-- name: ListRandomTracksForUser :many
|
|
-- #427 S4: backing query for GET /api/library/shuffle — the online
|
|
-- source for "Shuffle all". N random tracks across the whole
|
|
-- library, per-user-quarantine filtered. $1 user_id, $2 limit.
|
|
SELECT sqlc.embed(t),
|
|
albums.title AS album_title,
|
|
artists.name AS artist_name
|
|
FROM tracks t
|
|
JOIN albums ON albums.id = t.album_id
|
|
JOIN artists ON artists.id = t.artist_id
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
)
|
|
ORDER BY random()
|
|
LIMIT $2;
|
|
|
|
-- name: CountTracksByArtist :one
|
|
-- Used by request-progress reporting to count tracks ingested under a
|
|
-- matched artist (sum across all the artist's albums) while a request
|
|
-- is still in flight.
|
|
SELECT COUNT(*) FROM tracks WHERE artist_id = $1;
|
|
|
|
-- name: DeleteTrack :one
|
|
-- M7 #372: hard delete with FK cascade. The CASCADE on track_id from
|
|
-- play_events / general_likes_tracks / lidarr_quarantine /
|
|
-- lidarr_quarantine_actions handles their cleanup. RETURNING gives us
|
|
-- album_id + artist_id for the album-empty / artist-empty cascade
|
|
-- checks the service does next.
|
|
DELETE FROM tracks WHERE id = $1
|
|
RETURNING id, album_id, artist_id, file_path, mbid;
|
|
|
|
-- name: GetTracksByIDs :many
|
|
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
|
|
-- (#357). Mirror of GetArtistsByIDs.
|
|
SELECT * FROM tracks WHERE id = ANY($1::uuid[]);
|