release / web (push) Successful in 1m37s
release / govulncheck (push) Successful in 53s
release / go (push) Successful in 2m7s
release / integration (push) Successful in 5m2s
release / android (push) Successful in 6m34s
release / Build signed APK (releases and dev) (push) Successful in 6m7s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m36s
release / Verify release artifacts (tag releases only) (push) Skipped
Summed tag weight rewards a track for carrying many of the user's tags, so on the deploy two artists whose every track carries the whole lo-fi profile took all 120 rows of the taste-unheard query. capByAlbumAndArtist ran after the LIMIT and left 6, and the arm with the lowest skip rate (12% against ~23%) handed its slots to dormant and random. The query now ranks within album, then within artist over what the album cap kept, before the LIMIT: the same walk the Go cap makes, so the bucket fills from as many artists as match. The caps come from the Go constants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
6.6 KiB
SQL
168 lines
6.6 KiB
SQL
-- Discover playlist bucket queries. Each returns up to its LIMIT
|
|
-- count of (track_id, album_id, artist_id) triples ordered
|
|
-- daily-deterministically via md5(track_id || dateStr). The Go-side
|
|
-- bucket allocator (internal/playlists/discover.go) applies
|
|
-- per-album (<=2) and per-artist (<=3) caps then redistributes any
|
|
-- bucket deficit equally across the others. LIMIT values are
|
|
-- generous so the caps + redistribution have headroom.
|
|
|
|
-- name: ListDormantArtistTracksForDiscover :many
|
|
-- Tracks whose artist this user has played fewer than 10 times total.
|
|
-- The artist threshold defines "dormant" — we want to surface the
|
|
-- long tail of artists in the user's library that they rarely listen
|
|
-- to. Excludes tracks the user has played, liked, or has quarantined.
|
|
-- $1 = user_id, $2 = date string for md5 ordering.
|
|
WITH user_artist_counts AS (
|
|
SELECT t.artist_id, COUNT(*) AS play_count
|
|
FROM play_events pe
|
|
JOIN tracks t ON t.id = pe.track_id
|
|
WHERE pe.user_id = $1
|
|
AND pe.was_skipped = false
|
|
GROUP BY t.artist_id
|
|
),
|
|
dormant_artists AS (
|
|
SELECT a.id
|
|
FROM artists a
|
|
LEFT JOIN user_artist_counts uac ON uac.artist_id = a.id
|
|
WHERE COALESCE(uac.play_count, 0) < 10
|
|
)
|
|
SELECT t.id, t.album_id, t.artist_id
|
|
FROM tracks t
|
|
JOIN dormant_artists da ON da.id = t.artist_id
|
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM play_events pe
|
|
WHERE pe.user_id = $1
|
|
AND pe.track_id = t.id
|
|
AND pe.was_skipped = false
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM general_likes gl
|
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
)
|
|
ORDER BY md5(t.id::text || $2::text)
|
|
LIMIT 80;
|
|
|
|
-- name: ListCrossUserLikedTracksForDiscover :many
|
|
-- Tracks any OTHER user has liked, that this user hasn't played,
|
|
-- liked, or had quarantined. On single-user servers this returns 0
|
|
-- rows (the caller's slot redistribution rolls the deficit into the
|
|
-- other two buckets).
|
|
-- $1 = user_id, $2 = date string for md5 ordering.
|
|
--
|
|
-- GROUP BY (not SELECT DISTINCT) so the md5 ORDER BY expression need
|
|
-- not appear in the select list — Postgres rejects DISTINCT + ORDER BY
|
|
-- by-expression at plan time (SQLSTATE 42P10), which previously caused
|
|
-- the entire Discover build to fail silently.
|
|
SELECT t.id, t.album_id, t.artist_id
|
|
FROM general_likes gl
|
|
JOIN tracks t ON t.id = gl.track_id
|
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
|
AND gl.user_id != $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM play_events pe
|
|
WHERE pe.user_id = $1
|
|
AND pe.track_id = t.id
|
|
AND pe.was_skipped = false
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM general_likes ml
|
|
WHERE ml.user_id = $1 AND ml.track_id = t.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
)
|
|
GROUP BY t.id, t.album_id, t.artist_id
|
|
ORDER BY md5(t.id::text || $2::text)
|
|
LIMIT 60;
|
|
|
|
-- name: ListRandomUnheardTracksForDiscover :many
|
|
-- Random sample from the entire library. Same exclusion filters as
|
|
-- the other two buckets. The generous LIMIT (200) gives the caller
|
|
-- headroom for the per-album/artist caps and the slot redistribution.
|
|
-- $1 = user_id, $2 = date string for md5 ordering.
|
|
SELECT t.id, t.album_id, t.artist_id
|
|
FROM tracks t
|
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM play_events pe
|
|
WHERE pe.user_id = $1
|
|
AND pe.track_id = t.id
|
|
AND pe.was_skipped = false
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM general_likes gl
|
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
)
|
|
ORDER BY md5(t.id::text || $2::text)
|
|
LIMIT 200;
|
|
|
|
-- name: ListTasteUnheardTracksForDiscover :many
|
|
-- Taste-targeted novelty: unheard tracks whose genres overlap the user's
|
|
-- taste-profile tags (taste_profile_tags, #796), ranked by summed tag
|
|
-- weight — "new to you, but your vibe" rather than the crude random arm.
|
|
-- Genres live inline on tracks.genre as a delimited string, split the
|
|
-- same way the radio tag_overlap arm does (regexp_split_to_table on
|
|
-- [;,]). Same exclusion filters as the other buckets. Returns nothing
|
|
-- when the user has no taste tags yet (cold start), so the caller
|
|
-- redistributes its slots to the other buckets. Stamped 'taste_unheard'.
|
|
--
|
|
-- The per-album and per-artist caps apply BEFORE the LIMIT (#5356). Summed
|
|
-- weight rewards a track for carrying many of the user's tags, so a few
|
|
-- artists whose every track is tagged with the whole profile take every row
|
|
-- of a plain LIMIT; the caller's caps then left 6 of 120 on the deploy, and
|
|
-- the best-performing arm handed its slots to the others. Ranking within
|
|
-- album, then within artist over what the album cap kept, is the same walk
|
|
-- capByAlbumAndArtist makes, so the caller's caps keep everything here.
|
|
WITH scored AS (
|
|
SELECT t.id, t.album_id, t.artist_id,
|
|
SUM(nt.weight) AS weight,
|
|
md5(t.id::text || sqlc.arg(date_seed)::text) AS tiebreak
|
|
FROM tracks t
|
|
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
|
JOIN taste_profile_tags nt ON nt.user_id = sqlc.arg(user_id) AND trim(g_split.g) = nt.tag
|
|
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
|
AND nt.weight > 0
|
|
AND trim(g_split.g) <> ''
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM play_events pe
|
|
WHERE pe.user_id = sqlc.arg(user_id)
|
|
AND pe.track_id = t.id
|
|
AND pe.was_skipped = false
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM general_likes gl
|
|
WHERE gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
|
)
|
|
GROUP BY t.id, t.album_id, t.artist_id
|
|
),
|
|
album_capped AS (
|
|
SELECT s.*,
|
|
row_number() OVER (PARTITION BY s.album_id ORDER BY s.weight DESC, s.tiebreak) AS album_rank
|
|
FROM scored s
|
|
),
|
|
artist_capped AS (
|
|
SELECT a.*,
|
|
row_number() OVER (PARTITION BY a.artist_id ORDER BY a.weight DESC, a.tiebreak) AS artist_rank
|
|
FROM album_capped a
|
|
WHERE a.album_rank <= sqlc.arg(max_per_album)::int
|
|
)
|
|
SELECT c.id, c.album_id, c.artist_id
|
|
FROM artist_capped c
|
|
WHERE c.artist_rank <= sqlc.arg(max_per_artist)::int
|
|
ORDER BY c.weight DESC, c.tiebreak
|
|
LIMIT 120;
|