feat: video rips and stray copies handle themselves (M498 #5439)
release / govulncheck (push) Successful in 25s
release / web (push) Failing after 26s
release / go (push) Failing after 58s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m25s
release / Build signed APK (releases and dev) (push) Canceled after 3m26s
release / android (push) Canceled after 3m28s
release / govulncheck (push) Successful in 25s
release / web (push) Failing after 26s
release / go (push) Failing after 58s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m25s
release / Build signed APK (releases and dev) (push) Canceled after 3m26s
release / android (push) Canceled after 3m28s
- The resolver also merges cross-release and mismatch groups where Lidarr
maps exactly one copy: the others fulfil nothing, so removing them opens no
hole (D-a rule 1). That covers a rip beside the clean copy on another
release and a wrong-file import Lidarr holds unmapped. With two or more
mapped copies each fulfils its own release and nothing is removed.
- A track whose file name carries a video-rip marker is held back from radio
and the system mixes (tracks.source_verdict, migration 0077). It still plays
when chosen. A renamed file is released; the operator's "fine" sticks.
- Suspect sources shows what was done to each track, with "This one is fine"
and "Hold back again" (PUT /api/admin/library/suspect-sources/{id}).
- The Liked list prefers a copy that is not held back.
Replacing a rip that has no clean copy is left for the operator to decide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
@@ -22,6 +26,11 @@ type suspectTrackView struct {
|
|||||||
DiscNumber *int32 `json:"disc_number"`
|
DiscNumber *int32 `json:"disc_number"`
|
||||||
TrackNumber *int32 `json:"track_number"`
|
TrackNumber *int32 `json:"track_number"`
|
||||||
Markers []string `json:"markers"`
|
Markers []string `json:"markers"`
|
||||||
|
// Verdict is "suspect" while the track is held back from radio and the
|
||||||
|
// mixes, "fine" once the operator let it back, null before the resolver's
|
||||||
|
// first pass (M498 #5439). VerdictAt is when it was last set.
|
||||||
|
Verdict *string `json:"verdict"`
|
||||||
|
VerdictAt *string `json:"verdict_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// suspectGroupView is a folder's worth of flagged tracks. As on the
|
// suspectGroupView is a folder's worth of flagged tracks. As on the
|
||||||
@@ -42,9 +51,10 @@ type adminSuspectSourcesResponse struct {
|
|||||||
|
|
||||||
// handleListSuspectSources implements GET /api/admin/library/suspect-sources.
|
// handleListSuspectSources implements GET /api/admin/library/suspect-sources.
|
||||||
//
|
//
|
||||||
// Read-only. A marker is a reason to look, not proof: a band can title a song
|
// A marker is a reason to look, not proof: a band can title a song "Music
|
||||||
// "Music Video". What to do about a flagged file — merge it in Duplicates,
|
// Video". The duplicate resolver holds a flagged track back from radio and the
|
||||||
// quarantine it, replace it in Lidarr — stays the operator's call.
|
// mixes and merges it away where a clean copy exists (M498 #5439); this report
|
||||||
|
// is where the operator sees that and can say a track is fine.
|
||||||
func (h *handlers) handleListSuspectSources(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleListSuspectSources(w http.ResponseWriter, r *http.Request) {
|
||||||
limit, offset, err := parsePaging(r.URL.Query())
|
limit, offset, err := parsePaging(r.URL.Query())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,6 +106,11 @@ func groupSuspectByDirectory(rows []dbq.ListSuspectSourceTracksRow) []suspectGro
|
|||||||
DiscNumber: row.DiscNumber,
|
DiscNumber: row.DiscNumber,
|
||||||
TrackNumber: row.TrackNumber,
|
TrackNumber: row.TrackNumber,
|
||||||
Markers: library.SourceMarkersFor(row.FilePath),
|
Markers: library.SourceMarkersFor(row.FilePath),
|
||||||
|
Verdict: row.SourceVerdict,
|
||||||
|
}
|
||||||
|
if row.SourceVerdictAt.Valid {
|
||||||
|
at := row.SourceVerdictAt.Time.UTC().Format(time.RFC3339)
|
||||||
|
t.VerdictAt = &at
|
||||||
}
|
}
|
||||||
if n := len(groups); n > 0 && groups[n-1].Directory == row.Directory {
|
if n := len(groups); n > 0 && groups[n-1].Directory == row.Directory {
|
||||||
groups[n-1].Tracks = append(groups[n-1].Tracks, t)
|
groups[n-1].Tracks = append(groups[n-1].Tracks, t)
|
||||||
@@ -108,3 +123,39 @@ func groupSuspectByDirectory(rows []dbq.ListSuspectSourceTracksRow) []suspectGro
|
|||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type suspectVerdictRequest struct {
|
||||||
|
Verdict string `json:"verdict"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetSuspectSourceVerdict implements PUT
|
||||||
|
// /api/admin/library/suspect-sources/{id}: {"verdict": "fine"} lets a flagged
|
||||||
|
// track back into radio and the mixes for good, {"verdict": "suspect"} holds
|
||||||
|
// it back again. 404 suspect_track_not_found when the track was never flagged.
|
||||||
|
func (h *handlers) handleSetSuspectSourceVerdict(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req suspectVerdictRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Verdict != "fine" && req.Verdict != "suspect" {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_verdict")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := dbq.New(h.pool).SetTrackSourceVerdict(r.Context(), dbq.SetTrackSourceVerdictParams{Verdict: req.Verdict, ID: id})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: set suspect-source verdict", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
writeAdminJSONErr(w, http.StatusNotFound, "suspect_track_not_found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"verdict": req.Verdict})
|
||||||
|
}
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
// not be mistaken for it (#2527).
|
// not be mistaken for it (#2527).
|
||||||
admin.Get("/library/missing", h.handleListMissingTracks)
|
admin.Get("/library/missing", h.handleListMissingTracks)
|
||||||
admin.Get("/library/suspect-sources", h.handleListSuspectSources)
|
admin.Get("/library/suspect-sources", h.handleListSuspectSources)
|
||||||
|
admin.Put("/library/suspect-sources/{id}", h.handleSetSuspectSourceVerdict)
|
||||||
|
|
||||||
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
||||||
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
|
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key FROM tracks t
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at FROM tracks t
|
||||||
JOIN play_events pe ON pe.track_id = t.id
|
JOIN play_events pe ON pe.track_id = t.id
|
||||||
WHERE pe.session_id = $1
|
WHERE pe.session_id = $1
|
||||||
AND pe.started_at < $2
|
AND pe.started_at < $2
|
||||||
@@ -310,6 +310,8 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
|
|||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
&i.SongID,
|
&i.SongID,
|
||||||
&i.SongKey,
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
const listUserHistory = `-- name: ListUserHistory :many
|
const listUserHistory = `-- name: ListUserHistory :many
|
||||||
SELECT pe.id AS event_id,
|
SELECT pe.id AS event_id,
|
||||||
pe.started_at,
|
pe.started_at,
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
@@ -84,6 +84,8 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
|
|||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
&i.Track.SongID,
|
&i.Track.SongID,
|
||||||
&i.Track.SongKey,
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -281,12 +281,13 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key FROM tracks t
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at FROM tracks t
|
||||||
JOIN (SELECT DISTINCT ON (lt.song_key) l.track_id, l.liked_at
|
JOIN (SELECT DISTINCT ON (lt.song_key) l.track_id, l.liked_at
|
||||||
FROM general_likes l
|
FROM general_likes l
|
||||||
JOIN tracks lt ON lt.id = l.track_id
|
JOIN tracks lt ON lt.id = l.track_id
|
||||||
WHERE l.user_id = $1
|
WHERE l.user_id = $1
|
||||||
ORDER BY lt.song_key, (lt.missing_since IS NOT NULL), l.liked_at, lt.id) l
|
ORDER BY lt.song_key, (lt.missing_since IS NOT NULL),
|
||||||
|
(lt.source_verdict IS NOT DISTINCT FROM 'suspect'), l.liked_at, lt.id) l
|
||||||
ON l.track_id = t.id
|
ON l.track_id = t.id
|
||||||
ORDER BY l.liked_at DESC, t.id
|
ORDER BY l.liked_at DESC, t.id
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
@@ -298,8 +299,8 @@ type ListLikedTrackRowsParams struct {
|
|||||||
Offset int32
|
Offset int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// One row per liked song: of its copies, the one present on disk, then the
|
// One row per liked song: of its copies, the one present on disk, then one
|
||||||
// one liked first.
|
// that is not a held-back video rip, then the one liked first.
|
||||||
func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRowsParams) ([]Track, error) {
|
func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRowsParams) ([]Track, error) {
|
||||||
rows, err := q.db.Query(ctx, listLikedTrackRows, arg.UserID, arg.Limit, arg.Offset)
|
rows, err := q.db.Query(ctx, listLikedTrackRows, arg.UserID, arg.Limit, arg.Offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -332,6 +333,8 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
|
|||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
&i.SongID,
|
&i.SongID,
|
||||||
&i.SongKey,
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -756,6 +756,8 @@ type Track struct {
|
|||||||
MbidSource *string
|
MbidSource *string
|
||||||
SongID pgtype.UUID
|
SongID pgtype.UUID
|
||||||
SongKey pgtype.UUID
|
SongKey pgtype.UUID
|
||||||
|
SourceVerdict *string
|
||||||
|
SourceVerdictAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackAcoustidLookup struct {
|
type TrackAcoustidLookup struct {
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ WITH plays AS (
|
|||||||
WHERE user_id = $2 AND was_skipped = false
|
WHERE user_id = $2 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM plays p
|
FROM plays p
|
||||||
@@ -273,6 +273,8 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
|
|||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
&i.Track.SongID,
|
&i.Track.SongID,
|
||||||
&i.Track.SongKey,
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -293,7 +295,7 @@ WITH plays AS (
|
|||||||
WHERE user_id = $1 AND was_skipped = false
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM plays p
|
FROM plays p
|
||||||
@@ -360,6 +362,8 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
|
|||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
&i.Track.SongID,
|
&i.Track.SongID,
|
||||||
&i.Track.SongKey,
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -699,7 +703,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
|
|||||||
|
|
||||||
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
|
||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
@@ -783,6 +787,8 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
|
|||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
&i.Track.SongID,
|
&i.Track.SongID,
|
||||||
&i.Track.SongKey,
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
@@ -931,7 +937,7 @@ random_fill AS (
|
|||||||
LIMIT $9
|
LIMIT $9
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
|
||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
@@ -1061,6 +1067,8 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
&i.Track.SongID,
|
&i.Track.SongID,
|
||||||
&i.Track.SongKey,
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
|
|||||||
@@ -46,14 +46,18 @@ func (q *Queries) LinkTracksAsSong(ctx context.Context, trackIds []pgtype.UUID)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listTrackSongKeys = `-- name: ListTrackSongKeys :many
|
const listTrackSongKeys = `-- name: ListTrackSongKeys :many
|
||||||
SELECT id, song_key FROM tracks WHERE id = ANY($1::uuid[])
|
SELECT id, song_key, (source_verdict IS NOT DISTINCT FROM 'suspect')::boolean AS held_back
|
||||||
|
FROM tracks WHERE id = ANY($1::uuid[])
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListTrackSongKeysRow struct {
|
type ListTrackSongKeysRow struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
SongKey pgtype.UUID
|
SongKey pgtype.UUID
|
||||||
|
HeldBack bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What the mix writer needs to place a track: its song, and whether it is a
|
||||||
|
// video rip held back from the mixes (#5439).
|
||||||
func (q *Queries) ListTrackSongKeys(ctx context.Context, ids []pgtype.UUID) ([]ListTrackSongKeysRow, error) {
|
func (q *Queries) ListTrackSongKeys(ctx context.Context, ids []pgtype.UUID) ([]ListTrackSongKeysRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listTrackSongKeys, ids)
|
rows, err := q.db.Query(ctx, listTrackSongKeys, ids)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,7 +67,7 @@ func (q *Queries) ListTrackSongKeys(ctx context.Context, ids []pgtype.UUID) ([]L
|
|||||||
var items []ListTrackSongKeysRow
|
var items []ListTrackSongKeysRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListTrackSongKeysRow
|
var i ListTrackSongKeysRow
|
||||||
if err := rows.Scan(&i.ID, &i.SongKey); err != nil {
|
if err := rows.Scan(&i.ID, &i.SongKey, &i.HeldBack); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
|
|||||||
+100
-20
@@ -39,6 +39,23 @@ func (q *Queries) AdoptTrackPath(ctx context.Context, arg AdoptTrackPathParams)
|
|||||||
return result.RowsAffected(), nil
|
return result.RowsAffected(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearStaleSuspectSourceTracks = `-- name: ClearStaleSuspectSourceTracks :execrows
|
||||||
|
UPDATE tracks
|
||||||
|
SET source_verdict = NULL, source_verdict_at = NULL
|
||||||
|
WHERE source_verdict = 'suspect'
|
||||||
|
AND NOT regexp_replace(file_path, '^.*/', '') ~* $1::text
|
||||||
|
`
|
||||||
|
|
||||||
|
// A held-back track whose name no longer carries a marker (renamed, retagged
|
||||||
|
// by Lidarr) is released again.
|
||||||
|
func (q *Queries) ClearStaleSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, clearStaleSuspectSourceTracks, pattern)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
const clearTracksMissing = `-- name: ClearTracksMissing :execrows
|
const clearTracksMissing = `-- name: ClearTracksMissing :execrows
|
||||||
UPDATE tracks
|
UPDATE tracks
|
||||||
SET missing_since = NULL
|
SET missing_since = NULL
|
||||||
@@ -246,8 +263,27 @@ func (q *Queries) FindMissingTrackByMbid(ctx context.Context, mbid string) ([]Fi
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const flagSuspectSourceTracks = `-- name: FlagSuspectSourceTracks :execrows
|
||||||
|
UPDATE tracks
|
||||||
|
SET source_verdict = 'suspect', source_verdict_at = now()
|
||||||
|
WHERE missing_since IS NULL
|
||||||
|
AND source_verdict IS NULL
|
||||||
|
AND regexp_replace(file_path, '^.*/', '') ~* $1::text
|
||||||
|
`
|
||||||
|
|
||||||
|
// Holds back present tracks whose basename carries a video-rip marker (M498
|
||||||
|
// #5439), the same pattern the report lists. A track the operator called fine
|
||||||
|
// keeps that verdict.
|
||||||
|
func (q *Queries) FlagSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, flagSuspectSourceTracks, pattern)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
const getTrackByID = `-- name: GetTrackByID :one
|
const getTrackByID = `-- name: GetTrackByID :one
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key FROM tracks WHERE id = $1
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
|
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
|
||||||
@@ -276,12 +312,14 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
|
|||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
&i.SongID,
|
&i.SongID,
|
||||||
&i.SongKey,
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTrackByPath = `-- name: GetTrackByPath :one
|
const getTrackByPath = `-- name: GetTrackByPath :one
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key FROM tracks WHERE file_path = $1
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks WHERE file_path = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
|
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
|
||||||
@@ -310,12 +348,14 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
|
|||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
&i.SongID,
|
&i.SongID,
|
||||||
&i.SongKey,
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTracksByIDs = `-- name: GetTracksByIDs :many
|
const getTracksByIDs = `-- name: GetTracksByIDs :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key FROM tracks WHERE id = ANY($1::uuid[])
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks WHERE id = ANY($1::uuid[])
|
||||||
`
|
`
|
||||||
|
|
||||||
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
||||||
@@ -352,6 +392,8 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
|||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
&i.SongID,
|
&i.SongID,
|
||||||
&i.SongKey,
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -364,7 +406,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
|
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -427,6 +469,8 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
|||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
&i.Track.SongID,
|
&i.Track.SongID,
|
||||||
&i.Track.SongKey,
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -527,7 +571,7 @@ func (q *Queries) ListMissingTracks(ctx context.Context, arg ListMissingTracksPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
|
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source, t.song_id, t.song_key, t.source_verdict, t.source_verdict_at,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -587,6 +631,8 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
|||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
&i.Track.SongID,
|
&i.Track.SongID,
|
||||||
&i.Track.SongKey,
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -611,7 +657,9 @@ SELECT t.id,
|
|||||||
albums.id AS album_id,
|
albums.id AS album_id,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.id AS artist_id,
|
artists.id AS artist_id,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name,
|
||||||
|
t.source_verdict,
|
||||||
|
t.source_verdict_at
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums ON albums.id = t.album_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
@@ -628,17 +676,19 @@ type ListSuspectSourceTracksParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListSuspectSourceTracksRow struct {
|
type ListSuspectSourceTracksRow struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
Title string
|
Title string
|
||||||
FilePath string
|
FilePath string
|
||||||
Directory string
|
Directory string
|
||||||
DurationMs int32
|
DurationMs int32
|
||||||
DiscNumber *int32
|
DiscNumber *int32
|
||||||
TrackNumber *int32
|
TrackNumber *int32
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
AlbumTitle string
|
AlbumTitle string
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
ArtistName string
|
ArtistName string
|
||||||
|
SourceVerdict *string
|
||||||
|
SourceVerdictAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin report of present tracks whose filename looks like a video rip
|
// The admin report of present tracks whose filename looks like a video rip
|
||||||
@@ -672,6 +722,8 @@ func (q *Queries) ListSuspectSourceTracks(ctx context.Context, arg ListSuspectSo
|
|||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistID,
|
&i.ArtistID,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -719,7 +771,7 @@ func (q *Queries) ListTrackPathsForReconcile(ctx context.Context) ([]ListTrackPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key FROM tracks
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks
|
||||||
WHERE album_id = $1
|
WHERE album_id = $1
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -768,6 +820,8 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
|
|||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
&i.SongID,
|
&i.SongID,
|
||||||
&i.SongKey,
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -841,7 +895,7 @@ func (q *Queries) MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchTracks = `-- name: SearchTracks :many
|
const searchTracks = `-- name: SearchTracks :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key FROM tracks
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at FROM tracks
|
||||||
WHERE title ILIKE '%' || $1::text || '%'
|
WHERE title ILIKE '%' || $1::text || '%'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -897,6 +951,8 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
|
|||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
&i.SongID,
|
&i.SongID,
|
||||||
&i.SongKey,
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -926,6 +982,28 @@ func (q *Queries) SetTrackMbidIfNull(ctx context.Context, arg SetTrackMbidIfNull
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setTrackSourceVerdict = `-- name: SetTrackSourceVerdict :execrows
|
||||||
|
UPDATE tracks
|
||||||
|
SET source_verdict = $1::text, source_verdict_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
AND source_verdict IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetTrackSourceVerdictParams struct {
|
||||||
|
Verdict string
|
||||||
|
ID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// The operator's call on a flagged track: 'fine' lets it back into radio and
|
||||||
|
// the mixes for good; 'suspect' holds it back again.
|
||||||
|
func (q *Queries) SetTrackSourceVerdict(ctx context.Context, arg SetTrackSourceVerdictParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, setTrackSourceVerdict, arg.Verdict, arg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
const upsertTrack = `-- name: UpsertTrack :one
|
const upsertTrack = `-- name: UpsertTrack :one
|
||||||
INSERT INTO tracks (
|
INSERT INTO tracks (
|
||||||
title, album_id, artist_id, track_number, disc_number,
|
title, album_id, artist_id, track_number, disc_number,
|
||||||
@@ -953,7 +1031,7 @@ ON CONFLICT (file_path) DO UPDATE SET
|
|||||||
-- next scan can short-circuit them again (#2499).
|
-- next scan can short-circuit them again (#2499).
|
||||||
tag_read_version = EXCLUDED.tag_read_version,
|
tag_read_version = EXCLUDED.tag_read_version,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key
|
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpsertTrackParams struct {
|
type UpsertTrackParams struct {
|
||||||
@@ -1015,6 +1093,8 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
|
|||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
&i.SongID,
|
&i.SongID,
|
||||||
&i.SongKey,
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS tracks_source_suspect_idx;
|
||||||
|
ALTER TABLE tracks DROP COLUMN IF EXISTS source_verdict_at;
|
||||||
|
ALTER TABLE tracks DROP COLUMN IF EXISTS source_verdict;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 0077_track_source_verdict.up.sql — video rips are held back from mixes and
|
||||||
|
-- radio until the operator says otherwise (Scribe milestone #498, #5439;
|
||||||
|
-- operator decision D-d).
|
||||||
|
--
|
||||||
|
-- A file whose name carries a video-rip marker ("(Official Video)", "[HD]",
|
||||||
|
-- #5410) holds the audio of an upload, which can carry intros, skits or a
|
||||||
|
-- different take. The duplicate resolver sets 'suspect' on such a present
|
||||||
|
-- track each pass, and clears it when the name no longer matches. 'suspect'
|
||||||
|
-- keeps the track out of radio and the system mixes; it still plays when
|
||||||
|
-- chosen. 'fine' is the operator's "this one is fine" and is never
|
||||||
|
-- overwritten by the resolver.
|
||||||
|
ALTER TABLE tracks ADD COLUMN source_verdict text
|
||||||
|
CONSTRAINT tracks_source_verdict_check CHECK (source_verdict IN ('suspect', 'fine'));
|
||||||
|
ALTER TABLE tracks ADD COLUMN source_verdict_at timestamptz;
|
||||||
|
CREATE INDEX tracks_source_suspect_idx ON tracks (id) WHERE source_verdict = 'suspect';
|
||||||
@@ -20,14 +20,15 @@ DELETE FROM general_likes
|
|||||||
RETURNING track_id;
|
RETURNING track_id;
|
||||||
|
|
||||||
-- name: ListLikedTrackRows :many
|
-- name: ListLikedTrackRows :many
|
||||||
-- One row per liked song: of its copies, the one present on disk, then the
|
-- One row per liked song: of its copies, the one present on disk, then one
|
||||||
-- one liked first.
|
-- that is not a held-back video rip, then the one liked first.
|
||||||
SELECT t.* FROM tracks t
|
SELECT t.* FROM tracks t
|
||||||
JOIN (SELECT DISTINCT ON (lt.song_key) l.track_id, l.liked_at
|
JOIN (SELECT DISTINCT ON (lt.song_key) l.track_id, l.liked_at
|
||||||
FROM general_likes l
|
FROM general_likes l
|
||||||
JOIN tracks lt ON lt.id = l.track_id
|
JOIN tracks lt ON lt.id = l.track_id
|
||||||
WHERE l.user_id = $1
|
WHERE l.user_id = $1
|
||||||
ORDER BY lt.song_key, (lt.missing_since IS NOT NULL), l.liked_at, lt.id) l
|
ORDER BY lt.song_key, (lt.missing_since IS NOT NULL),
|
||||||
|
(lt.source_verdict IS NOT DISTINCT FROM 'suspect'), l.liked_at, lt.id) l
|
||||||
ON l.track_id = t.id
|
ON l.track_id = t.id
|
||||||
ORDER BY l.liked_at DESC, t.id
|
ORDER BY l.liked_at DESC, t.id
|
||||||
LIMIT $2 OFFSET $3;
|
LIMIT $2 OFFSET $3;
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ UPDATE tracks SET song_id = NULL
|
|||||||
AND id IN (SELECT track_id FROM duplicate_group_members WHERE group_id = sqlc.arg(group_id));
|
AND id IN (SELECT track_id FROM duplicate_group_members WHERE group_id = sqlc.arg(group_id));
|
||||||
|
|
||||||
-- name: ListTrackSongKeys :many
|
-- name: ListTrackSongKeys :many
|
||||||
SELECT id, song_key FROM tracks WHERE id = ANY(sqlc.arg(ids)::uuid[]);
|
-- What the mix writer needs to place a track: its song, and whether it is a
|
||||||
|
-- video rip held back from the mixes (#5439).
|
||||||
|
SELECT id, song_key, (source_verdict IS NOT DISTINCT FROM 'suspect')::boolean AS held_back
|
||||||
|
FROM tracks WHERE id = ANY(sqlc.arg(ids)::uuid[]);
|
||||||
|
|
||||||
-- name: MergeInheritSongLink :exec
|
-- name: MergeInheritSongLink :exec
|
||||||
-- A merge keeps the removed copy's song link: when the copy being removed was
|
-- A merge keeps the removed copy's song link: when the copy being removed was
|
||||||
|
|||||||
@@ -285,7 +285,9 @@ SELECT t.id,
|
|||||||
albums.id AS album_id,
|
albums.id AS album_id,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.id AS artist_id,
|
artists.id AS artist_id,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name,
|
||||||
|
t.source_verdict,
|
||||||
|
t.source_verdict_at
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums ON albums.id = t.album_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
@@ -299,3 +301,29 @@ SELECT t.id,
|
|||||||
SELECT COUNT(*) FROM tracks t
|
SELECT COUNT(*) FROM tracks t
|
||||||
WHERE t.missing_since IS NULL
|
WHERE t.missing_since IS NULL
|
||||||
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
|
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
|
||||||
|
|
||||||
|
-- name: FlagSuspectSourceTracks :execrows
|
||||||
|
-- Holds back present tracks whose basename carries a video-rip marker (M498
|
||||||
|
-- #5439), the same pattern the report lists. A track the operator called fine
|
||||||
|
-- keeps that verdict.
|
||||||
|
UPDATE tracks
|
||||||
|
SET source_verdict = 'suspect', source_verdict_at = now()
|
||||||
|
WHERE missing_since IS NULL
|
||||||
|
AND source_verdict IS NULL
|
||||||
|
AND regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
|
||||||
|
|
||||||
|
-- name: ClearStaleSuspectSourceTracks :execrows
|
||||||
|
-- A held-back track whose name no longer carries a marker (renamed, retagged
|
||||||
|
-- by Lidarr) is released again.
|
||||||
|
UPDATE tracks
|
||||||
|
SET source_verdict = NULL, source_verdict_at = NULL
|
||||||
|
WHERE source_verdict = 'suspect'
|
||||||
|
AND NOT regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
|
||||||
|
|
||||||
|
-- name: SetTrackSourceVerdict :execrows
|
||||||
|
-- The operator's call on a flagged track: 'fine' lets it back into radio and
|
||||||
|
-- the mixes for good; 'suspect' holds it back again.
|
||||||
|
UPDATE tracks
|
||||||
|
SET source_verdict = sqlc.arg(verdict)::text, source_verdict_at = now()
|
||||||
|
WHERE id = sqlc.arg(id)
|
||||||
|
AND source_verdict IS NOT NULL;
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ type DuplicateResolveResult struct {
|
|||||||
ReleaseChanges []ReleaseChange
|
ReleaseChanges []ReleaseChange
|
||||||
// SongsLinked counts cross-release groups newly linked as one song.
|
// SongsLinked counts cross-release groups newly linked as one song.
|
||||||
SongsLinked int
|
SongsLinked int
|
||||||
|
// HeldBack counts tracks newly held back from radio and the mixes as video
|
||||||
|
// rips; Released counts held-back tracks whose name no longer says so.
|
||||||
|
HeldBack, Released int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// LidarrPathKey is the part of a path Minstrel and Lidarr agree on: the last
|
// LidarrPathKey is the part of a path Minstrel and Lidarr agree on: the last
|
||||||
@@ -115,6 +118,35 @@ type resolveGroup struct {
|
|||||||
states []string // per member: tracked, unmapped, or "" when Lidarr was not asked
|
states []string // per member: tracked, unmapped, or "" when Lidarr was not asked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoMergeable says whether the resolver may merge the group on its own: when
|
||||||
|
// every copy it would remove is one Lidarr does not map (D-a rule 1). On one
|
||||||
|
// album that is any group with at most one mapped copy. Across releases each
|
||||||
|
// mapped copy fulfils its own release (D-b), so only a group with exactly one
|
||||||
|
// mapped copy qualifies: the others fulfil nothing — a rip beside the clean
|
||||||
|
// copy on another release, or a wrong-file import Lidarr holds unmapped
|
||||||
|
// (#5439) — and the mapped copy is the clear one to keep.
|
||||||
|
func autoMergeable(g *resolveGroup) bool {
|
||||||
|
switch g.class {
|
||||||
|
case ClassSameRelease:
|
||||||
|
return g.tracked() <= 1
|
||||||
|
case ClassCrossRelease, ClassMismatch:
|
||||||
|
return g.tracked() == 1
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settling says whether any of the group's albums had its Lidarr release
|
||||||
|
// changed recently, so Lidarr may still be remapping its files.
|
||||||
|
func (g *resolveGroup) settling(albums map[string]bool) bool {
|
||||||
|
for _, m := range g.members {
|
||||||
|
if albums[syncpkg.FormatUUID(m.AlbumID)] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (g *resolveGroup) tracked() int {
|
func (g *resolveGroup) tracked() int {
|
||||||
n := 0
|
n := 0
|
||||||
for _, s := range g.states {
|
for _, s := range g.states {
|
||||||
@@ -183,7 +215,14 @@ func ResolveDuplicates(
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linking removes nothing and needs nothing from Lidarr.
|
// Holding rips back and linking songs remove nothing and need nothing from
|
||||||
|
// Lidarr.
|
||||||
|
if res.HeldBack, err = q.FlagSuspectSourceTracks(ctx, SuspectSourcePattern); err != nil {
|
||||||
|
return res, fmt.Errorf("hold back video rips: %w", err)
|
||||||
|
}
|
||||||
|
if res.Released, err = q.ClearStaleSuspectSourceTracks(ctx, SuspectSourcePattern); err != nil {
|
||||||
|
return res, fmt.Errorf("release renamed tracks: %w", err)
|
||||||
|
}
|
||||||
linked, err := linkCrossRelease(ctx, pool, logger, groups)
|
linked, err := linkCrossRelease(ctx, pool, logger, groups)
|
||||||
res.SongsLinked = linked
|
res.SongsLinked = linked
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -208,7 +247,7 @@ func ResolveDuplicates(
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return res, ctx.Err()
|
return res, ctx.Err()
|
||||||
}
|
}
|
||||||
if g.class != ClassSameRelease || g.tracked() > 1 || settling[syncpkg.FormatUUID(g.members[0].AlbumID)] {
|
if !autoMergeable(g) || g.settling(settling) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := autoMerge(ctx, pool, logger, dataDir, g, removable); err != nil {
|
if err := autoMerge(ctx, pool, logger, dataDir, g, removable); err != nil {
|
||||||
@@ -747,5 +786,6 @@ func (w *DuplicateResolveWorker) tickOnce(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
w.logger.Info("duplicate resolve complete",
|
w.logger.Info("duplicate resolve complete",
|
||||||
"groups", res.Groups, "lidarr", res.LidarrConsulted, "merged", res.Merged,
|
"groups", res.Groups, "lidarr", res.LidarrConsulted, "merged", res.Merged,
|
||||||
"merge_failed", res.MergeFailed, "release_changes", len(res.ReleaseChanges), "songs_linked", res.SongsLinked)
|
"merge_failed", res.MergeFailed, "release_changes", len(res.ReleaseChanges), "songs_linked", res.SongsLinked,
|
||||||
|
"held_back", res.HeldBack, "released", res.Released)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,3 +347,158 @@ func TestMergeDuplicateGroupGuarded_RefusesATrackedCopy_Integration(t *testing.T
|
|||||||
t.Errorf("group status = %s, want pending", status)
|
t.Errorf("group status = %s, want pending", status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// crossReleaseRipFixture is a video rip on one release with the clean copy on
|
||||||
|
// another: the album cut, and the same song's video filed as a single.
|
||||||
|
type crossReleaseRipFixture struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
clean, rip dbq.Track
|
||||||
|
cleanPath, ripPath string
|
||||||
|
groupID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCrossReleaseRipFixture(t *testing.T) crossReleaseRipFixture {
|
||||||
|
t.Helper()
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
root := t.TempDir()
|
||||||
|
f := crossReleaseRipFixture{pool: pool}
|
||||||
|
f.cleanPath = filepath.Join(root, "Gorillaz", "Demon Days (2005)", "06 - Feel Good Inc.mp3")
|
||||||
|
f.ripPath = filepath.Join(root, "Gorillaz", "Feel Good Inc (2005)", "01 - Feel Good Inc. (Official Video).mp3")
|
||||||
|
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Gorillaz", SortName: "Gorillaz"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
track := func(albumTitle, title, path string) dbq.Track {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: albumTitle, SortTitle: albumTitle, ArtistID: artist.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||||
|
Title: title, AlbumID: album.ID, ArtistID: artist.ID,
|
||||||
|
DurationMs: 1000, FilePath: path, FileSize: 100, FileFormat: "mp3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
f.clean = track("Demon Days", "Feel Good Inc.", f.cleanPath)
|
||||||
|
f.rip = track("Feel Good Inc.", "Feel Good Inc. (Official Video)", f.ripPath)
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO duplicate_groups (member_key, tier) VALUES ('cross-rip', 'acoustic') RETURNING id`,
|
||||||
|
).Scan(&f.groupID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`,
|
||||||
|
f.groupID, f.clean.ID, f.rip.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rip fulfils nothing in Lidarr while the clean copy on the album does:
|
||||||
|
// the rip goes and the album copy stays (#5439, D-a rule 1).
|
||||||
|
func TestResolveDuplicates_MergesAnUnmappedRipAcrossReleases_Integration(t *testing.T) {
|
||||||
|
f := newCrossReleaseRipFixture(t)
|
||||||
|
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
|
||||||
|
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Classes[ClassCrossRelease] != 1 || res.Merged != 1 {
|
||||||
|
t.Fatalf("classes %v, merged %d; want the cross-release group merged", res.Classes, res.Merged)
|
||||||
|
}
|
||||||
|
if exists(f.ripPath) || !exists(f.cleanPath) {
|
||||||
|
t.Errorf("rip exists %v, clean exists %v; want only the clean copy", exists(f.ripPath), exists(f.cleanPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When Lidarr maps both copies, each fulfils its own release: nothing is
|
||||||
|
// removed, and the copies are linked as one song instead.
|
||||||
|
func TestResolveDuplicates_KeepsCopiesLidarrMapsOnTwoReleases_Integration(t *testing.T) {
|
||||||
|
f := newCrossReleaseRipFixture(t)
|
||||||
|
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", &fakeLidarr{}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Merged != 0 || !exists(f.ripPath) {
|
||||||
|
t.Errorf("merged %d; want both copies kept", res.Merged)
|
||||||
|
}
|
||||||
|
if res.SongsLinked != 1 {
|
||||||
|
t.Errorf("linked %d, want the pair linked as one song", res.SongsLinked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rip is held back from radio and the mixes; the operator's "fine" sticks
|
||||||
|
// through later passes; a renamed file is released.
|
||||||
|
func TestResolveDuplicates_HoldsBackRips_Integration(t *testing.T) {
|
||||||
|
f := newCrossReleaseRipFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(f.pool)
|
||||||
|
verdict := func(id pgtype.UUID) *string {
|
||||||
|
t.Helper()
|
||||||
|
var v *string
|
||||||
|
if err := f.pool.QueryRow(ctx, `SELECT source_verdict FROM tracks WHERE id = $1`, id).Scan(&v); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
resolve := func() DuplicateResolveResult {
|
||||||
|
t.Helper()
|
||||||
|
res, err := ResolveDuplicates(ctx, f.pool, nil, "", &fakeLidarr{}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
if res := resolve(); res.HeldBack != 1 {
|
||||||
|
t.Errorf("held back %d, want the rip", res.HeldBack)
|
||||||
|
}
|
||||||
|
if v := verdict(f.rip.ID); v == nil || *v != "suspect" {
|
||||||
|
t.Errorf("rip verdict %v, want suspect", v)
|
||||||
|
}
|
||||||
|
if v := verdict(f.clean.ID); v != nil {
|
||||||
|
t.Errorf("clean copy verdict %v, want none", *v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, err := q.SetTrackSourceVerdict(ctx, dbq.SetTrackSourceVerdictParams{Verdict: "fine", ID: f.rip.ID}); err != nil || n != 1 {
|
||||||
|
t.Fatalf("set fine: %d, %v", n, err)
|
||||||
|
}
|
||||||
|
if res := resolve(); res.HeldBack != 0 {
|
||||||
|
t.Errorf("held back %d after the operator's fine, want 0", res.HeldBack)
|
||||||
|
}
|
||||||
|
if v := verdict(f.rip.ID); v == nil || *v != "fine" {
|
||||||
|
t.Errorf("rip verdict %v, want fine kept", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Held back again, then renamed: the next pass lets it go.
|
||||||
|
if _, err := q.SetTrackSourceVerdict(ctx, dbq.SetTrackSourceVerdictParams{Verdict: "suspect", ID: f.rip.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.pool.Exec(ctx, `UPDATE tracks SET file_path = $2 WHERE id = $1`,
|
||||||
|
f.rip.ID, filepath.Join(filepath.Dir(f.ripPath), "01 - Feel Good Inc.mp3")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res := resolve(); res.Released != 1 {
|
||||||
|
t.Errorf("released %d, want the renamed track", res.Released)
|
||||||
|
}
|
||||||
|
if v := verdict(f.rip.ID); v != nil {
|
||||||
|
t.Errorf("renamed track verdict %v, want none", *v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clean copy can't be flagged by hand: there is nothing to judge.
|
||||||
|
if n, err := q.SetTrackSourceVerdict(ctx, dbq.SetTrackSourceVerdictParams{Verdict: "fine", ID: f.clean.ID}); err != nil || n != 0 {
|
||||||
|
t.Errorf("set on an unflagged track: %d, %v; want 0 rows", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package playlists
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A mix holds a song once (M498 #5438) and never a held-back video rip
|
||||||
|
// (#5439): of a single and its album cut, the better-ranked copy stays; a rip
|
||||||
|
// is left out and the next copy of its song takes the place.
|
||||||
|
func TestOneCopyPerSong_Integration(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
single := seedTrack(t, pool, "copies-single", "copies-artist")
|
||||||
|
albumCut := seedTrack(t, pool, "copies-album-cut", "copies-artist")
|
||||||
|
rip := seedTrack(t, pool, "copies-rip", "copies-artist")
|
||||||
|
clean := seedTrack(t, pool, "copies-clean", "copies-artist")
|
||||||
|
other := seedTrack(t, pool, "copies-other", "copies-artist")
|
||||||
|
for _, pair := range [][]pgtype.UUID{{single.ID, albumCut.ID}, {rip.ID, clean.ID}} {
|
||||||
|
if _, err := q.LinkTracksAsSong(ctx, pair); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE tracks SET source_verdict = 'suspect' WHERE id = $1`, rip.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
in := []rankedCandidate{
|
||||||
|
{TrackID: single.ID}, {TrackID: rip.ID}, {TrackID: albumCut.ID}, {TrackID: other.ID}, {TrackID: clean.ID},
|
||||||
|
}
|
||||||
|
out, err := oneCopyPerSong(ctx, q, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []pgtype.UUID{single.ID, other.ID, clean.ID}
|
||||||
|
if len(out) != len(want) {
|
||||||
|
t.Fatalf("got %d tracks, want %d", len(out), len(want))
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
if out[i].TrackID != w {
|
||||||
|
t.Errorf("position %d = %s, want %s", i, uuidString(out[i].TrackID), uuidString(w))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1101,7 +1101,9 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
|
|||||||
}
|
}
|
||||||
|
|
||||||
// oneCopyPerSong keeps the first of a song's copies on different releases
|
// oneCopyPerSong keeps the first of a song's copies on different releases
|
||||||
// (M498 #5438): a mix holds the song once, in its best-ranked place.
|
// (M498 #5438): a mix holds the song once, in its best-ranked place. A video
|
||||||
|
// rip held back from the mixes (#5439) is left out, and a clean copy of the
|
||||||
|
// same song can take its place.
|
||||||
func oneCopyPerSong(ctx context.Context, qtx *dbq.Queries, tracks []rankedCandidate) ([]rankedCandidate, error) {
|
func oneCopyPerSong(ctx context.Context, qtx *dbq.Queries, tracks []rankedCandidate) ([]rankedCandidate, error) {
|
||||||
ids := make([]pgtype.UUID, len(tracks))
|
ids := make([]pgtype.UUID, len(tracks))
|
||||||
for i, t := range tracks {
|
for i, t := range tracks {
|
||||||
@@ -1111,14 +1113,18 @@ func oneCopyPerSong(ctx context.Context, qtx *dbq.Queries, tracks []rankedCandid
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("song keys: %w", err)
|
return nil, fmt.Errorf("song keys: %w", err)
|
||||||
}
|
}
|
||||||
songOf := make(map[pgtype.UUID]pgtype.UUID, len(rows))
|
songOf := make(map[pgtype.UUID]dbq.ListTrackSongKeysRow, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
songOf[r.ID] = r.SongKey
|
songOf[r.ID] = r
|
||||||
}
|
}
|
||||||
seen := make(map[pgtype.UUID]bool, len(tracks))
|
seen := make(map[pgtype.UUID]bool, len(tracks))
|
||||||
out := tracks[:0:0]
|
out := tracks[:0:0]
|
||||||
for _, t := range tracks {
|
for _, t := range tracks {
|
||||||
song, ok := songOf[t.TrackID]
|
row, ok := songOf[t.TrackID]
|
||||||
|
if ok && row.HeldBack {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
song := row.SongKey
|
||||||
if ok && seen[song] {
|
if ok && seen[song] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ func RadioDiversityCaps(limit int) DiversityCaps {
|
|||||||
return DiversityCaps{MaxPerArtist: artist, MaxPerAlbum: album}
|
return DiversityCaps{MaxPerArtist: artist, MaxPerAlbum: album}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// heldBack says whether the candidate is a video rip held back from radio
|
||||||
|
// (M498 #5439): it plays when chosen, but radio does not choose it.
|
||||||
|
func heldBack(c Candidate) bool {
|
||||||
|
return c.Track.SourceVerdict != nil && *c.Track.SourceVerdict == "suspect"
|
||||||
|
}
|
||||||
|
|
||||||
// Shuffle scores each candidate, sorts descending by score, and returns the
|
// Shuffle scores each candidate, sorts descending by score, and returns the
|
||||||
// top `limit` candidates, preferring artist/album diversity. limit <= 0
|
// top `limit` candidates, preferring artist/album diversity. limit <= 0
|
||||||
// returns nil; nil input returns nil. Pure — no IO, no global state beyond
|
// returns nil; nil input returns nil. Pure — no IO, no global state beyond
|
||||||
@@ -61,7 +67,9 @@ func RadioDiversityCaps(limit int) DiversityCaps {
|
|||||||
// first takes candidates that fit under the caps, and the second fills any
|
// first takes candidates that fit under the caps, and the second fills any
|
||||||
// remaining slots from those the first pass skipped, still in score order.
|
// remaining slots from those the first pass skipped, still in score order.
|
||||||
// So the result holds min(limit, len(candidates)) either way — the caps
|
// So the result holds min(limit, len(candidates)) either way — the caps
|
||||||
// change WHICH tracks are chosen, never HOW MANY.
|
// change WHICH tracks are chosen, never HOW MANY. (What is not a candidate at
|
||||||
|
// all is dropped first: a held-back video rip, and a second copy of a song
|
||||||
|
// already chosen from another release, M498.)
|
||||||
//
|
//
|
||||||
// That two-pass shape is the whole design, and a hard cap would have been
|
// That two-pass shape is the whole design, and a hard cap would have been
|
||||||
// the easy mistake. Radio asks for 50 tracks by default and 200 at most; a
|
// the easy mistake. Radio asks for 50 tracks by default and 200 at most; a
|
||||||
@@ -119,7 +127,7 @@ func Shuffle(
|
|||||||
if len(out) == limit {
|
if len(out) == limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if repeat(s.c) {
|
if heldBack(s.c) || repeat(s.c) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
overArtist := caps.MaxPerArtist > 0 && artistCount[s.c.Track.ArtistID] >= caps.MaxPerArtist
|
overArtist := caps.MaxPerArtist > 0 && artistCount[s.c.Track.ArtistID] >= caps.MaxPerArtist
|
||||||
|
|||||||
@@ -111,3 +111,18 @@ func TestShuffle_OneCopyPerSong(t *testing.T) {
|
|||||||
t.Errorf("got %v, want two tracks: one copy of the song", titles(out))
|
t.Errorf("got %v, want two tracks: one copy of the song", titles(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A video rip held back from radio (M498 #5439) is never chosen, even to fill
|
||||||
|
// the request.
|
||||||
|
func TestShuffle_SkipsHeldBackRips(t *testing.T) {
|
||||||
|
suspect := "suspect"
|
||||||
|
fine := "fine"
|
||||||
|
rip := cand("000000000001", ScoringInputs{IsGeneralLiked: true})
|
||||||
|
rip.Track.SourceVerdict = &suspect
|
||||||
|
cleared := cand("000000000002", ScoringInputs{})
|
||||||
|
cleared.Track.SourceVerdict = &fine
|
||||||
|
out := Shuffle([]Candidate{rip, cleared}, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
|
||||||
|
if got := titles(out); len(got) != 1 || got[0] != "000000000002" {
|
||||||
|
t.Errorf("got %v, want only the track marked fine", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ActionResult,
|
ActionResult,
|
||||||
AdminMissingResponse,
|
AdminMissingResponse,
|
||||||
AdminSuspectResponse,
|
AdminSuspectResponse,
|
||||||
|
SuspectVerdict,
|
||||||
AdminDuplicatesResponse,
|
AdminDuplicatesResponse,
|
||||||
DuplicatesView,
|
DuplicatesView,
|
||||||
MergeDuplicateResult,
|
MergeDuplicateResult,
|
||||||
@@ -961,6 +962,12 @@ export function createSuspectSourcesQuery() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 'fine' lets a flagged track back into radio and the mixes; 'suspect' holds
|
||||||
|
// it back again (M498 #5439).
|
||||||
|
export async function setSuspectVerdict(trackId: string, verdict: SuspectVerdict): Promise<void> {
|
||||||
|
await api.put(`/api/admin/library/suspect-sources/${encodeURIComponent(trackId)}`, { verdict });
|
||||||
|
}
|
||||||
|
|
||||||
// Missing-file re-acquisition (#2527 / milestone #290) ----------------------
|
// Missing-file re-acquisition (#2527 / milestone #290) ----------------------
|
||||||
|
|
||||||
export type ReacquisitionSettings = {
|
export type ReacquisitionSettings = {
|
||||||
|
|||||||
@@ -439,8 +439,14 @@ export type AdminSuspectTrack = {
|
|||||||
disc_number: number | null;
|
disc_number: number | null;
|
||||||
track_number: number | null;
|
track_number: number | null;
|
||||||
markers: string[];
|
markers: string[];
|
||||||
|
// 'suspect': held back from radio and the mixes. 'fine': the operator let it
|
||||||
|
// back. null: the resolver hasn't looked yet (M498 #5439).
|
||||||
|
verdict: SuspectVerdict | null;
|
||||||
|
verdict_at: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SuspectVerdict = 'suspect' | 'fine';
|
||||||
|
|
||||||
// A folder's worth of flagged tracks; the server orders and groups by folder.
|
// A folder's worth of flagged tracks; the server orders and groups by folder.
|
||||||
export type AdminSuspectGroup = {
|
export type AdminSuspectGroup = {
|
||||||
directory: string;
|
directory: string;
|
||||||
|
|||||||
@@ -121,8 +121,9 @@
|
|||||||
<span class="text-sm text-text-primary">Resolve duplicates automatically</span>
|
<span class="text-sm text-text-primary">Resolve duplicates automatically</span>
|
||||||
<span class="block text-xs text-text-secondary">
|
<span class="block text-xs text-text-secondary">
|
||||||
Merges copies Lidarr doesn't use into the one it does, and moves a Lidarr album off a
|
Merges copies Lidarr doesn't use into the one it does, and moves a Lidarr album off a
|
||||||
release that lists songs twice. A copy Lidarr uses is never removed. Off, every group
|
release that lists songs twice. A song kept on several releases counts as one song, and
|
||||||
waits here for you.
|
video rips are kept out of radio and the mixes. A copy Lidarr uses is never removed.
|
||||||
|
Off, every group waits here for you.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -48,6 +48,8 @@
|
|||||||
"survivor_not_in_group": "That copy isn't part of this group any more.",
|
"survivor_not_in_group": "That copy isn't part of this group any more.",
|
||||||
"copy_tracked_by_lidarr": "Lidarr uses a copy this would remove, and would download it again. Keep that copy instead.",
|
"copy_tracked_by_lidarr": "Lidarr uses a copy this would remove, and would download it again. Keep that copy instead.",
|
||||||
"lidarr_unavailable": "Lidarr didn't answer, so it isn't safe to remove a copy yet. Try again shortly.",
|
"lidarr_unavailable": "Lidarr didn't answer, so it isn't safe to remove a copy yet. Try again shortly.",
|
||||||
|
"suspect_track_not_found": "That track isn't flagged any more. Refresh the list.",
|
||||||
|
"invalid_verdict": "That isn't a choice for a flagged track.",
|
||||||
"invalid_setting": "That setting is out of range.",
|
"invalid_setting": "That setting is out of range.",
|
||||||
"invalid_public_url": "That address isn't valid.",
|
"invalid_public_url": "That address isn't valid.",
|
||||||
"album_not_found": "That album no longer exists.",
|
"album_not_found": "That album no longer exists.",
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { FileCheck, Music2 } from 'lucide-svelte';
|
import { FileCheck, Music2 } from 'lucide-svelte';
|
||||||
import { createSuspectSourcesQuery } from '#lib/api/admin.js';
|
import { createSuspectSourcesQuery, setSuspectVerdict } from '#lib/api/admin.js';
|
||||||
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||||
import { coverUrl } from '#lib/media/covers.js';
|
import { coverUrl } from '#lib/media/covers.js';
|
||||||
import { formatDuration } from '#lib/media/duration.js';
|
import { formatDuration } from '#lib/media/duration.js';
|
||||||
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
import ListContinuation from '#lib/components/ListContinuation.svelte';
|
||||||
import type { AdminSuspectGroup, AdminSuspectTrack } from '#lib/api/types.js';
|
import type { AdminSuspectGroup, AdminSuspectTrack, SuspectVerdict } from '#lib/api/types.js';
|
||||||
|
|
||||||
// Files whose names say they were ripped from a video (#5410): "(Official
|
// Files whose names say they were ripped from a video (#5410): "(Official
|
||||||
// Video)", "[Audio]", "Visualizer", a reaction. Humanz was 62 of them in one
|
// Video)", "[Audio]", "Visualizer", a reaction. Humanz was 62 of them in one
|
||||||
// folder, with junk disc numbers and a reaction video filed as a song.
|
// folder, with junk disc numbers and a reaction video filed as a song.
|
||||||
// Read-only: a marker is a reason to look, not proof, and the fix lives
|
//
|
||||||
// elsewhere — Duplicates, Quarantine, or a better release in Lidarr.
|
// The duplicate resolver holds each one back from radio and the mixes, and
|
||||||
|
// merges it away where Lidarr keeps a clean copy (M498 #5439). A marker is a
|
||||||
|
// reason to look, not proof, so each row says what was done and offers the
|
||||||
|
// undo: "This one is fine" lets the track back for good.
|
||||||
|
|
||||||
const queryStore = createSuspectSourcesQuery();
|
const queryStore = createSuspectSourcesQuery();
|
||||||
const query = $derived($queryStore);
|
const query = $derived($queryStore);
|
||||||
@@ -34,6 +40,28 @@
|
|||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The verdict the operator just set, shown at once while the list refetches.
|
||||||
|
let changed = $state<Record<string, SuspectVerdict>>({});
|
||||||
|
let saving = $state<string | null>(null);
|
||||||
|
|
||||||
|
function verdictOf(t: AdminSuspectTrack): SuspectVerdict | null {
|
||||||
|
return changed[t.track_id] ?? t.verdict;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setVerdict(t: AdminSuspectTrack, verdict: SuspectVerdict) {
|
||||||
|
saving = t.track_id;
|
||||||
|
try {
|
||||||
|
await setSuspectVerdict(t.track_id, verdict);
|
||||||
|
changed[t.track_id] = verdict;
|
||||||
|
pushToast(verdict === 'fine' ? `${t.title} is back in radio and mixes.` : `${t.title} is held back again.`);
|
||||||
|
query.refetch();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(errMessage(e), 'error');
|
||||||
|
} finally {
|
||||||
|
saving = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function trackCountLabel(n: number): string {
|
function trackCountLabel(n: number): string {
|
||||||
return n === 1 ? '1 track' : `${n} tracks`;
|
return n === 1 ? '1 track' : `${n} tracks`;
|
||||||
}
|
}
|
||||||
@@ -71,7 +99,9 @@
|
|||||||
Tracks whose file name reads like a video title rather than an album track,
|
Tracks whose file name reads like a video title rather than an album track,
|
||||||
such as "(Official Video)", "[Audio]" or "Visualizer". These usually came from
|
such as "(Official Video)", "[Audio]" or "Visualizer". These usually came from
|
||||||
a video site, and can be the wrong audio: a live take, a music-video edit, or
|
a video site, and can be the wrong audio: a live take, a music-video edit, or
|
||||||
something else entirely. Nothing here is changed automatically.
|
something else entirely. Each one is kept out of radio and the mixes, though it still
|
||||||
|
plays when you choose it, and it is removed where Lidarr keeps a clean copy of the same
|
||||||
|
song. If one is fine, say so and it goes back in.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -143,6 +173,33 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 text-xs text-text-muted">{formatDuration(t.duration_sec)}</span>
|
<span class="shrink-0 text-xs text-text-muted">{formatDuration(t.duration_sec)}</span>
|
||||||
|
{#if verdictOf(t) === 'suspect'}
|
||||||
|
<div class="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<span class="text-xs text-text-secondary" data-testid="suspect-verdict">
|
||||||
|
Held back{#if t.verdict_at && !changed[t.track_id]} {relativeTime(t.verdict_at)}{/if}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => setVerdict(t, 'fine')}
|
||||||
|
disabled={saving === t.track_id}
|
||||||
|
class="rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
This one is fine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if verdictOf(t) === 'fine'}
|
||||||
|
<div class="flex shrink-0 flex-col items-end gap-1">
|
||||||
|
<span class="text-xs text-text-secondary" data-testid="suspect-verdict">Marked fine</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => setVerdict(t, 'suspect')}
|
||||||
|
disabled={saving === t.track_id}
|
||||||
|
class="rounded-md px-2.5 py-1 text-xs text-text-muted hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Hold back again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/svelte';
|
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||||
import { readable } from 'svelte/store';
|
import { readable } from 'svelte/store';
|
||||||
import type { AdminSuspectResponse, AdminSuspectTrack } from '#lib/api/types.js';
|
import type { AdminSuspectResponse, AdminSuspectTrack } from '#lib/api/types.js';
|
||||||
|
|
||||||
vi.mock('#lib/api/admin.js', () => ({
|
vi.mock('#lib/api/admin.js', () => ({
|
||||||
createSuspectSourcesQuery: vi.fn()
|
createSuspectSourcesQuery: vi.fn(),
|
||||||
|
setSuspectVerdict: vi.fn(async () => {})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import SuspectSourcesPage from './+page.svelte';
|
import SuspectSourcesPage from './+page.svelte';
|
||||||
import { createSuspectSourcesQuery } from '#lib/api/admin.js';
|
import { createSuspectSourcesQuery, setSuspectVerdict } from '#lib/api/admin.js';
|
||||||
|
|
||||||
const mocked = createSuspectSourcesQuery as ReturnType<typeof vi.fn>;
|
const mocked = createSuspectSourcesQuery as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
@@ -26,7 +27,9 @@ function track(title: string, file: string, markers: string[], disc: number | nu
|
|||||||
duration_sec: 180,
|
duration_sec: 180,
|
||||||
disc_number: disc,
|
disc_number: disc,
|
||||||
track_number: 4,
|
track_number: 4,
|
||||||
markers
|
markers,
|
||||||
|
verdict: 'suspect',
|
||||||
|
verdict_at: new Date(Date.now() - 3_600_000).toISOString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +45,8 @@ function infinite(pages: AdminSuspectResponse[], opts: { hasNextPage?: boolean;
|
|||||||
isError: false,
|
isError: false,
|
||||||
hasNextPage: opts.hasNextPage ?? false,
|
hasNextPage: opts.hasNextPage ?? false,
|
||||||
isFetchingNextPage: false,
|
isFetchingNextPage: false,
|
||||||
fetchNextPage: () => {}
|
fetchNextPage: () => {},
|
||||||
|
refetch: () => {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,4 +114,26 @@ describe('Suspect sources page', () => {
|
|||||||
expect(container.querySelector('div[aria-hidden="true"].h-px')).not.toBeNull();
|
expect(container.querySelector('div[aria-hidden="true"].h-px')).not.toBeNull();
|
||||||
expect(screen.queryByRole('button', { name: /more/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /more/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// M498 #5439: a flagged track is held back from radio and the mixes, and the
|
||||||
|
// operator can let it back.
|
||||||
|
test('This one is fine lets a held-back track back in', async () => {
|
||||||
|
mocked.mockReturnValue(
|
||||||
|
infinite([
|
||||||
|
page(0, 1, [
|
||||||
|
{
|
||||||
|
directory: HUMANZ,
|
||||||
|
tracks: [track('Strobelite', 'Gorillaz - Strobelite (Official Video).mp3', ['music video'])]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
])
|
||||||
|
);
|
||||||
|
render(SuspectSourcesPage);
|
||||||
|
expect(screen.getByTestId('suspect-verdict')).toHaveTextContent(/Held back/);
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: 'This one is fine' }));
|
||||||
|
expect(setSuspectVerdict).toHaveBeenCalledWith('t-Strobelite', 'fine');
|
||||||
|
expect(await screen.findByText('Marked fine')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Hold back again' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user