Compare commits
25
Commits
@@ -239,9 +239,16 @@ jobs:
|
|||||||
# container itself: the run: shell is dash (rule 81), where the
|
# container itself: the run: shell is dash (rule 81), where the
|
||||||
# old `/dev/tcp` probe never connects and the loop silently
|
# old `/dev/tcp` probe never connects and the loop silently
|
||||||
# burned its full two minutes on every run.
|
# burned its full two minutes on every run.
|
||||||
|
#
|
||||||
|
# Over TCP (-h 127.0.0.1), not the unix socket (#5426). On first
|
||||||
|
# boot the image's entrypoint runs initdb under a temporary server
|
||||||
|
# that listens on the socket only, then stops it and starts the
|
||||||
|
# real one. A socket probe can catch the temporary server, and the
|
||||||
|
# next step then meets "the database system is shutting down".
|
||||||
|
# Only the real server listens on TCP.
|
||||||
ready=""
|
ready=""
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if docker exec "$PG_ID" pg_isready -U minstrel -d minstrel_test -q; then ready=1; break; fi
|
if docker exec "$PG_ID" pg_isready -h 127.0.0.1 -U minstrel -d minstrel_test -q; then ready=1; break; fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
test -n "$ready" || { echo "FATAL: postgres never became ready"; exit 1; }
|
test -n "$ready" || { echo "FATAL: postgres never became ready"; exit 1; }
|
||||||
@@ -259,13 +266,25 @@ jobs:
|
|||||||
# - fsync / full_page_writes are sighup GUCs and
|
# - fsync / full_page_writes are sighup GUCs and
|
||||||
# synchronous_commit is user-context, so pg_reload_conf() picks
|
# synchronous_commit is user-context, so pg_reload_conf() picks
|
||||||
# all three up with no restart.
|
# all three up with no restart.
|
||||||
# Non-fatal: a perms surprise degrades to "slower", never red CI.
|
# Non-fatal: a failure degrades to "slower", never red CI. But
|
||||||
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
|
# slower is ~15x on internal/api (#5426), so retry a few times and
|
||||||
-c "ALTER SYSTEM SET fsync = off" \
|
# print what took effect.
|
||||||
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
relaxed=""
|
||||||
-c "ALTER SYSTEM SET full_page_writes = off" \
|
for i in 1 2 3 4 5; do
|
||||||
-c "SELECT pg_reload_conf()" \
|
if docker exec -e PGPASSWORD=minstrel "$PG_ID" psql -h 127.0.0.1 -U minstrel -d minstrel_test \
|
||||||
|| echo "WARN: durability relax failed; continuing"
|
-c "ALTER SYSTEM SET fsync = off" \
|
||||||
|
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
||||||
|
-c "ALTER SYSTEM SET full_page_writes = off" \
|
||||||
|
-c "SELECT pg_reload_conf()"; then relaxed=1; break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ -n "$relaxed" ]; then
|
||||||
|
docker exec -e PGPASSWORD=minstrel "$PG_ID" psql -h 127.0.0.1 -U minstrel -d minstrel_test -At \
|
||||||
|
-c "SELECT 'durability: ' || string_agg(name || '=' || setting, ' ' ORDER BY name) FROM pg_settings WHERE name IN ('fsync','synchronous_commit','full_page_writes')" \
|
||||||
|
|| true
|
||||||
|
else
|
||||||
|
echo "::warning::durability relax failed after 5 tries; the suite will run several times slower"
|
||||||
|
fi
|
||||||
|
|
||||||
# Apply embedded migrations to the fresh test DB, then run the
|
# Apply embedded migrations to the fresh test DB, then run the
|
||||||
# full suite (no -short → integration tests execute). -p 1:
|
# full suite (no -short → integration tests execute). -p 1:
|
||||||
@@ -295,12 +314,13 @@ jobs:
|
|||||||
- name: Install deps
|
- name: Install deps
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
# What ships to browsers: `dependencies` and the runtime they pull in
|
# The whole tree, build and test tooling included. Until #5021 this
|
||||||
# (svelte, devalue). Build and test tooling (vite, vitest, tailwind,
|
# audited only what ships to browsers (`--omit=dev`), because vite,
|
||||||
# kit's dev server) is left out because none of it reaches a user, and
|
# vitest, tailwind and kit carried advisories that needed major
|
||||||
# its open advisories need major-version upgrades tracked separately.
|
# upgrades. Those upgrades landed and the full tree audits clean, so
|
||||||
- name: npm audit (shipped dependencies)
|
# the tooling that builds the shipped bundle is held to the same bar.
|
||||||
run: npm audit --omit=dev --audit-level=moderate
|
- name: npm audit (all dependencies)
|
||||||
|
run: npm audit --audit-level=moderate
|
||||||
|
|
||||||
- name: Type-check + svelte-check
|
- name: Type-check + svelte-check
|
||||||
run: npm run check
|
run: npm run check
|
||||||
|
|||||||
+1
@@ -301,6 +301,7 @@ internal fun kindLabel(kind: String): String = when (kind) {
|
|||||||
"scan_failed" -> "Library scan failed"
|
"scan_failed" -> "Library scan failed"
|
||||||
"tracks_missing" -> "Tracks gone missing"
|
"tracks_missing" -> "Tracks gone missing"
|
||||||
"duplicates_found" -> "Duplicates to review"
|
"duplicates_found" -> "Duplicates to review"
|
||||||
|
"duplicates_resolved" -> "Duplicates resolved"
|
||||||
"playback_errors" -> "Playback errors"
|
"playback_errors" -> "Playback errors"
|
||||||
else -> kind
|
else -> kind
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,6 +325,19 @@ func run() error {
|
|||||||
library.SetNotifier(notifier)
|
library.SetNotifier(notifier)
|
||||||
go lidarrReconciler.Run(ctx)
|
go lidarrReconciler.Run(ctx)
|
||||||
|
|
||||||
|
// Duplicate resolver (M498): classifies the sweep's groups and, with the
|
||||||
|
// operator's auto-resolve on, merges copies Lidarr does not map and moves a
|
||||||
|
// Lidarr release that lists songs twice. Started after SetNotifier so its
|
||||||
|
// summaries reach admins. See internal/library/duplicate_resolve.go.
|
||||||
|
resolverLidarr := func() library.LidarrLibrary {
|
||||||
|
if c := lidarrClientFn(); c != nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return nil // a nil *lidarr.Client must not become a non-nil interface
|
||||||
|
}
|
||||||
|
go library.NewDuplicateResolveWorker(pool, logger.With("component", "duplicate_resolve"),
|
||||||
|
cfg.Storage.DataDir, fpSettings, resolverLidarr).Run(ctx)
|
||||||
|
|
||||||
// Missing-file re-acquisition (milestone #290). Turns albums whose files
|
// Missing-file re-acquisition (milestone #290). Turns albums whose files
|
||||||
// have been gone longer than the grace window into Lidarr requests, on an
|
// have been gone longer than the grace window into Lidarr requests, on an
|
||||||
// exponential per-album backoff. Hourly tick — the shortest meaningful
|
// exponential per-album backoff. Hourly tick — the shortest meaningful
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ Keystore, so a copy of the app's files doesn't yield a usable session.
|
|||||||
|
|
||||||
Nothing is published unless every check passes: the Go, integration, web and
|
Nothing is published unless every check passes: the Go, integration, web and
|
||||||
Android test suites, `govulncheck` against the toolchain that builds the
|
Android test suites, `govulncheck` against the toolchain that builds the
|
||||||
image, and `npm audit` on the packages that ship to the browser. See
|
image, and `npm audit` on every web dependency, build tooling included. See
|
||||||
`.gitea/workflows/release.yml`.
|
`.gitea/workflows/release.yml`.
|
||||||
|
|
||||||
## Reporting a problem
|
## Reporting a problem
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import (
|
|||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||||
"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"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// duplicateMemberView is one copy in a proposed duplicate group. LikeCount and
|
// duplicateMemberView is one copy in a proposed duplicate group. LikeCount and
|
||||||
@@ -31,6 +33,12 @@ type duplicateMemberView struct {
|
|||||||
AddedAt string `json:"added_at"`
|
AddedAt string `json:"added_at"`
|
||||||
LikeCount int64 `json:"like_count"`
|
LikeCount int64 `json:"like_count"`
|
||||||
PlayCount int64 `json:"play_count"`
|
PlayCount int64 `json:"play_count"`
|
||||||
|
// LidarrState is Lidarr's view of the file as of the resolver's last pass
|
||||||
|
// (M498): "tracked" (mapped to a track, so it cannot be removed without
|
||||||
|
// Lidarr downloading it again), "unmapped", or null when not asked.
|
||||||
|
LidarrState *string `json:"lidarr_state"`
|
||||||
|
DiscNumber *int32 `json:"disc_number"`
|
||||||
|
TrackNumber *int32 `json:"track_number"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are
|
// duplicateGroupView is one proposal. SurvivorTrackID and SurvivorReason are
|
||||||
@@ -38,13 +46,17 @@ type duplicateMemberView struct {
|
|||||||
// (library.ProposeSurvivor) — a default the merge (#3911) lets the operator
|
// (library.ProposeSurvivor) — a default the merge (#3911) lets the operator
|
||||||
// override.
|
// override.
|
||||||
type duplicateGroupView struct {
|
type duplicateGroupView struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Tier string `json:"tier"`
|
Tier string `json:"tier"`
|
||||||
WorstBitErrorRate *float32 `json:"worst_bit_error_rate"`
|
WorstBitErrorRate *float32 `json:"worst_bit_error_rate"`
|
||||||
DetectedAt string `json:"detected_at"`
|
DetectedAt string `json:"detected_at"`
|
||||||
SurvivorTrackID string `json:"survivor_track_id"`
|
SurvivorTrackID string `json:"survivor_track_id"`
|
||||||
SurvivorReason string `json:"survivor_reason"`
|
SurvivorReason string `json:"survivor_reason"`
|
||||||
Members []duplicateMemberView `json:"members"`
|
// Class is the resolver's verdict (library.DuplicateClass), null until its
|
||||||
|
// first pass; ResolveNote is why it left the group for the operator.
|
||||||
|
Class *string `json:"class"`
|
||||||
|
ResolveNote *string `json:"resolve_note"`
|
||||||
|
Members []duplicateMemberView `json:"members"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// duplicateSweepView is the latest sweep. State is "never" when none has run,
|
// duplicateSweepView is the latest sweep. State is "never" when none has run,
|
||||||
@@ -60,10 +72,26 @@ type duplicateSweepView struct {
|
|||||||
ErrorMessage *string `json:"error_message"`
|
ErrorMessage *string `json:"error_message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminDuplicatesResponse is the paged report. Total counts groups.
|
// duplicateViewCounts sizes each tab of the report.
|
||||||
|
type duplicateViewCounts struct {
|
||||||
|
Review int64 `json:"review"`
|
||||||
|
CrossRelease int64 `json:"cross_release"`
|
||||||
|
Resolved int64 `json:"resolved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report views (M498). cross_release groups are the same song on different
|
||||||
|
// releases, kept on purpose; everything else pending needs the operator.
|
||||||
|
const (
|
||||||
|
duplicateViewReview = "review"
|
||||||
|
duplicateViewCrossRelease = "cross_release"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adminDuplicatesResponse is the paged report. Total counts groups in View.
|
||||||
type adminDuplicatesResponse struct {
|
type adminDuplicatesResponse struct {
|
||||||
Sweep duplicateSweepView `json:"sweep"`
|
Sweep duplicateSweepView `json:"sweep"`
|
||||||
Fingerprints fingerprintCoverageResp `json:"fingerprints"`
|
Fingerprints fingerprintCoverageResp `json:"fingerprints"`
|
||||||
|
View string `json:"view"`
|
||||||
|
Counts duplicateViewCounts `json:"counts"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
Offset int `json:"offset"`
|
Offset int `json:"offset"`
|
||||||
@@ -71,6 +99,7 @@ type adminDuplicatesResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleListDuplicates implements GET /api/admin/library/duplicates (#3912).
|
// handleListDuplicates implements GET /api/admin/library/duplicates (#3912).
|
||||||
|
// ?view=review (the default) or cross_release picks the tab (M498).
|
||||||
//
|
//
|
||||||
// Read-only. The sweep's state and the fingerprint backfill's progress travel
|
// Read-only. The sweep's state and the fingerprint backfill's progress travel
|
||||||
// with the groups because an empty report means three different things — still
|
// with the groups because an empty report means three different things — still
|
||||||
@@ -81,6 +110,14 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
|
|||||||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging")
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
view := r.URL.Query().Get("view")
|
||||||
|
if view == "" {
|
||||||
|
view = duplicateViewReview
|
||||||
|
}
|
||||||
|
if view != duplicateViewReview && view != duplicateViewCrossRelease {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_view")
|
||||||
|
return
|
||||||
|
}
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
|
|
||||||
@@ -101,14 +138,18 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
|
|||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := q.CountPendingDuplicateGroups(ctx)
|
counts, err := duplicateCounts(ctx, q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("admin: count duplicate groups", "err", err)
|
h.logger.Error("admin: count duplicate groups", "err", err)
|
||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
total := counts.Review
|
||||||
|
if view == duplicateViewCrossRelease {
|
||||||
|
total = counts.CrossRelease
|
||||||
|
}
|
||||||
rows, err := q.ListPendingDuplicateGroupMembers(ctx, dbq.ListPendingDuplicateGroupMembersParams{
|
rows, err := q.ListPendingDuplicateGroupMembers(ctx, dbq.ListPendingDuplicateGroupMembersParams{
|
||||||
PageLimit: int32(limit), PageOffset: int32(offset),
|
View: view, PageLimit: int32(limit), PageOffset: int32(offset),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("admin: list duplicate groups", "err", err)
|
h.logger.Error("admin: list duplicate groups", "err", err)
|
||||||
@@ -119,6 +160,8 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, adminDuplicatesResponse{
|
writeJSON(w, http.StatusOK, adminDuplicatesResponse{
|
||||||
Sweep: sweep,
|
Sweep: sweep,
|
||||||
Fingerprints: cov,
|
Fingerprints: cov,
|
||||||
|
View: view,
|
||||||
|
Counts: counts,
|
||||||
Total: total,
|
Total: total,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
@@ -126,6 +169,76 @@ func (h *handlers) handleListDuplicates(w http.ResponseWriter, r *http.Request)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolvedActions are the audit actions the "Resolved automatically" tab lists.
|
||||||
|
var resolvedActions = []string{string(audit.ActionDuplicateMerge), string(audit.ActionLidarrReleaseChange)}
|
||||||
|
|
||||||
|
func duplicateCounts(ctx context.Context, q *dbq.Queries) (duplicateViewCounts, error) {
|
||||||
|
var c duplicateViewCounts
|
||||||
|
var err error
|
||||||
|
if c.Review, err = q.CountPendingDuplicateGroupsInView(ctx, duplicateViewReview); err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
if c.CrossRelease, err = q.CountPendingDuplicateGroupsInView(ctx, duplicateViewCrossRelease); err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
c.Resolved, err = q.CountAuditLogByActions(ctx, dbq.CountAuditLogByActionsParams{Actions: resolvedActions, SystemOnly: true})
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvedActivityView is one thing the resolver did on its own: a merge
|
||||||
|
// (action duplicate_merge) or a Lidarr release change (lidarr_release_change).
|
||||||
|
// Details is the audit row's metadata, which names the files or releases.
|
||||||
|
type resolvedActivityView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Details json.RawMessage `json:"details"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolvedActivityResponse struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Items []resolvedActivityView `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListResolvedDuplicates implements GET
|
||||||
|
// /api/admin/library/duplicates/resolved (M498): what the resolver did by
|
||||||
|
// itself, newest first, read from the audit log so it outlives the groups.
|
||||||
|
func (h *handlers) handleListResolvedDuplicates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit, offset, err := parsePaging(r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
total, err := q.CountAuditLogByActions(r.Context(), dbq.CountAuditLogByActionsParams{Actions: resolvedActions, SystemOnly: true})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: count resolved duplicates", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := q.ListAuditLogByActions(r.Context(), dbq.ListAuditLogByActionsParams{
|
||||||
|
Actions: resolvedActions, SystemOnly: true, PageLimit: int32(limit), PageOffset: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: list resolved duplicates", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]resolvedActivityView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
details := json.RawMessage(row.Metadata)
|
||||||
|
if len(details) == 0 {
|
||||||
|
details = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
items = append(items, resolvedActivityView{
|
||||||
|
ID: uuidToString(row.ID), Action: row.Action, CreatedAt: formatTimestamp(row.CreatedAt), Details: details,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resolvedActivityResponse{Total: total, Limit: limit, Offset: offset, Items: items})
|
||||||
|
}
|
||||||
|
|
||||||
func duplicateSweepViewOf(s dbq.DuplicateSweep) duplicateSweepView {
|
func duplicateSweepViewOf(s dbq.DuplicateSweep) duplicateSweepView {
|
||||||
v := duplicateSweepView{
|
v := duplicateSweepView{
|
||||||
State: "running",
|
State: "running",
|
||||||
@@ -158,6 +271,8 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
|
|||||||
Tier: row.Tier,
|
Tier: row.Tier,
|
||||||
WorstBitErrorRate: row.WorstBitErrorRate,
|
WorstBitErrorRate: row.WorstBitErrorRate,
|
||||||
DetectedAt: formatTimestamp(row.DetectedAt),
|
DetectedAt: formatTimestamp(row.DetectedAt),
|
||||||
|
Class: row.Class,
|
||||||
|
ResolveNote: row.ResolveNote,
|
||||||
})
|
})
|
||||||
candidates = append(candidates, nil)
|
candidates = append(candidates, nil)
|
||||||
}
|
}
|
||||||
@@ -176,9 +291,14 @@ func foldDuplicateGroups(rows []dbq.ListPendingDuplicateGroupMembersRow) []dupli
|
|||||||
AddedAt: formatTimestamp(row.AddedAt),
|
AddedAt: formatTimestamp(row.AddedAt),
|
||||||
LikeCount: row.LikeCount,
|
LikeCount: row.LikeCount,
|
||||||
PlayCount: row.PlayCount,
|
PlayCount: row.PlayCount,
|
||||||
|
LidarrState: row.LidarrState,
|
||||||
|
DiscNumber: row.DiscNumber,
|
||||||
|
TrackNumber: row.TrackNumber,
|
||||||
})
|
})
|
||||||
candidates[n] = append(candidates[n], library.SurvivorCandidate{
|
candidates[n] = append(candidates[n], library.SurvivorCandidate{
|
||||||
TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time,
|
TrackID: trackID, FileFormat: row.FileFormat, FileSize: row.FileSize, AddedAt: row.AddedAt.Time,
|
||||||
|
LidarrTracked: row.LidarrState != nil && *row.LidarrState == "tracked",
|
||||||
|
TagFit: library.TagFitScore(row.TrackNumber != nil, row.PositionClash, row.FilePath, row.HasMbid),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for i := range groups {
|
for i := range groups {
|
||||||
@@ -211,15 +331,17 @@ func (h *handlers) handleRunDuplicateSweep(w http.ResponseWriter, _ *http.Reques
|
|||||||
|
|
||||||
// handleDismissDuplicateGroup implements POST
|
// handleDismissDuplicateGroup implements POST
|
||||||
// /api/admin/library/duplicates/{id}/dismiss: "these are not duplicates". The
|
// /api/admin/library/duplicates/{id}/dismiss: "these are not duplicates". The
|
||||||
// sweep keeps the dismissal and will not propose that set of tracks again. 404
|
// sweep keeps the dismissal and will not propose that set of tracks again. On a
|
||||||
// duplicate_group_not_pending when the group was already resolved or is gone.
|
// cross-release group it also undoes the song link (M498 #5438): the copies
|
||||||
|
// stop sharing likes from here on. 404 duplicate_group_not_pending when the
|
||||||
|
// group was already resolved or is gone.
|
||||||
func (h *handlers) handleDismissDuplicateGroup(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleDismissDuplicateGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := parseUUID(chi.URLParam(r, "id"))
|
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
if !ok {
|
if !ok {
|
||||||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
n, err := dbq.New(h.pool).DismissDuplicateGroup(r.Context(), id)
|
n, err := h.dismissDuplicateGroup(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("admin: dismiss duplicate group", "err", err)
|
h.logger.Error("admin: dismiss duplicate group", "err", err)
|
||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
@@ -232,6 +354,25 @@ func (h *handlers) handleDismissDuplicateGroup(w http.ResponseWriter, r *http.Re
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "dismissed"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "dismissed"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dismissDuplicateGroup dismisses the group and unlinks its copies as one song,
|
||||||
|
// together.
|
||||||
|
func (h *handlers) dismissDuplicateGroup(ctx context.Context, id pgtype.UUID) (int64, error) {
|
||||||
|
tx, err := h.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
tq := dbq.New(tx)
|
||||||
|
n, err := tq.DismissDuplicateGroup(ctx, id)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
if err := tq.UnlinkDuplicateGroupSongs(ctx, id); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// mergeDuplicateRequest chooses the copy to keep. An empty survivor_track_id
|
// mergeDuplicateRequest chooses the copy to keep. An empty survivor_track_id
|
||||||
// keeps the report's proposal.
|
// keeps the report's proposal.
|
||||||
type mergeDuplicateRequest struct {
|
type mergeDuplicateRequest struct {
|
||||||
@@ -257,6 +398,10 @@ const mergeRequestBodyLimit = 1 << 16
|
|||||||
// Errors:
|
// Errors:
|
||||||
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
|
// - 409 library_not_writable / 500 file_delete_failed when a file could not be
|
||||||
// removed — nothing was changed (fileRemoveAPIError)
|
// removed — nothing was changed (fileRemoveAPIError)
|
||||||
|
// - 409 copy_tracked_by_lidarr when a copy to remove is one Lidarr maps to a
|
||||||
|
// track: removing it would make Lidarr download it again (M498)
|
||||||
|
// - 503 lidarr_unavailable when Lidarr is on but did not answer, so that
|
||||||
|
// cannot be checked
|
||||||
// - 404 duplicate_group_not_pending when the group was already resolved
|
// - 404 duplicate_group_not_pending when the group was already resolved
|
||||||
// - 400 survivor_not_in_group, invalid_id, invalid_body
|
// - 400 survivor_not_in_group, invalid_id, invalid_body
|
||||||
func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -294,6 +439,11 @@ func (h *handlers) handleMergeDuplicateGroup(w http.ResponseWriter, r *http.Requ
|
|||||||
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
|
writeAdminJSONErr(w, http.StatusNotFound, "duplicate_group_not_pending")
|
||||||
case errors.Is(err, library.ErrSurvivorNotInGroup):
|
case errors.Is(err, library.ErrSurvivorNotInGroup):
|
||||||
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
|
writeAdminJSONErr(w, http.StatusBadRequest, "survivor_not_in_group")
|
||||||
|
case errors.Is(err, library.ErrCopyTrackedByLidarr):
|
||||||
|
writeAdminJSONErr(w, http.StatusConflict, "copy_tracked_by_lidarr")
|
||||||
|
case errors.Is(err, tracks.ErrLidarrUnavailable):
|
||||||
|
h.logger.Warn("admin: merge duplicate group: lidarr unavailable", "group_id", uuidToString(groupID), "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unavailable")
|
||||||
default:
|
default:
|
||||||
h.logger.Error("admin: merge duplicate group", "group_id", uuidToString(groupID), "err", err)
|
h.logger.Error("admin: merge duplicate group", "group_id", uuidToString(groupID), "err", err)
|
||||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
|||||||
@@ -63,3 +63,29 @@ func TestFoldDuplicateGroups(t *testing.T) {
|
|||||||
t.Errorf("group 2 survivor = (%s, %q), want the FLAC copy", g2.SurvivorTrackID, g2.SurvivorReason)
|
t.Errorf("group 2 survivor = (%s, %q), want the FLAC copy", g2.SurvivorTrackID, g2.SurvivorReason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M498: the proposal shown beside a group follows the resolver's rule. The copy
|
||||||
|
// Lidarr tracks is proposed even when the other is lossless, because removing
|
||||||
|
// the tracked one would make Lidarr download it again.
|
||||||
|
func TestFoldDuplicateGroups_ProposesTheCopyLidarrTracks(t *testing.T) {
|
||||||
|
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
tracked, unmapped := "tracked", "unmapped"
|
||||||
|
class := "same_release"
|
||||||
|
rows := []dbq.ListPendingDuplicateGroupMembersRow{
|
||||||
|
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(30), Class: &class,
|
||||||
|
Title: "Song", FileFormat: "flac", FileSize: 30_000_000, AddedAt: dupTS(at), LidarrState: &unmapped},
|
||||||
|
{GroupID: dupUUID(3), Tier: "acoustic", DetectedAt: dupTS(at), TrackID: dupUUID(31), Class: &class,
|
||||||
|
Title: "Song", FileFormat: "mp3", FileSize: 9_000_000, AddedAt: dupTS(at), LidarrState: &tracked},
|
||||||
|
}
|
||||||
|
got := foldDuplicateGroups(rows)
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("folded %d groups, want 1", len(got))
|
||||||
|
}
|
||||||
|
g := got[0]
|
||||||
|
if g.SurvivorTrackID != uuidToString(dupUUID(31)) || g.SurvivorReason != "the copy Lidarr tracks" {
|
||||||
|
t.Errorf("survivor = (%s, %q), want the tracked mp3", g.SurvivorTrackID, g.SurvivorReason)
|
||||||
|
}
|
||||||
|
if g.Class == nil || *g.Class != "same_release" || g.Members[1].LidarrState == nil || *g.Members[1].LidarrState != "tracked" {
|
||||||
|
t.Errorf("class or lidarr state not carried: %+v", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ type fingerprintSettingsBody struct {
|
|||||||
AcousticMaxBitErrorRate float64 `json:"acoustic_max_bit_error_rate"`
|
AcousticMaxBitErrorRate float64 `json:"acoustic_max_bit_error_rate"`
|
||||||
BackfillConcurrency int32 `json:"backfill_concurrency"`
|
BackfillConcurrency int32 `json:"backfill_concurrency"`
|
||||||
SweepIntervalHours int32 `json:"sweep_interval_hours"`
|
SweepIntervalHours int32 `json:"sweep_interval_hours"`
|
||||||
|
// AutoResolve is a pointer so a client that predates it (M498) and leaves
|
||||||
|
// it out keeps the saved value rather than switching the resolver off.
|
||||||
|
AutoResolve *bool `json:"auto_resolve"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func fingerprintSettingsBodyOf(s library.FingerprintSettings) fingerprintSettingsBody {
|
func fingerprintSettingsBodyOf(s library.FingerprintSettings) fingerprintSettingsBody {
|
||||||
@@ -27,6 +30,7 @@ func fingerprintSettingsBodyOf(s library.FingerprintSettings) fingerprintSetting
|
|||||||
AcousticMaxBitErrorRate: s.AcousticMaxBitErrorRate,
|
AcousticMaxBitErrorRate: s.AcousticMaxBitErrorRate,
|
||||||
BackfillConcurrency: s.BackfillConcurrency,
|
BackfillConcurrency: s.BackfillConcurrency,
|
||||||
SweepIntervalHours: s.SweepIntervalHours,
|
SweepIntervalHours: s.SweepIntervalHours,
|
||||||
|
AutoResolve: &s.AutoResolve,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +43,7 @@ func (h *handlers) handleGetFingerprintSettings(w http.ResponseWriter, _ *http.R
|
|||||||
//
|
//
|
||||||
// A whole-row write. A body that leaves a field out decodes it as zero, which no
|
// A whole-row write. A body that leaves a field out decodes it as zero, which no
|
||||||
// field accepts, so a partial save is refused rather than zeroing what it omitted.
|
// field accepts, so a partial save is refused rather than zeroing what it omitted.
|
||||||
|
// auto_resolve is the exception: left out, it keeps its saved value.
|
||||||
// The saved settings reach the scanner and both workers at once: they share the
|
// The saved settings reach the scanner and both workers at once: they share the
|
||||||
// service instance.
|
// service instance.
|
||||||
func (h *handlers) handleUpdateFingerprintSettings(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleUpdateFingerprintSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -47,12 +52,17 @@ func (h *handlers) handleUpdateFingerprintSettings(w http.ResponseWriter, r *htt
|
|||||||
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
autoResolve := h.fingerprintSettings.Get().AutoResolve
|
||||||
|
if req.AutoResolve != nil {
|
||||||
|
autoResolve = *req.AutoResolve
|
||||||
|
}
|
||||||
saved, err := h.fingerprintSettings.Set(r.Context(), library.FingerprintSettings{
|
saved, err := h.fingerprintSettings.Set(r.Context(), library.FingerprintSettings{
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
ChromaprintLengthSec: req.ChromaprintLengthSec,
|
ChromaprintLengthSec: req.ChromaprintLengthSec,
|
||||||
AcousticMaxBitErrorRate: req.AcousticMaxBitErrorRate,
|
AcousticMaxBitErrorRate: req.AcousticMaxBitErrorRate,
|
||||||
BackfillConcurrency: req.BackfillConcurrency,
|
BackfillConcurrency: req.BackfillConcurrency,
|
||||||
SweepIntervalHours: req.SweepIntervalHours,
|
SweepIntervalHours: req.SweepIntervalHours,
|
||||||
|
AutoResolve: autoResolve,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Validation mirrors migration 0061's CHECKs and names the field.
|
// Validation mirrors migration 0061's CHECKs and names the field.
|
||||||
|
|||||||
@@ -23,7 +23,13 @@ func TestGetFingerprintSettings_ServesDefaultsWithoutAService(t *testing.T) {
|
|||||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("decode: %v", err)
|
||||||
}
|
}
|
||||||
if want := fingerprintSettingsBodyOf(library.DefaultFingerprintSettings); got != want {
|
want := fingerprintSettingsBodyOf(library.DefaultFingerprintSettings)
|
||||||
|
// auto_resolve is a pointer, so it is compared by value and then set aside.
|
||||||
|
if got.AutoResolve == nil || *got.AutoResolve != *want.AutoResolve {
|
||||||
|
t.Fatalf("auto_resolve = %v, want %v", got.AutoResolve, *want.AutoResolve)
|
||||||
|
}
|
||||||
|
got.AutoResolve, want.AutoResolve = nil, nil
|
||||||
|
if got != want {
|
||||||
t.Fatalf("body = %+v, want the defaults %+v", got, want)
|
t.Fatalf("body = %+v, want the defaults %+v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
|
)
|
||||||
|
|
||||||
|
// suspectTrackView is one flagged track. FilePath is the evidence: the
|
||||||
|
// markers are read from its basename, and the operator needs to see it to
|
||||||
|
// judge whether the flag is right.
|
||||||
|
type suspectTrackView struct {
|
||||||
|
TrackID string `json:"track_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
ArtistID string `json:"artist_id"`
|
||||||
|
ArtistName string `json:"artist_name"`
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
AlbumTitle string `json:"album_title"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
DurationSec int32 `json:"duration_sec"`
|
||||||
|
DiscNumber *int32 `json:"disc_number"`
|
||||||
|
TrackNumber *int32 `json:"track_number"`
|
||||||
|
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"`
|
||||||
|
// SearchedAt is when the resolver last asked Lidarr to search the album for
|
||||||
|
// a better copy (#5447): null when it has not.
|
||||||
|
SearchedAt *string `json:"searched_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspectGroupView is a folder's worth of flagged tracks. As on the
|
||||||
|
// missing-files report, the folder is the unit of decision: Humanz was 62 rows
|
||||||
|
// in one folder, which is one problem to look at, not 62.
|
||||||
|
type suspectGroupView struct {
|
||||||
|
Directory string `json:"directory"`
|
||||||
|
Tracks []suspectTrackView `json:"tracks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminSuspectSourcesResponse is the paged envelope. Total counts tracks.
|
||||||
|
type adminSuspectSourcesResponse struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Groups []suspectGroupView `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListSuspectSources implements GET /api/admin/library/suspect-sources.
|
||||||
|
//
|
||||||
|
// A marker is a reason to look, not proof: a band can title a song "Music
|
||||||
|
// Video". The duplicate resolver holds a flagged track back from radio and the
|
||||||
|
// 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) {
|
||||||
|
limit, offset, err := parsePaging(r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_paging")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
total, err := q.CountSuspectSourceTracks(r.Context(), library.SuspectSourcePattern)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: count suspect-source tracks", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := q.ListSuspectSourceTracks(r.Context(), dbq.ListSuspectSourceTracksParams{
|
||||||
|
Pattern: library.SuspectSourcePattern,
|
||||||
|
PageLimit: int32(limit),
|
||||||
|
PageOffset: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: list suspect-source tracks", "err", err)
|
||||||
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, adminSuspectSourcesResponse{
|
||||||
|
Total: total,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
Groups: groupSuspectByDirectory(rows),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupSuspectByDirectory folds the directory-ordered rows into per-folder
|
||||||
|
// groups, the same run-length fold as groupMissingByDirectory. A page boundary
|
||||||
|
// can split a folder; the web page joins the halves.
|
||||||
|
func groupSuspectByDirectory(rows []dbq.ListSuspectSourceTracksRow) []suspectGroupView {
|
||||||
|
groups := make([]suspectGroupView, 0, 8)
|
||||||
|
for _, row := range rows {
|
||||||
|
t := suspectTrackView{
|
||||||
|
TrackID: uuidToString(row.ID),
|
||||||
|
Title: row.Title,
|
||||||
|
ArtistID: uuidToString(row.ArtistID),
|
||||||
|
ArtistName: row.ArtistName,
|
||||||
|
AlbumID: uuidToString(row.AlbumID),
|
||||||
|
AlbumTitle: row.AlbumTitle,
|
||||||
|
FilePath: row.FilePath,
|
||||||
|
DurationSec: row.DurationMs / 1000,
|
||||||
|
DiscNumber: row.DiscNumber,
|
||||||
|
TrackNumber: row.TrackNumber,
|
||||||
|
Markers: library.SourceMarkersFor(row.FilePath),
|
||||||
|
Verdict: row.SourceVerdict,
|
||||||
|
}
|
||||||
|
if row.SourceVerdictAt.Valid {
|
||||||
|
at := row.SourceVerdictAt.Time.UTC().Format(time.RFC3339)
|
||||||
|
t.VerdictAt = &at
|
||||||
|
}
|
||||||
|
if row.SearchedAt.Valid {
|
||||||
|
at := row.SearchedAt.Time.UTC().Format(time.RFC3339)
|
||||||
|
t.SearchedAt = &at
|
||||||
|
}
|
||||||
|
if n := len(groups); n > 0 && groups[n-1].Directory == row.Directory {
|
||||||
|
groups[n-1].Tracks = append(groups[n-1].Tracks, t)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups = append(groups, suspectGroupView{
|
||||||
|
Directory: row.Directory,
|
||||||
|
Tracks: []suspectTrackView{t},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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})
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The integration test runs the same alternation through Postgres's ~*, so the
|
||||||
|
// one pattern is exercised in both regex dialects it has to work in.
|
||||||
|
func TestHandleListSuspectSources_Integration(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
truncateLibrary(t, pool)
|
||||||
|
admin := seedUser(t, pool, "suspect-admin", "pw", true)
|
||||||
|
|
||||||
|
artist := seedArtist(t, pool, "Gorillaz")
|
||||||
|
humanz := seedAlbum(t, pool, artist.ID, "Humanz", 2017)
|
||||||
|
seedTrack(t, pool, humanz.ID, artist.ID, "Saturnz Barz (Official Video)", 1, 180_000)
|
||||||
|
seedTrack(t, pool, humanz.ID, artist.ID, "Momentz (Visualizer)", 2, 200_000)
|
||||||
|
seedTrack(t, pool, humanz.ID, artist.ID, "Andromeda [Audio]", 3, 190_000)
|
||||||
|
seedTrack(t, pool, humanz.ID, artist.ID, "Chain Reaction", 4, 190_000)
|
||||||
|
seedTrack(t, pool, humanz.ID, artist.ID, "Busted and Blue", 5, 190_000)
|
||||||
|
gone := seedTrack(t, pool, humanz.ID, artist.ID, "Ascension (Lyric Video)", 6, 156_000)
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`UPDATE tracks SET missing_since = now() WHERE id = $1`, gone.ID); err != nil {
|
||||||
|
t.Fatalf("mark missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/library/suspect-sources?limit=50", nil)
|
||||||
|
req = withUser(req, admin)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleListSuspectSources(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp adminSuspectSourcesResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
// Three present files carry a marker. "Chain Reaction" and "Busted and
|
||||||
|
// Blue" carry none, and the lyric video's file is missing.
|
||||||
|
if resp.Total != 3 {
|
||||||
|
t.Errorf("total = %d, want 3", resp.Total)
|
||||||
|
}
|
||||||
|
if len(resp.Groups) != 1 {
|
||||||
|
t.Fatalf("groups = %d, want 1 (one folder)", len(resp.Groups))
|
||||||
|
}
|
||||||
|
got := map[string][]string{}
|
||||||
|
for _, tr := range resp.Groups[0].Tracks {
|
||||||
|
got[tr.Title] = tr.Markers
|
||||||
|
}
|
||||||
|
want := map[string][]string{
|
||||||
|
"Saturnz Barz (Official Video)": {"music video"},
|
||||||
|
"Momentz (Visualizer)": {"visualiser"},
|
||||||
|
"Andromeda [Audio]": {"[Audio]"},
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("tracks = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for title, labels := range want {
|
||||||
|
if !slices.Equal(got[title], labels) {
|
||||||
|
t.Errorf("%s: markers %v, want %v", title, got[title], labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -252,6 +252,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
// and because the destructive /tracks/{id} route above must
|
// and because the destructive /tracks/{id} route above must
|
||||||
// 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.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)
|
||||||
@@ -268,6 +270,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
// trigger to sweep now, dismissal, and the merge (#3911), which deletes
|
// trigger to sweep now, dismissal, and the merge (#3911), which deletes
|
||||||
// the removed copies' files after moving their history onto the kept one.
|
// the removed copies' files after moving their history onto the kept one.
|
||||||
admin.Get("/library/duplicates", h.handleListDuplicates)
|
admin.Get("/library/duplicates", h.handleListDuplicates)
|
||||||
|
admin.Get("/library/duplicates/resolved", h.handleListResolvedDuplicates)
|
||||||
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
|
admin.Post("/library/duplicates/sweep", h.handleRunDuplicateSweep)
|
||||||
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
|
admin.Post("/library/duplicates/{id}/dismiss", h.handleDismissDuplicateGroup)
|
||||||
admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup)
|
admin.Post("/library/duplicates/{id}/merge", h.handleMergeDuplicateGroup)
|
||||||
|
|||||||
+27
-7
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -50,20 +51,36 @@ func (h *handlers) handleLikeTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id})
|
liked, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: like track insert", "err", err)
|
h.logger.Error("api: like track insert", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("insert failed", err))
|
writeErr(w, apierror.InternalMsg("insert failed", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rows == 1 {
|
if slices.Contains(liked, id) {
|
||||||
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger)
|
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, id, h.logger)
|
||||||
}
|
}
|
||||||
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpUpsert)
|
// The like is on the song (M498 #5438): every copy it reached changes on
|
||||||
h.publishLikeEvent(user.ID, id, "track", true)
|
// the client too.
|
||||||
|
for _, t := range withTrack(id, liked) {
|
||||||
|
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, t, syncpkg.OpUpsert)
|
||||||
|
h.publishLikeEvent(user.ID, t, "track", true)
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withTrack is the tracks a like or unlike changed, led by the one asked for,
|
||||||
|
// which is reported even when it was already in that state.
|
||||||
|
func withTrack(id pgtype.UUID, changed []pgtype.UUID) []pgtype.UUID {
|
||||||
|
out := []pgtype.UUID{id}
|
||||||
|
for _, t := range changed {
|
||||||
|
if t != id {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := requireUser(w, r)
|
user, ok := requireUser(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -74,7 +91,8 @@ func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
if err := q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id}); err != nil {
|
unliked, err := q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: id})
|
||||||
|
if err != nil {
|
||||||
h.logger.Error("api: unlike track", "err", err)
|
h.logger.Error("api: unlike track", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("delete failed", err))
|
writeErr(w, apierror.InternalMsg("delete failed", err))
|
||||||
return
|
return
|
||||||
@@ -83,8 +101,10 @@ func (h *handlers) handleUnlikeTrack(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.logger.Error("api: soft-delete contextual_likes", "err", err)
|
h.logger.Error("api: soft-delete contextual_likes", "err", err)
|
||||||
// Don't fail the response — soft-delete is best-effort.
|
// Don't fail the response — soft-delete is best-effort.
|
||||||
}
|
}
|
||||||
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, id, syncpkg.OpDelete)
|
for _, t := range withTrack(id, unliked) {
|
||||||
h.publishLikeEvent(user.ID, id, "track", false)
|
h.logLikeChange(r, syncpkg.EntityLikeTrack, user.ID, t, syncpkg.OpDelete)
|
||||||
|
h.publishLikeEvent(user.ID, t, "track", false)
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ const (
|
|||||||
// log can answer "where did that file go" long after the report is gone.
|
// log can answer "where did that file go" long after the report is gone.
|
||||||
ActionDuplicateMerge Action = "duplicate_merge"
|
ActionDuplicateMerge Action = "duplicate_merge"
|
||||||
|
|
||||||
|
// Lidarr release change (M498 #5437): the duplicate resolver moved an
|
||||||
|
// album's monitored release in Lidarr to one that lists each song once.
|
||||||
|
// Written with no actor; the metadata names the album and both releases.
|
||||||
|
ActionLidarrReleaseChange Action = "lidarr_release_change"
|
||||||
|
|
||||||
|
// Lidarr rip search (M498 #5447): the resolver asked Lidarr to search an
|
||||||
|
// album whose only copy of a song is a video rip, for a better release.
|
||||||
|
// Written with no actor; the metadata names the album and how many rips.
|
||||||
|
ActionLidarrRipSearch Action = "lidarr_rip_search"
|
||||||
|
|
||||||
// Subsonic password (#5026): generated in Settings for t/s-only clients.
|
// Subsonic password (#5026): generated in Settings for t/s-only clients.
|
||||||
ActionSubsonicPasswordSet Action = "subsonic_password_set"
|
ActionSubsonicPasswordSet Action = "subsonic_password_set"
|
||||||
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
|
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
|
||||||
|
|||||||
@@ -11,6 +11,25 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const countAuditLogByActions = `-- name: CountAuditLogByActions :one
|
||||||
|
SELECT count(*)::bigint FROM audit_log
|
||||||
|
WHERE action = ANY($1::text[])
|
||||||
|
AND (NOT $2::boolean OR actor_id IS NULL)
|
||||||
|
`
|
||||||
|
|
||||||
|
type CountAuditLogByActionsParams struct {
|
||||||
|
Actions []string
|
||||||
|
SystemOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// System actions carry no actor; system_only narrows to them.
|
||||||
|
func (q *Queries) CountAuditLogByActions(ctx context.Context, arg CountAuditLogByActionsParams) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countAuditLogByActions, arg.Actions, arg.SystemOnly)
|
||||||
|
var column_1 int64
|
||||||
|
err := row.Scan(&column_1)
|
||||||
|
return column_1, err
|
||||||
|
}
|
||||||
|
|
||||||
const listAuditLog = `-- name: ListAuditLog :many
|
const listAuditLog = `-- name: ListAuditLog :many
|
||||||
SELECT id, actor_id, target_id, action, metadata, created_at FROM audit_log
|
SELECT id, actor_id, target_id, action, metadata, created_at FROM audit_log
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -49,6 +68,56 @@ func (q *Queries) ListAuditLog(ctx context.Context, arg ListAuditLogParams) ([]A
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listAuditLogByActions = `-- name: ListAuditLogByActions :many
|
||||||
|
SELECT id, actor_id, target_id, action, metadata, created_at FROM audit_log
|
||||||
|
WHERE action = ANY($1::text[])
|
||||||
|
AND (NOT $2::boolean OR actor_id IS NULL)
|
||||||
|
ORDER BY created_at DESC, id
|
||||||
|
LIMIT $4 OFFSET $3
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAuditLogByActionsParams struct {
|
||||||
|
Actions []string
|
||||||
|
SystemOnly bool
|
||||||
|
PageOffset int32
|
||||||
|
PageLimit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// One page of the given actions, newest first: the duplicates report's
|
||||||
|
// "resolved automatically" activity (M498) reads the resolver's merges and
|
||||||
|
// Lidarr release changes from here.
|
||||||
|
func (q *Queries) ListAuditLogByActions(ctx context.Context, arg ListAuditLogByActionsParams) ([]AuditLog, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAuditLogByActions,
|
||||||
|
arg.Actions,
|
||||||
|
arg.SystemOnly,
|
||||||
|
arg.PageOffset,
|
||||||
|
arg.PageLimit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []AuditLog
|
||||||
|
for rows.Next() {
|
||||||
|
var i AuditLog
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ActorID,
|
||||||
|
&i.TargetID,
|
||||||
|
&i.Action,
|
||||||
|
&i.Metadata,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const writeAuditLog = `-- name: WriteAuditLog :exec
|
const writeAuditLog = `-- name: WriteAuditLog :exec
|
||||||
INSERT INTO audit_log (actor_id, target_id, action, metadata)
|
INSERT INTO audit_log (actor_id, target_id, action, metadata)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
|
|||||||
@@ -62,6 +62,29 @@ func (q *Queries) CountPendingDuplicateGroups(ctx context.Context) (int64, error
|
|||||||
return column_1, err
|
return column_1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const countPendingDuplicateGroupsInView = `-- name: CountPendingDuplicateGroupsInView :one
|
||||||
|
SELECT count(*)::bigint
|
||||||
|
FROM duplicate_groups g
|
||||||
|
WHERE g.status = 'pending'
|
||||||
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
|
||||||
|
AND CASE $1::text
|
||||||
|
WHEN 'cross_release' THEN g.class = 'cross_release'
|
||||||
|
ELSE g.class IS DISTINCT FROM 'cross_release'
|
||||||
|
END
|
||||||
|
`
|
||||||
|
|
||||||
|
// CountPendingDuplicateGroups narrowed to one view of the report (M498):
|
||||||
|
//
|
||||||
|
// review what the operator still has to decide: every class but
|
||||||
|
// cross_release, and groups not yet classified
|
||||||
|
// cross_release the same song on different releases, which is kept, not merged
|
||||||
|
func (q *Queries) CountPendingDuplicateGroupsInView(ctx context.Context, view string) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countPendingDuplicateGroupsInView, view)
|
||||||
|
var column_1 int64
|
||||||
|
err := row.Scan(&column_1)
|
||||||
|
return column_1, err
|
||||||
|
}
|
||||||
|
|
||||||
const deleteStalePendingDuplicateGroups = `-- name: DeleteStalePendingDuplicateGroups :execrows
|
const deleteStalePendingDuplicateGroups = `-- name: DeleteStalePendingDuplicateGroups :execrows
|
||||||
DELETE FROM duplicate_groups g
|
DELETE FROM duplicate_groups g
|
||||||
WHERE g.status = 'pending'
|
WHERE g.status = 'pending'
|
||||||
@@ -187,6 +210,32 @@ func (q *Queries) GetLatestFingerprintComputedAt(ctx context.Context) (pgtype.Ti
|
|||||||
return latest, err
|
return latest, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listAlbumPresentTrackTitles = `-- name: ListAlbumPresentTrackTitles :many
|
||||||
|
SELECT title FROM tracks WHERE album_id = $1 AND missing_since IS NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
// The titles an album has on disk, for choosing the Lidarr release that covers
|
||||||
|
// them best (M498 #5437).
|
||||||
|
func (q *Queries) ListAlbumPresentTrackTitles(ctx context.Context, albumID pgtype.UUID) ([]string, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAlbumPresentTrackTitles, albumID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []string
|
||||||
|
for rows.Next() {
|
||||||
|
var title string
|
||||||
|
if err := rows.Scan(&title); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, title)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listDismissedDuplicateMemberSets = `-- name: ListDismissedDuplicateMemberSets :many
|
const listDismissedDuplicateMemberSets = `-- name: ListDismissedDuplicateMemberSets :many
|
||||||
SELECT g.id, array_agg(m.track_id ORDER BY m.track_id)::uuid[] AS track_ids
|
SELECT g.id, array_agg(m.track_id ORDER BY m.track_id)::uuid[] AS track_ids
|
||||||
FROM duplicate_groups g
|
FROM duplicate_groups g
|
||||||
@@ -288,6 +337,98 @@ func (q *Queries) ListDuplicateCandidates(ctx context.Context, arg ListDuplicate
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listDuplicateGroupsForResolve = `-- name: ListDuplicateGroupsForResolve :many
|
||||||
|
SELECT g.id AS group_id,
|
||||||
|
g.tier,
|
||||||
|
t.id AS track_id,
|
||||||
|
t.album_id,
|
||||||
|
t.title,
|
||||||
|
artists.name AS artist_name,
|
||||||
|
t.file_path,
|
||||||
|
t.file_format,
|
||||||
|
t.file_size,
|
||||||
|
t.added_at,
|
||||||
|
t.disc_number,
|
||||||
|
t.track_number,
|
||||||
|
(t.mbid IS NOT NULL)::boolean AS has_mbid,
|
||||||
|
albums.release_group_mbid,
|
||||||
|
albums.title AS album_title,
|
||||||
|
EXISTS (SELECT 1 FROM tracks o
|
||||||
|
WHERE o.album_id = t.album_id AND o.id <> t.id AND o.missing_since IS NULL
|
||||||
|
AND t.track_number IS NOT NULL
|
||||||
|
AND o.disc_number IS NOT DISTINCT FROM t.disc_number
|
||||||
|
AND o.track_number = t.track_number)::boolean AS position_clash
|
||||||
|
FROM duplicate_groups g
|
||||||
|
JOIN duplicate_group_members m ON m.group_id = g.id
|
||||||
|
JOIN tracks t ON t.id = m.track_id
|
||||||
|
JOIN albums ON albums.id = t.album_id
|
||||||
|
JOIN artists ON artists.id = t.artist_id
|
||||||
|
WHERE g.status = 'pending'
|
||||||
|
AND t.missing_since IS NULL
|
||||||
|
ORDER BY g.id, t.id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListDuplicateGroupsForResolveRow struct {
|
||||||
|
GroupID pgtype.UUID
|
||||||
|
Tier string
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
AlbumID pgtype.UUID
|
||||||
|
Title string
|
||||||
|
ArtistName string
|
||||||
|
FilePath string
|
||||||
|
FileFormat string
|
||||||
|
FileSize int64
|
||||||
|
AddedAt pgtype.Timestamptz
|
||||||
|
DiscNumber *int32
|
||||||
|
TrackNumber *int32
|
||||||
|
HasMbid bool
|
||||||
|
ReleaseGroupMbid *string
|
||||||
|
AlbumTitle string
|
||||||
|
PositionClash bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every pending group's present copies, for the resolver (M498 #5435): what it
|
||||||
|
// needs to classify the group and to choose the copy to keep. One row per copy,
|
||||||
|
// grouped by group. position_clash is true when another present track on the
|
||||||
|
// same album claims the same disc and track number: tags that do not fit the
|
||||||
|
// album, which the survivor rule weighs against a copy.
|
||||||
|
func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context) ([]ListDuplicateGroupsForResolveRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListDuplicateGroupsForResolveRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListDuplicateGroupsForResolveRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.GroupID,
|
||||||
|
&i.Tier,
|
||||||
|
&i.TrackID,
|
||||||
|
&i.AlbumID,
|
||||||
|
&i.Title,
|
||||||
|
&i.ArtistName,
|
||||||
|
&i.FilePath,
|
||||||
|
&i.FileFormat,
|
||||||
|
&i.FileSize,
|
||||||
|
&i.AddedAt,
|
||||||
|
&i.DiscNumber,
|
||||||
|
&i.TrackNumber,
|
||||||
|
&i.HasMbid,
|
||||||
|
&i.ReleaseGroupMbid,
|
||||||
|
&i.AlbumTitle,
|
||||||
|
&i.PositionClash,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listExactDuplicateHashes = `-- name: ListExactDuplicateHashes :many
|
const listExactDuplicateHashes = `-- name: ListExactDuplicateHashes :many
|
||||||
SELECT f.audio_stream_sha256,
|
SELECT f.audio_stream_sha256,
|
||||||
array_agg(t.id ORDER BY t.id)::uuid[] AS track_ids
|
array_agg(t.id ORDER BY t.id)::uuid[] AS track_ids
|
||||||
@@ -329,17 +470,32 @@ func (q *Queries) ListExactDuplicateHashes(ctx context.Context, currentVersion i
|
|||||||
|
|
||||||
const listPendingDuplicateGroupMembers = `-- name: ListPendingDuplicateGroupMembers :many
|
const listPendingDuplicateGroupMembers = `-- name: ListPendingDuplicateGroupMembers :many
|
||||||
WITH page AS (
|
WITH page AS (
|
||||||
SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at
|
SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at, g.class, g.resolve_note
|
||||||
FROM duplicate_groups g
|
FROM duplicate_groups g
|
||||||
WHERE g.status = 'pending'
|
WHERE g.status = 'pending'
|
||||||
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
|
||||||
|
AND CASE $1::text
|
||||||
|
WHEN 'cross_release' THEN g.class = 'cross_release'
|
||||||
|
ELSE g.class IS DISTINCT FROM 'cross_release'
|
||||||
|
END
|
||||||
ORDER BY g.detected_at DESC, g.id
|
ORDER BY g.detected_at DESC, g.id
|
||||||
LIMIT $2 OFFSET $1
|
LIMIT $3 OFFSET $2
|
||||||
)
|
)
|
||||||
SELECT p.id AS group_id,
|
SELECT p.id AS group_id,
|
||||||
p.tier,
|
p.tier,
|
||||||
p.worst_bit_error_rate,
|
p.worst_bit_error_rate,
|
||||||
p.detected_at,
|
p.detected_at,
|
||||||
|
p.class,
|
||||||
|
p.resolve_note,
|
||||||
|
m.lidarr_state,
|
||||||
|
t.disc_number,
|
||||||
|
t.track_number,
|
||||||
|
(t.mbid IS NOT NULL)::boolean AS has_mbid,
|
||||||
|
EXISTS (SELECT 1 FROM tracks o
|
||||||
|
WHERE o.album_id = t.album_id AND o.id <> t.id AND o.missing_since IS NULL
|
||||||
|
AND t.track_number IS NOT NULL
|
||||||
|
AND o.disc_number IS NOT DISTINCT FROM t.disc_number
|
||||||
|
AND o.track_number = t.track_number)::boolean AS position_clash,
|
||||||
t.id AS track_id,
|
t.id AS track_id,
|
||||||
t.title,
|
t.title,
|
||||||
artists.name AS artist_name,
|
artists.name AS artist_name,
|
||||||
@@ -361,6 +517,7 @@ SELECT p.id AS group_id,
|
|||||||
`
|
`
|
||||||
|
|
||||||
type ListPendingDuplicateGroupMembersParams struct {
|
type ListPendingDuplicateGroupMembersParams struct {
|
||||||
|
View string
|
||||||
PageOffset int32
|
PageOffset int32
|
||||||
PageLimit int32
|
PageLimit int32
|
||||||
}
|
}
|
||||||
@@ -370,6 +527,13 @@ type ListPendingDuplicateGroupMembersRow struct {
|
|||||||
Tier string
|
Tier string
|
||||||
WorstBitErrorRate *float32
|
WorstBitErrorRate *float32
|
||||||
DetectedAt pgtype.Timestamptz
|
DetectedAt pgtype.Timestamptz
|
||||||
|
Class *string
|
||||||
|
ResolveNote *string
|
||||||
|
LidarrState *string
|
||||||
|
DiscNumber *int32
|
||||||
|
TrackNumber *int32
|
||||||
|
HasMbid bool
|
||||||
|
PositionClash bool
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
Title string
|
Title string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
@@ -384,12 +548,14 @@ type ListPendingDuplicateGroupMembersRow struct {
|
|||||||
PlayCount int64
|
PlayCount int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// One page of proposals, newest first, flattened to one row per member so the
|
// One page of proposals in one view (see CountPendingDuplicateGroupsInView),
|
||||||
// handler folds them without a query per group. What each copy carries — likes
|
// newest first, flattened to one row per member so the handler folds them
|
||||||
// and plays from every user — is here because it is what the operator weighs
|
// without a query per group. What each copy carries — likes and plays from
|
||||||
// when deciding which copy to keep.
|
// every user — is here because it is what the operator weighs when deciding
|
||||||
|
// which copy to keep; Lidarr's view of it, because a copy Lidarr tracks cannot
|
||||||
|
// be removed without Lidarr downloading it again.
|
||||||
func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg ListPendingDuplicateGroupMembersParams) ([]ListPendingDuplicateGroupMembersRow, error) {
|
func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg ListPendingDuplicateGroupMembersParams) ([]ListPendingDuplicateGroupMembersRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listPendingDuplicateGroupMembers, arg.PageOffset, arg.PageLimit)
|
rows, err := q.db.Query(ctx, listPendingDuplicateGroupMembers, arg.View, arg.PageOffset, arg.PageLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -402,6 +568,13 @@ func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg List
|
|||||||
&i.Tier,
|
&i.Tier,
|
||||||
&i.WorstBitErrorRate,
|
&i.WorstBitErrorRate,
|
||||||
&i.DetectedAt,
|
&i.DetectedAt,
|
||||||
|
&i.Class,
|
||||||
|
&i.ResolveNote,
|
||||||
|
&i.LidarrState,
|
||||||
|
&i.DiscNumber,
|
||||||
|
&i.TrackNumber,
|
||||||
|
&i.HasMbid,
|
||||||
|
&i.PositionClash,
|
||||||
&i.TrackID,
|
&i.TrackID,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
@@ -425,6 +598,64 @@ func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg List
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markDuplicateGroupResolvedAutomatically = `-- name: MarkDuplicateGroupResolvedAutomatically :exec
|
||||||
|
UPDATE duplicate_groups SET resolved_automatically = true WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) MarkDuplicateGroupResolvedAutomatically(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, markDuplicateGroupResolvedAutomatically, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDuplicateGroupClasses = `-- name: SetDuplicateGroupClasses :exec
|
||||||
|
UPDATE duplicate_groups g
|
||||||
|
SET class = v.class, resolve_note = NULLIF(v.note, '')
|
||||||
|
FROM (SELECT unnest($1::uuid[]) AS id,
|
||||||
|
unnest($2::text[]) AS class,
|
||||||
|
unnest($3::text[]) AS note) v
|
||||||
|
WHERE g.id = v.id
|
||||||
|
AND g.status = 'pending'
|
||||||
|
AND (g.class IS DISTINCT FROM v.class OR g.resolve_note IS DISTINCT FROM NULLIF(v.note, ''))
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetDuplicateGroupClassesParams struct {
|
||||||
|
GroupIds []pgtype.UUID
|
||||||
|
Classes []string
|
||||||
|
Notes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resolver's verdicts, written in one statement. Only rows whose class or
|
||||||
|
// note actually changed are touched, so an hourly pass over thousands of
|
||||||
|
// unchanged groups writes nothing. An empty note clears it.
|
||||||
|
func (q *Queries) SetDuplicateGroupClasses(ctx context.Context, arg SetDuplicateGroupClassesParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, setDuplicateGroupClasses, arg.GroupIds, arg.Classes, arg.Notes)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDuplicateMemberLidarrStates = `-- name: SetDuplicateMemberLidarrStates :exec
|
||||||
|
UPDATE duplicate_group_members m
|
||||||
|
SET lidarr_state = NULLIF(v.state, '')
|
||||||
|
FROM (SELECT unnest($1::uuid[]) AS group_id,
|
||||||
|
unnest($2::uuid[]) AS track_id,
|
||||||
|
unnest($3::text[]) AS state) v
|
||||||
|
WHERE m.group_id = v.group_id
|
||||||
|
AND m.track_id = v.track_id
|
||||||
|
AND m.lidarr_state IS DISTINCT FROM NULLIF(v.state, '')
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetDuplicateMemberLidarrStatesParams struct {
|
||||||
|
GroupIds []pgtype.UUID
|
||||||
|
TrackIds []pgtype.UUID
|
||||||
|
States []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lidarr's view of each copy as of this pass; an empty state clears it (Lidarr
|
||||||
|
// not consulted). Unchanged rows are left alone.
|
||||||
|
func (q *Queries) SetDuplicateMemberLidarrStates(ctx context.Context, arg SetDuplicateMemberLidarrStatesParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, setDuplicateMemberLidarrStates, arg.GroupIds, arg.TrackIds, arg.States)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const startDuplicateSweep = `-- name: StartDuplicateSweep :one
|
const startDuplicateSweep = `-- name: StartDuplicateSweep :one
|
||||||
INSERT INTO duplicate_sweeps DEFAULT VALUES RETURNING id, started_at
|
INSERT INTO duplicate_sweeps DEFAULT VALUES RETURNING id, started_at
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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 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
|
||||||
@@ -308,6 +308,10 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
|
|||||||
&i.TagReadVersion,
|
&i.TagReadVersion,
|
||||||
&i.MissingSince,
|
&i.MissingSince,
|
||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
|
&i.SongID,
|
||||||
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const getFingerprintSettings = `-- name: GetFingerprintSettings :one
|
const getFingerprintSettings = `-- name: GetFingerprintSettings :one
|
||||||
SELECT id, enabled, chromaprint_length_sec, acoustic_max_bit_error_rate, backfill_concurrency, sweep_interval_hours, updated_at FROM fingerprint_settings WHERE id = true
|
SELECT id, enabled, chromaprint_length_sec, acoustic_max_bit_error_rate, backfill_concurrency, sweep_interval_hours, updated_at, auto_resolve FROM fingerprint_settings WHERE id = true
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetFingerprintSettings(ctx context.Context) (FingerprintSetting, error) {
|
func (q *Queries) GetFingerprintSettings(ctx context.Context) (FingerprintSetting, error) {
|
||||||
@@ -24,6 +24,7 @@ func (q *Queries) GetFingerprintSettings(ctx context.Context) (FingerprintSettin
|
|||||||
&i.BackfillConcurrency,
|
&i.BackfillConcurrency,
|
||||||
&i.SweepIntervalHours,
|
&i.SweepIntervalHours,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.AutoResolve,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -35,9 +36,10 @@ UPDATE fingerprint_settings
|
|||||||
acoustic_max_bit_error_rate = $3,
|
acoustic_max_bit_error_rate = $3,
|
||||||
backfill_concurrency = $4,
|
backfill_concurrency = $4,
|
||||||
sweep_interval_hours = $5,
|
sweep_interval_hours = $5,
|
||||||
|
auto_resolve = $6,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = true
|
WHERE id = true
|
||||||
RETURNING id, enabled, chromaprint_length_sec, acoustic_max_bit_error_rate, backfill_concurrency, sweep_interval_hours, updated_at
|
RETURNING id, enabled, chromaprint_length_sec, acoustic_max_bit_error_rate, backfill_concurrency, sweep_interval_hours, updated_at, auto_resolve
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateFingerprintSettingsParams struct {
|
type UpdateFingerprintSettingsParams struct {
|
||||||
@@ -46,6 +48,7 @@ type UpdateFingerprintSettingsParams struct {
|
|||||||
AcousticMaxBitErrorRate float64
|
AcousticMaxBitErrorRate float64
|
||||||
BackfillConcurrency int32
|
BackfillConcurrency int32
|
||||||
SweepIntervalHours int32
|
SweepIntervalHours int32
|
||||||
|
AutoResolve bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whole-row write from the admin card; migration 0061's CHECKs are the backstop
|
// Whole-row write from the admin card; migration 0061's CHECKs are the backstop
|
||||||
@@ -57,6 +60,7 @@ func (q *Queries) UpdateFingerprintSettings(ctx context.Context, arg UpdateFinge
|
|||||||
arg.AcousticMaxBitErrorRate,
|
arg.AcousticMaxBitErrorRate,
|
||||||
arg.BackfillConcurrency,
|
arg.BackfillConcurrency,
|
||||||
arg.SweepIntervalHours,
|
arg.SweepIntervalHours,
|
||||||
|
arg.AutoResolve,
|
||||||
)
|
)
|
||||||
var i FingerprintSetting
|
var i FingerprintSetting
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -67,6 +71,7 @@ func (q *Queries) UpdateFingerprintSettings(ctx context.Context, arg UpdateFinge
|
|||||||
&i.BackfillConcurrency,
|
&i.BackfillConcurrency,
|
||||||
&i.SweepIntervalHours,
|
&i.SweepIntervalHours,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.AutoResolve,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, 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.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
|
||||||
@@ -82,6 +82,10 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
|
|||||||
&i.Track.TagReadVersion,
|
&i.Track.TagReadVersion,
|
||||||
&i.Track.MissingSince,
|
&i.Track.MissingSince,
|
||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
|
&i.Track.SongID,
|
||||||
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -34,9 +34,12 @@ func (q *Queries) CountLikedArtists(ctx context.Context, userID pgtype.UUID) (in
|
|||||||
}
|
}
|
||||||
|
|
||||||
const countLikedTracks = `-- name: CountLikedTracks :one
|
const countLikedTracks = `-- name: CountLikedTracks :one
|
||||||
SELECT count(*) FROM general_likes WHERE user_id = $1
|
SELECT count(DISTINCT t.song_key) FROM general_likes l
|
||||||
|
JOIN tracks t ON t.id = l.track_id
|
||||||
|
WHERE l.user_id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Liked songs, each counted once however many releases hold it.
|
||||||
func (q *Queries) CountLikedTracks(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
func (q *Queries) CountLikedTracks(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
||||||
row := q.db.QueryRow(ctx, countLikedTracks, userID)
|
row := q.db.QueryRow(ctx, countLikedTracks, userID)
|
||||||
var count int64
|
var count int64
|
||||||
@@ -76,10 +79,13 @@ func (q *Queries) LikeArtist(ctx context.Context, arg LikeArtistParams) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const likeTrack = `-- name: LikeTrack :execrows
|
const likeTrack = `-- name: LikeTrack :many
|
||||||
INSERT INTO general_likes (user_id, track_id)
|
INSERT INTO general_likes (user_id, track_id)
|
||||||
VALUES ($1, $2)
|
SELECT $1::uuid, t.id
|
||||||
|
FROM tracks t
|
||||||
|
WHERE t.song_key = (SELECT s.song_key FROM tracks s WHERE s.id = $2::uuid)
|
||||||
ON CONFLICT (user_id, track_id) DO NOTHING
|
ON CONFLICT (user_id, track_id) DO NOTHING
|
||||||
|
RETURNING track_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type LikeTrackParams struct {
|
type LikeTrackParams struct {
|
||||||
@@ -87,12 +93,27 @@ type LikeTrackParams struct {
|
|||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) (int64, error) {
|
// A like is on the song (M498 #5438): every copy of it sharing the track's
|
||||||
result, err := q.db.Exec(ctx, likeTrack, arg.UserID, arg.TrackID)
|
// song_key is liked. Returns the tracks newly liked, for the sync log; empty
|
||||||
|
// when the song was already liked.
|
||||||
|
func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) ([]pgtype.UUID, error) {
|
||||||
|
rows, err := q.db.Query(ctx, likeTrack, arg.UserID, arg.TrackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return result.RowsAffected(), nil
|
defer rows.Close()
|
||||||
|
var items []pgtype.UUID
|
||||||
|
for rows.Next() {
|
||||||
|
var track_id pgtype.UUID
|
||||||
|
if err := rows.Scan(&track_id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, track_id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many
|
const listLikedAlbumIDs = `-- name: ListLikedAlbumIDs :many
|
||||||
@@ -260,10 +281,15 @@ 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 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 general_likes l ON l.track_id = t.id
|
JOIN (SELECT DISTINCT ON (lt.song_key) l.track_id, l.liked_at
|
||||||
WHERE l.user_id = $1
|
FROM general_likes l
|
||||||
ORDER BY l.liked_at DESC
|
JOIN tracks lt ON lt.id = l.track_id
|
||||||
|
WHERE l.user_id = $1
|
||||||
|
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
|
||||||
|
ORDER BY l.liked_at DESC, t.id
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -273,6 +299,8 @@ type ListLikedTrackRowsParams struct {
|
|||||||
Offset int32
|
Offset int32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One row per liked song: of its copies, the one present on disk, then one
|
||||||
|
// 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 {
|
||||||
@@ -303,6 +331,10 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
|
|||||||
&i.TagReadVersion,
|
&i.TagReadVersion,
|
||||||
&i.MissingSince,
|
&i.MissingSince,
|
||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
|
&i.SongID,
|
||||||
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -342,8 +374,13 @@ func (q *Queries) UnlikeArtist(ctx context.Context, arg UnlikeArtistParams) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
const unlikeTrack = `-- name: UnlikeTrack :exec
|
const unlikeTrack = `-- name: UnlikeTrack :many
|
||||||
DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2
|
DELETE FROM general_likes
|
||||||
|
WHERE user_id = $1::uuid
|
||||||
|
AND (track_id = $2::uuid
|
||||||
|
OR track_id IN (SELECT t.id FROM tracks t
|
||||||
|
WHERE t.song_key = (SELECT s.song_key FROM tracks s WHERE s.id = $2::uuid)))
|
||||||
|
RETURNING track_id
|
||||||
`
|
`
|
||||||
|
|
||||||
type UnlikeTrackParams struct {
|
type UnlikeTrackParams struct {
|
||||||
@@ -351,7 +388,24 @@ type UnlikeTrackParams struct {
|
|||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UnlikeTrack(ctx context.Context, arg UnlikeTrackParams) error {
|
// Unlikes the song: every copy sharing the track's song_key. Returns the
|
||||||
_, err := q.db.Exec(ctx, unlikeTrack, arg.UserID, arg.TrackID)
|
// tracks unliked, for the sync log.
|
||||||
return err
|
func (q *Queries) UnlikeTrack(ctx context.Context, arg UnlikeTrackParams) ([]pgtype.UUID, error) {
|
||||||
|
rows, err := q.db.Query(ctx, unlikeTrack, arg.UserID, arg.TrackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []pgtype.UUID
|
||||||
|
for rows.Next() {
|
||||||
|
var track_id pgtype.UUID
|
||||||
|
if err := rows.Scan(&track_id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, track_id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-10
@@ -323,19 +323,23 @@ type DiscoverTuning struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DuplicateGroup struct {
|
type DuplicateGroup struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
MemberKey string
|
MemberKey string
|
||||||
Tier string
|
Tier string
|
||||||
WorstBitErrorRate *float32
|
WorstBitErrorRate *float32
|
||||||
Status string
|
Status string
|
||||||
DetectedAt pgtype.Timestamptz
|
DetectedAt pgtype.Timestamptz
|
||||||
LastSeenSweepID pgtype.UUID
|
LastSeenSweepID pgtype.UUID
|
||||||
ResolvedAt pgtype.Timestamptz
|
ResolvedAt pgtype.Timestamptz
|
||||||
|
Class *string
|
||||||
|
ResolveNote *string
|
||||||
|
ResolvedAutomatically bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type DuplicateGroupMember struct {
|
type DuplicateGroupMember struct {
|
||||||
GroupID pgtype.UUID
|
GroupID pgtype.UUID
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
|
LidarrState *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type DuplicateSweep struct {
|
type DuplicateSweep struct {
|
||||||
@@ -356,6 +360,7 @@ type FingerprintSetting struct {
|
|||||||
BackfillConcurrency int32
|
BackfillConcurrency int32
|
||||||
SweepIntervalHours int32
|
SweepIntervalHours int32
|
||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
AutoResolve bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeneralLike struct {
|
type GeneralLike struct {
|
||||||
@@ -749,6 +754,10 @@ type Track struct {
|
|||||||
TagReadVersion int16
|
TagReadVersion int16
|
||||||
MissingSince pgtype.Timestamptz
|
MissingSince pgtype.Timestamptz
|
||||||
MbidSource *string
|
MbidSource *string
|
||||||
|
SongID 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,
|
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
|
||||||
@@ -271,6 +271,10 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
|
|||||||
&i.Track.TagReadVersion,
|
&i.Track.TagReadVersion,
|
||||||
&i.Track.MissingSince,
|
&i.Track.MissingSince,
|
||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
|
&i.Track.SongID,
|
||||||
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -291,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,
|
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
|
||||||
@@ -356,6 +360,10 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
|
|||||||
&i.Track.TagReadVersion,
|
&i.Track.TagReadVersion,
|
||||||
&i.Track.MissingSince,
|
&i.Track.MissingSince,
|
||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
|
&i.Track.SongID,
|
||||||
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -695,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.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,
|
||||||
@@ -777,6 +785,10 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
|
|||||||
&i.Track.TagReadVersion,
|
&i.Track.TagReadVersion,
|
||||||
&i.Track.MissingSince,
|
&i.Track.MissingSince,
|
||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
|
&i.Track.SongID,
|
||||||
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
@@ -925,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.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,
|
||||||
@@ -1053,6 +1065,10 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
&i.Track.TagReadVersion,
|
&i.Track.TagReadVersion,
|
||||||
&i.Track.MissingSince,
|
&i.Track.MissingSince,
|
||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
|
&i.Track.SongID,
|
||||||
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: songs.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const linkTracksAsSong = `-- name: LinkTracksAsSong :one
|
||||||
|
|
||||||
|
WITH keys AS (
|
||||||
|
SELECT DISTINCT song_key FROM tracks WHERE id = ANY($1::uuid[])
|
||||||
|
), target AS (
|
||||||
|
SELECT song_key FROM keys ORDER BY song_key LIMIT 1
|
||||||
|
), moved AS (
|
||||||
|
UPDATE tracks t
|
||||||
|
SET song_id = (SELECT song_key FROM target)
|
||||||
|
WHERE t.song_key IN (SELECT song_key FROM keys)
|
||||||
|
AND t.song_key <> (SELECT song_key FROM target)
|
||||||
|
RETURNING t.id
|
||||||
|
)
|
||||||
|
SELECT (SELECT song_key FROM target)::uuid AS song_key,
|
||||||
|
(SELECT count(*) FROM moved)::bigint AS moved
|
||||||
|
`
|
||||||
|
|
||||||
|
type LinkTracksAsSongRow struct {
|
||||||
|
SongKey pgtype.UUID
|
||||||
|
Moved int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Song links (M498 #5438): copies of one song on different releases share a
|
||||||
|
// song_key (migration 0076).
|
||||||
|
// Links the tracks into one song, joining any songs they already belong to.
|
||||||
|
// The song keeps the lowest key among them, so linking the same set again
|
||||||
|
// changes nothing. Returns that key and how many tracks moved to it.
|
||||||
|
func (q *Queries) LinkTracksAsSong(ctx context.Context, trackIds []pgtype.UUID) (LinkTracksAsSongRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, linkTracksAsSong, trackIds)
|
||||||
|
var i LinkTracksAsSongRow
|
||||||
|
err := row.Scan(&i.SongKey, &i.Moved)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTrackSongKeys = `-- name: ListTrackSongKeys :many
|
||||||
|
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 {
|
||||||
|
ID 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) {
|
||||||
|
rows, err := q.db.Query(ctx, listTrackSongKeys, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListTrackSongKeysRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListTrackSongKeysRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.SongKey, &i.HeldBack); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeInheritSongLink = `-- name: MergeInheritSongLink :exec
|
||||||
|
UPDATE tracks s
|
||||||
|
SET song_id = l.song_key
|
||||||
|
FROM tracks l
|
||||||
|
WHERE s.id = $1::uuid
|
||||||
|
AND l.id = $2::uuid
|
||||||
|
AND s.song_id IS NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM tracks o
|
||||||
|
WHERE o.song_key = l.song_key AND o.id <> l.id AND o.id <> s.id)
|
||||||
|
`
|
||||||
|
|
||||||
|
type MergeInheritSongLinkParams struct {
|
||||||
|
SurvivorID pgtype.UUID
|
||||||
|
LoserID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// A merge keeps the removed copy's song link: when the copy being removed was
|
||||||
|
// linked to copies on other releases and the survivor was not, the survivor
|
||||||
|
// joins that song. The copy whose id names the song has no song_id of its
|
||||||
|
// own, so "linked" is another copy sharing its key, not a song_id.
|
||||||
|
func (q *Queries) MergeInheritSongLink(ctx context.Context, arg MergeInheritSongLinkParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, mergeInheritSongLink, arg.SurvivorID, arg.LoserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareSongLikes = `-- name: ShareSongLikes :many
|
||||||
|
INSERT INTO general_likes (user_id, track_id, liked_at)
|
||||||
|
SELECT l.user_id, t.id, min(l.liked_at)
|
||||||
|
FROM general_likes l
|
||||||
|
JOIN tracks lt ON lt.id = l.track_id
|
||||||
|
JOIN tracks t ON t.song_key = lt.song_key
|
||||||
|
WHERE lt.song_key = $1::uuid
|
||||||
|
GROUP BY l.user_id, t.id
|
||||||
|
ON CONFLICT (user_id, track_id) DO NOTHING
|
||||||
|
RETURNING user_id, track_id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ShareSongLikesRow struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// A like on any copy of the song becomes a like on every copy, dated the
|
||||||
|
// earliest. Returns the likes added, for the sync log.
|
||||||
|
func (q *Queries) ShareSongLikes(ctx context.Context, songKey pgtype.UUID) ([]ShareSongLikesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, shareSongLikes, songKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ShareSongLikesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ShareSongLikesRow
|
||||||
|
if err := rows.Scan(&i.UserID, &i.TrackID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlinkDuplicateGroupSongs = `-- name: UnlinkDuplicateGroupSongs :exec
|
||||||
|
UPDATE tracks SET song_id = NULL
|
||||||
|
WHERE song_id IS NOT NULL
|
||||||
|
AND id IN (SELECT track_id FROM duplicate_group_members WHERE group_id = $1)
|
||||||
|
`
|
||||||
|
|
||||||
|
// "Not the same song": the group's copies stop sharing a song. Likes already
|
||||||
|
// shared stay where they are.
|
||||||
|
func (q *Queries) UnlinkDuplicateGroupSongs(ctx context.Context, groupID pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, unlinkDuplicateGroupSongs, groupID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -70,6 +87,20 @@ func (q *Queries) CountMissingTracks(ctx context.Context) (int64, error) {
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const countSuspectSourceTracks = `-- name: CountSuspectSourceTracks :one
|
||||||
|
SELECT COUNT(*) FROM tracks t
|
||||||
|
WHERE t.missing_since IS NULL
|
||||||
|
AND regexp_replace(t.file_path, '^.*/', '') ~* $1::text
|
||||||
|
`
|
||||||
|
|
||||||
|
// Total for the report's paging; the same filter as the list above.
|
||||||
|
func (q *Queries) CountSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countSuspectSourceTracks, pattern)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
const countTracksByAlbum = `-- name: CountTracksByAlbum :one
|
const countTracksByAlbum = `-- name: CountTracksByAlbum :one
|
||||||
SELECT count(*) FROM tracks WHERE album_id = $1
|
SELECT count(*) FROM tracks WHERE album_id = $1
|
||||||
`
|
`
|
||||||
@@ -232,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 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) {
|
||||||
@@ -260,12 +310,16 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
|
|||||||
&i.TagReadVersion,
|
&i.TagReadVersion,
|
||||||
&i.MissingSince,
|
&i.MissingSince,
|
||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
|
&i.SongID,
|
||||||
|
&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 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) {
|
||||||
@@ -292,12 +346,16 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
|
|||||||
&i.TagReadVersion,
|
&i.TagReadVersion,
|
||||||
&i.MissingSince,
|
&i.MissingSince,
|
||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
|
&i.SongID,
|
||||||
|
&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 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
|
||||||
@@ -332,6 +390,82 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
|||||||
&i.TagReadVersion,
|
&i.TagReadVersion,
|
||||||
&i.MissingSince,
|
&i.MissingSince,
|
||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
|
&i.SongID,
|
||||||
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAlbumsWithSoleCopyRips = `-- name: ListAlbumsWithSoleCopyRips :many
|
||||||
|
SELECT a.id AS album_id,
|
||||||
|
a.release_group_mbid::text AS release_group_mbid,
|
||||||
|
a.title AS album_title,
|
||||||
|
ar.name AS artist_name,
|
||||||
|
count(*)::bigint AS rips
|
||||||
|
FROM tracks t
|
||||||
|
JOIN albums a ON a.id = t.album_id
|
||||||
|
JOIN artists ar ON ar.id = a.artist_id
|
||||||
|
WHERE t.source_verdict = 'suspect'
|
||||||
|
AND t.missing_since IS NULL
|
||||||
|
AND a.release_group_mbid IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM tracks c
|
||||||
|
WHERE c.song_key = t.song_key AND c.id <> t.id
|
||||||
|
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM duplicate_group_members m
|
||||||
|
JOIN duplicate_groups g ON g.id = m.group_id AND g.status = 'pending'
|
||||||
|
JOIN duplicate_group_members m2 ON m2.group_id = g.id AND m2.track_id <> t.id
|
||||||
|
JOIN tracks c ON c.id = m2.track_id
|
||||||
|
WHERE m.track_id = t.id
|
||||||
|
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM audit_log l
|
||||||
|
WHERE l.action = 'lidarr_rip_search'
|
||||||
|
AND l.metadata->>'album_id' = a.id::text
|
||||||
|
AND l.created_at > now() - interval '7 days')
|
||||||
|
GROUP BY a.id, a.release_group_mbid, a.title, ar.name
|
||||||
|
ORDER BY count(*) DESC, a.id
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAlbumsWithSoleCopyRipsRow struct {
|
||||||
|
AlbumID pgtype.UUID
|
||||||
|
ReleaseGroupMbid string
|
||||||
|
AlbumTitle string
|
||||||
|
ArtistName string
|
||||||
|
Rips int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Albums holding a held-back video rip that is the only copy of its song
|
||||||
|
// (#5447): no present copy that is not a rip shares its song, and no pending
|
||||||
|
// duplicate group pairs it with one. Lidarr counts such a track fulfilled, so
|
||||||
|
// the resolver asks it to search the album for a better release. Albums
|
||||||
|
// searched within the last week are left out, most rips first.
|
||||||
|
func (q *Queries) ListAlbumsWithSoleCopyRips(ctx context.Context, pageLimit int32) ([]ListAlbumsWithSoleCopyRipsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAlbumsWithSoleCopyRips, pageLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListAlbumsWithSoleCopyRipsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListAlbumsWithSoleCopyRipsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.AlbumID,
|
||||||
|
&i.ReleaseGroupMbid,
|
||||||
|
&i.AlbumTitle,
|
||||||
|
&i.ArtistName,
|
||||||
|
&i.Rips,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -344,7 +478,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,
|
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
|
||||||
@@ -405,6 +539,10 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
|||||||
&i.Track.TagReadVersion,
|
&i.Track.TagReadVersion,
|
||||||
&i.Track.MissingSince,
|
&i.Track.MissingSince,
|
||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
|
&i.Track.SongID,
|
||||||
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -505,7 +643,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,
|
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
|
||||||
@@ -563,6 +701,10 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
|||||||
&i.Track.TagReadVersion,
|
&i.Track.TagReadVersion,
|
||||||
&i.Track.MissingSince,
|
&i.Track.MissingSince,
|
||||||
&i.Track.MbidSource,
|
&i.Track.MbidSource,
|
||||||
|
&i.Track.SongID,
|
||||||
|
&i.Track.SongKey,
|
||||||
|
&i.Track.SourceVerdict,
|
||||||
|
&i.Track.SourceVerdictAt,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -576,6 +718,104 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listSuspectSourceTracks = `-- name: ListSuspectSourceTracks :many
|
||||||
|
SELECT t.id,
|
||||||
|
t.title,
|
||||||
|
t.file_path,
|
||||||
|
regexp_replace(t.file_path, '/[^/]*$', '') AS directory,
|
||||||
|
t.duration_ms,
|
||||||
|
t.disc_number,
|
||||||
|
t.track_number,
|
||||||
|
albums.id AS album_id,
|
||||||
|
albums.title AS album_title,
|
||||||
|
artists.id AS artist_id,
|
||||||
|
artists.name AS artist_name,
|
||||||
|
t.source_verdict,
|
||||||
|
t.source_verdict_at,
|
||||||
|
searched.at AS searched_at
|
||||||
|
FROM tracks t
|
||||||
|
JOIN albums ON albums.id = t.album_id
|
||||||
|
JOIN artists ON artists.id = t.artist_id
|
||||||
|
-- When the resolver last asked Lidarr to search the album for a better copy
|
||||||
|
-- (#5447).
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT max(l.created_at)::timestamptz AS at FROM audit_log l
|
||||||
|
WHERE l.action = 'lidarr_rip_search' AND l.metadata->>'album_id' = albums.id::text
|
||||||
|
) searched ON true
|
||||||
|
WHERE t.missing_since IS NULL
|
||||||
|
AND regexp_replace(t.file_path, '^.*/', '') ~* $1::text
|
||||||
|
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
|
||||||
|
LIMIT $3 OFFSET $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListSuspectSourceTracksParams struct {
|
||||||
|
Pattern string
|
||||||
|
PageOffset int32
|
||||||
|
PageLimit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListSuspectSourceTracksRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
Title string
|
||||||
|
FilePath string
|
||||||
|
Directory string
|
||||||
|
DurationMs int32
|
||||||
|
DiscNumber *int32
|
||||||
|
TrackNumber *int32
|
||||||
|
AlbumID pgtype.UUID
|
||||||
|
AlbumTitle string
|
||||||
|
ArtistID pgtype.UUID
|
||||||
|
ArtistName string
|
||||||
|
SourceVerdict *string
|
||||||
|
SourceVerdictAt pgtype.Timestamptz
|
||||||
|
SearchedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// The admin report of present tracks whose filename looks like a video rip
|
||||||
|
// (#5410): "(Official Video)", "[Audio]", "Visualizer" and the like.
|
||||||
|
//
|
||||||
|
// pattern is built in Go (internal/api/admin_suspect_sources.go) from the
|
||||||
|
// same marker list that labels each row, so the filter and the labels cannot
|
||||||
|
// drift. It is matched against the basename only: a folder called "Reaction
|
||||||
|
// Sessions" says nothing about how a file was sourced.
|
||||||
|
//
|
||||||
|
// Ordered by directory, like ListMissingTracks, so the handler's fold gets
|
||||||
|
// each folder as one contiguous run.
|
||||||
|
func (q *Queries) ListSuspectSourceTracks(ctx context.Context, arg ListSuspectSourceTracksParams) ([]ListSuspectSourceTracksRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listSuspectSourceTracks, arg.Pattern, arg.PageOffset, arg.PageLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListSuspectSourceTracksRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListSuspectSourceTracksRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.FilePath,
|
||||||
|
&i.Directory,
|
||||||
|
&i.DurationMs,
|
||||||
|
&i.DiscNumber,
|
||||||
|
&i.TrackNumber,
|
||||||
|
&i.AlbumID,
|
||||||
|
&i.AlbumTitle,
|
||||||
|
&i.ArtistID,
|
||||||
|
&i.ArtistName,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
|
&i.SearchedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listTrackPathsForReconcile = `-- name: ListTrackPathsForReconcile :many
|
const listTrackPathsForReconcile = `-- name: ListTrackPathsForReconcile :many
|
||||||
SELECT id, file_path, missing_since FROM tracks
|
SELECT id, file_path, missing_since FROM tracks
|
||||||
`
|
`
|
||||||
@@ -612,7 +852,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 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
|
||||||
@@ -659,6 +899,10 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
|
|||||||
&i.TagReadVersion,
|
&i.TagReadVersion,
|
||||||
&i.MissingSince,
|
&i.MissingSince,
|
||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
|
&i.SongID,
|
||||||
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -732,7 +976,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 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
|
||||||
@@ -786,6 +1030,10 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
|
|||||||
&i.TagReadVersion,
|
&i.TagReadVersion,
|
||||||
&i.MissingSince,
|
&i.MissingSince,
|
||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
|
&i.SongID,
|
||||||
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -815,6 +1063,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,
|
||||||
@@ -842,7 +1112,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
|
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 {
|
||||||
@@ -902,6 +1172,10 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
|
|||||||
&i.TagReadVersion,
|
&i.TagReadVersion,
|
||||||
&i.MissingSince,
|
&i.MissingSince,
|
||||||
&i.MbidSource,
|
&i.MbidSource,
|
||||||
|
&i.SongID,
|
||||||
|
&i.SongKey,
|
||||||
|
&i.SourceVerdict,
|
||||||
|
&i.SourceVerdictAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
DELETE FROM user_notifications WHERE kind = 'duplicates_resolved';
|
||||||
|
DELETE FROM user_notification_prefs WHERE kind = 'duplicates_resolved';
|
||||||
|
ALTER TABLE user_notification_prefs DROP CONSTRAINT user_notification_prefs_kind_check;
|
||||||
|
ALTER TABLE user_notification_prefs ADD CONSTRAINT user_notification_prefs_kind_check
|
||||||
|
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
|
||||||
|
'request_pending', 'quarantine_flagged', 'scan_failed',
|
||||||
|
'tracks_missing', 'duplicates_found', 'playback_errors'));
|
||||||
|
ALTER TABLE user_notifications DROP CONSTRAINT user_notifications_kind_check;
|
||||||
|
ALTER TABLE user_notifications ADD CONSTRAINT user_notifications_kind_check
|
||||||
|
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
|
||||||
|
'request_pending', 'quarantine_flagged', 'scan_failed',
|
||||||
|
'tracks_missing', 'duplicates_found', 'playback_errors'));
|
||||||
|
ALTER TABLE fingerprint_settings DROP COLUMN IF EXISTS auto_resolve;
|
||||||
|
ALTER TABLE duplicate_group_members DROP COLUMN IF EXISTS lidarr_state;
|
||||||
|
ALTER TABLE duplicate_groups DROP COLUMN IF EXISTS resolved_automatically;
|
||||||
|
ALTER TABLE duplicate_groups DROP COLUMN IF EXISTS resolve_note;
|
||||||
|
ALTER TABLE duplicate_groups DROP COLUMN IF EXISTS class;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- 0075_duplicate_resolve.up.sql — duplicates resolve themselves (Scribe
|
||||||
|
-- milestone #498: #5435, #5436, #5437).
|
||||||
|
--
|
||||||
|
-- The sweep (0059) proposes groups and, until now, every proposal waited for the
|
||||||
|
-- operator. The resolver classifies each pending group and acts on the classes
|
||||||
|
-- that are safe. What makes deletion safe is Lidarr's view of the file: Lidarr
|
||||||
|
-- maps one file to each track of the release it monitors, so deleting a mapped
|
||||||
|
-- file opens a hole it fills by downloading again. Only a copy Lidarr does not
|
||||||
|
-- map may go.
|
||||||
|
|
||||||
|
-- What kind of duplicate a group is. NULL until the resolver has classified it.
|
||||||
|
-- same_release every copy is on one album
|
||||||
|
-- cross_release the same song on different releases (a single and its album)
|
||||||
|
-- mismatch identical audio under different titles on different albums:
|
||||||
|
-- a file imported as the wrong song
|
||||||
|
-- review anything else: the operator decides
|
||||||
|
-- Rule 36: a new value swaps this constraint in the same migration.
|
||||||
|
ALTER TABLE duplicate_groups ADD COLUMN class text;
|
||||||
|
ALTER TABLE duplicate_groups ADD CONSTRAINT duplicate_groups_class_check
|
||||||
|
CHECK (class IN ('same_release', 'cross_release', 'mismatch', 'review'));
|
||||||
|
|
||||||
|
-- Why the resolver left a group for the operator, in words the report shows
|
||||||
|
-- ("Lidarr tracks every copy as its own track"). NULL when it had nothing to say.
|
||||||
|
ALTER TABLE duplicate_groups ADD COLUMN resolve_note text;
|
||||||
|
|
||||||
|
-- True when the resolver merged the group itself, so the report can tell its
|
||||||
|
-- merges from the operator's.
|
||||||
|
ALTER TABLE duplicate_groups ADD COLUMN resolved_automatically boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Lidarr's view of each copy, as of the resolver's last pass:
|
||||||
|
-- tracked Lidarr holds the file and it is not among its unmapped files
|
||||||
|
-- unmapped Lidarr holds the file but maps it to no track: removing it opens
|
||||||
|
-- no hole
|
||||||
|
-- NULL when Lidarr was not consulted (disabled, or unreachable that pass).
|
||||||
|
ALTER TABLE duplicate_group_members ADD COLUMN lidarr_state text;
|
||||||
|
ALTER TABLE duplicate_group_members ADD CONSTRAINT duplicate_group_members_lidarr_state_check
|
||||||
|
CHECK (lidarr_state IN ('tracked', 'unmapped'));
|
||||||
|
|
||||||
|
-- Rule 25: the operator can turn the resolver off. On by default: the operator
|
||||||
|
-- asked for duplicates to resolve themselves (2026-10-09).
|
||||||
|
ALTER TABLE fingerprint_settings ADD COLUMN auto_resolve boolean NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- The resolver tells admins what it did (rule 36: the kind CHECKs of 0073 are
|
||||||
|
-- swapped to carry the new value). Postgres named both from their columns.
|
||||||
|
ALTER TABLE user_notifications DROP CONSTRAINT user_notifications_kind_check;
|
||||||
|
ALTER TABLE user_notifications ADD CONSTRAINT user_notifications_kind_check
|
||||||
|
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
|
||||||
|
'request_pending', 'quarantine_flagged', 'scan_failed',
|
||||||
|
'tracks_missing', 'duplicates_found', 'duplicates_resolved',
|
||||||
|
'playback_errors'));
|
||||||
|
ALTER TABLE user_notification_prefs DROP CONSTRAINT user_notification_prefs_kind_check;
|
||||||
|
ALTER TABLE user_notification_prefs ADD CONSTRAINT user_notification_prefs_kind_check
|
||||||
|
CHECK (kind IN ('request_approved', 'request_rejected', 'request_completed',
|
||||||
|
'request_pending', 'quarantine_flagged', 'scan_failed',
|
||||||
|
'tracks_missing', 'duplicates_found', 'duplicates_resolved',
|
||||||
|
'playback_errors'));
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS tracks_song_key_idx;
|
||||||
|
ALTER TABLE tracks DROP COLUMN IF EXISTS song_key;
|
||||||
|
ALTER TABLE tracks DROP COLUMN IF EXISTS song_id;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 0076_track_song_link.up.sql — the same song on several releases counts as
|
||||||
|
-- one song (Scribe milestone #498, #5438; operator decision D-b).
|
||||||
|
--
|
||||||
|
-- A single and the album it came from each hold the song, and Lidarr keeps
|
||||||
|
-- both: each fulfils its own release. Minstrel keeps both files and links
|
||||||
|
-- them, so a like on one is a like on the song, and a mix or radio picks the
|
||||||
|
-- song once rather than once per release.
|
||||||
|
--
|
||||||
|
-- song_id is set only on a linked track, to the song_key the link chose.
|
||||||
|
-- song_key is what everything reads: the track's own id until it is linked.
|
||||||
|
-- No foreign key: the track whose id names a song may itself be merged away
|
||||||
|
-- later, and the key still groups the copies left.
|
||||||
|
ALTER TABLE tracks ADD COLUMN song_id uuid;
|
||||||
|
ALTER TABLE tracks ADD COLUMN song_key uuid GENERATED ALWAYS AS (COALESCE(song_id, id)) STORED;
|
||||||
|
CREATE INDEX tracks_song_key_idx ON tracks (song_key);
|
||||||
@@ -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';
|
||||||
@@ -6,3 +6,19 @@ VALUES ($1, $2, $3, $4);
|
|||||||
SELECT * FROM audit_log
|
SELECT * FROM audit_log
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT $1 OFFSET $2;
|
LIMIT $1 OFFSET $2;
|
||||||
|
|
||||||
|
-- name: CountAuditLogByActions :one
|
||||||
|
-- System actions carry no actor; system_only narrows to them.
|
||||||
|
SELECT count(*)::bigint FROM audit_log
|
||||||
|
WHERE action = ANY(sqlc.arg(actions)::text[])
|
||||||
|
AND (NOT sqlc.arg(system_only)::boolean OR actor_id IS NULL);
|
||||||
|
|
||||||
|
-- name: ListAuditLogByActions :many
|
||||||
|
-- One page of the given actions, newest first: the duplicates report's
|
||||||
|
-- "resolved automatically" activity (M498) reads the resolver's merges and
|
||||||
|
-- Lidarr release changes from here.
|
||||||
|
SELECT * FROM audit_log
|
||||||
|
WHERE action = ANY(sqlc.arg(actions)::text[])
|
||||||
|
AND (NOT sqlc.arg(system_only)::boolean OR actor_id IS NULL)
|
||||||
|
ORDER BY created_at DESC, id
|
||||||
|
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset);
|
||||||
|
|||||||
@@ -112,16 +112,36 @@ SELECT count(*)::bigint
|
|||||||
WHERE g.status = 'pending'
|
WHERE g.status = 'pending'
|
||||||
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2;
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2;
|
||||||
|
|
||||||
|
-- name: CountPendingDuplicateGroupsInView :one
|
||||||
|
-- CountPendingDuplicateGroups narrowed to one view of the report (M498):
|
||||||
|
-- review what the operator still has to decide: every class but
|
||||||
|
-- cross_release, and groups not yet classified
|
||||||
|
-- cross_release the same song on different releases, which is kept, not merged
|
||||||
|
SELECT count(*)::bigint
|
||||||
|
FROM duplicate_groups g
|
||||||
|
WHERE g.status = 'pending'
|
||||||
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
|
||||||
|
AND CASE sqlc.arg(view)::text
|
||||||
|
WHEN 'cross_release' THEN g.class = 'cross_release'
|
||||||
|
ELSE g.class IS DISTINCT FROM 'cross_release'
|
||||||
|
END;
|
||||||
|
|
||||||
-- name: ListPendingDuplicateGroupMembers :many
|
-- name: ListPendingDuplicateGroupMembers :many
|
||||||
-- One page of proposals, newest first, flattened to one row per member so the
|
-- One page of proposals in one view (see CountPendingDuplicateGroupsInView),
|
||||||
-- handler folds them without a query per group. What each copy carries — likes
|
-- newest first, flattened to one row per member so the handler folds them
|
||||||
-- and plays from every user — is here because it is what the operator weighs
|
-- without a query per group. What each copy carries — likes and plays from
|
||||||
-- when deciding which copy to keep.
|
-- every user — is here because it is what the operator weighs when deciding
|
||||||
|
-- which copy to keep; Lidarr's view of it, because a copy Lidarr tracks cannot
|
||||||
|
-- be removed without Lidarr downloading it again.
|
||||||
WITH page AS (
|
WITH page AS (
|
||||||
SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at
|
SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at, g.class, g.resolve_note
|
||||||
FROM duplicate_groups g
|
FROM duplicate_groups g
|
||||||
WHERE g.status = 'pending'
|
WHERE g.status = 'pending'
|
||||||
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
|
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2
|
||||||
|
AND CASE sqlc.arg(view)::text
|
||||||
|
WHEN 'cross_release' THEN g.class = 'cross_release'
|
||||||
|
ELSE g.class IS DISTINCT FROM 'cross_release'
|
||||||
|
END
|
||||||
ORDER BY g.detected_at DESC, g.id
|
ORDER BY g.detected_at DESC, g.id
|
||||||
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset)
|
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset)
|
||||||
)
|
)
|
||||||
@@ -129,6 +149,17 @@ SELECT p.id AS group_id,
|
|||||||
p.tier,
|
p.tier,
|
||||||
p.worst_bit_error_rate,
|
p.worst_bit_error_rate,
|
||||||
p.detected_at,
|
p.detected_at,
|
||||||
|
p.class,
|
||||||
|
p.resolve_note,
|
||||||
|
m.lidarr_state,
|
||||||
|
t.disc_number,
|
||||||
|
t.track_number,
|
||||||
|
(t.mbid IS NOT NULL)::boolean AS has_mbid,
|
||||||
|
EXISTS (SELECT 1 FROM tracks o
|
||||||
|
WHERE o.album_id = t.album_id AND o.id <> t.id AND o.missing_since IS NULL
|
||||||
|
AND t.track_number IS NOT NULL
|
||||||
|
AND o.disc_number IS NOT DISTINCT FROM t.disc_number
|
||||||
|
AND o.track_number = t.track_number)::boolean AS position_clash,
|
||||||
t.id AS track_id,
|
t.id AS track_id,
|
||||||
t.title,
|
t.title,
|
||||||
artists.name AS artist_name,
|
artists.name AS artist_name,
|
||||||
@@ -164,3 +195,71 @@ SELECT count(*)::bigint
|
|||||||
FROM duplicate_groups
|
FROM duplicate_groups
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
AND detected_at >= sqlc.arg(since);
|
AND detected_at >= sqlc.arg(since);
|
||||||
|
|
||||||
|
-- name: ListDuplicateGroupsForResolve :many
|
||||||
|
-- Every pending group's present copies, for the resolver (M498 #5435): what it
|
||||||
|
-- needs to classify the group and to choose the copy to keep. One row per copy,
|
||||||
|
-- grouped by group. position_clash is true when another present track on the
|
||||||
|
-- same album claims the same disc and track number: tags that do not fit the
|
||||||
|
-- album, which the survivor rule weighs against a copy.
|
||||||
|
SELECT g.id AS group_id,
|
||||||
|
g.tier,
|
||||||
|
t.id AS track_id,
|
||||||
|
t.album_id,
|
||||||
|
t.title,
|
||||||
|
artists.name AS artist_name,
|
||||||
|
t.file_path,
|
||||||
|
t.file_format,
|
||||||
|
t.file_size,
|
||||||
|
t.added_at,
|
||||||
|
t.disc_number,
|
||||||
|
t.track_number,
|
||||||
|
(t.mbid IS NOT NULL)::boolean AS has_mbid,
|
||||||
|
albums.release_group_mbid,
|
||||||
|
albums.title AS album_title,
|
||||||
|
EXISTS (SELECT 1 FROM tracks o
|
||||||
|
WHERE o.album_id = t.album_id AND o.id <> t.id AND o.missing_since IS NULL
|
||||||
|
AND t.track_number IS NOT NULL
|
||||||
|
AND o.disc_number IS NOT DISTINCT FROM t.disc_number
|
||||||
|
AND o.track_number = t.track_number)::boolean AS position_clash
|
||||||
|
FROM duplicate_groups g
|
||||||
|
JOIN duplicate_group_members m ON m.group_id = g.id
|
||||||
|
JOIN tracks t ON t.id = m.track_id
|
||||||
|
JOIN albums ON albums.id = t.album_id
|
||||||
|
JOIN artists ON artists.id = t.artist_id
|
||||||
|
WHERE g.status = 'pending'
|
||||||
|
AND t.missing_since IS NULL
|
||||||
|
ORDER BY g.id, t.id;
|
||||||
|
|
||||||
|
-- name: SetDuplicateGroupClasses :exec
|
||||||
|
-- The resolver's verdicts, written in one statement. Only rows whose class or
|
||||||
|
-- note actually changed are touched, so an hourly pass over thousands of
|
||||||
|
-- unchanged groups writes nothing. An empty note clears it.
|
||||||
|
UPDATE duplicate_groups g
|
||||||
|
SET class = v.class, resolve_note = NULLIF(v.note, '')
|
||||||
|
FROM (SELECT unnest(sqlc.arg(group_ids)::uuid[]) AS id,
|
||||||
|
unnest(sqlc.arg(classes)::text[]) AS class,
|
||||||
|
unnest(sqlc.arg(notes)::text[]) AS note) v
|
||||||
|
WHERE g.id = v.id
|
||||||
|
AND g.status = 'pending'
|
||||||
|
AND (g.class IS DISTINCT FROM v.class OR g.resolve_note IS DISTINCT FROM NULLIF(v.note, ''));
|
||||||
|
|
||||||
|
-- name: SetDuplicateMemberLidarrStates :exec
|
||||||
|
-- Lidarr's view of each copy as of this pass; an empty state clears it (Lidarr
|
||||||
|
-- not consulted). Unchanged rows are left alone.
|
||||||
|
UPDATE duplicate_group_members m
|
||||||
|
SET lidarr_state = NULLIF(v.state, '')
|
||||||
|
FROM (SELECT unnest(sqlc.arg(group_ids)::uuid[]) AS group_id,
|
||||||
|
unnest(sqlc.arg(track_ids)::uuid[]) AS track_id,
|
||||||
|
unnest(sqlc.arg(states)::text[]) AS state) v
|
||||||
|
WHERE m.group_id = v.group_id
|
||||||
|
AND m.track_id = v.track_id
|
||||||
|
AND m.lidarr_state IS DISTINCT FROM NULLIF(v.state, '');
|
||||||
|
|
||||||
|
-- name: MarkDuplicateGroupResolvedAutomatically :exec
|
||||||
|
UPDATE duplicate_groups SET resolved_automatically = true WHERE id = sqlc.arg(id);
|
||||||
|
|
||||||
|
-- name: ListAlbumPresentTrackTitles :many
|
||||||
|
-- The titles an album has on disk, for choosing the Lidarr release that covers
|
||||||
|
-- them best (M498 #5437).
|
||||||
|
SELECT title FROM tracks WHERE album_id = sqlc.arg(album_id) AND missing_since IS NULL;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ UPDATE fingerprint_settings
|
|||||||
acoustic_max_bit_error_rate = sqlc.arg(acoustic_max_bit_error_rate),
|
acoustic_max_bit_error_rate = sqlc.arg(acoustic_max_bit_error_rate),
|
||||||
backfill_concurrency = sqlc.arg(backfill_concurrency),
|
backfill_concurrency = sqlc.arg(backfill_concurrency),
|
||||||
sweep_interval_hours = sqlc.arg(sweep_interval_hours),
|
sweep_interval_hours = sqlc.arg(sweep_interval_hours),
|
||||||
|
auto_resolve = sqlc.arg(auto_resolve),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = true
|
WHERE id = true
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|||||||
@@ -1,20 +1,43 @@
|
|||||||
-- name: LikeTrack :execrows
|
-- name: LikeTrack :many
|
||||||
|
-- A like is on the song (M498 #5438): every copy of it sharing the track's
|
||||||
|
-- song_key is liked. Returns the tracks newly liked, for the sync log; empty
|
||||||
|
-- when the song was already liked.
|
||||||
INSERT INTO general_likes (user_id, track_id)
|
INSERT INTO general_likes (user_id, track_id)
|
||||||
VALUES ($1, $2)
|
SELECT sqlc.arg(user_id)::uuid, t.id
|
||||||
ON CONFLICT (user_id, track_id) DO NOTHING;
|
FROM tracks t
|
||||||
|
WHERE t.song_key = (SELECT s.song_key FROM tracks s WHERE s.id = sqlc.arg(track_id)::uuid)
|
||||||
|
ON CONFLICT (user_id, track_id) DO NOTHING
|
||||||
|
RETURNING track_id;
|
||||||
|
|
||||||
-- name: UnlikeTrack :exec
|
-- name: UnlikeTrack :many
|
||||||
DELETE FROM general_likes WHERE user_id = $1 AND track_id = $2;
|
-- Unlikes the song: every copy sharing the track's song_key. Returns the
|
||||||
|
-- tracks unliked, for the sync log.
|
||||||
|
DELETE FROM general_likes
|
||||||
|
WHERE user_id = sqlc.arg(user_id)::uuid
|
||||||
|
AND (track_id = sqlc.arg(track_id)::uuid
|
||||||
|
OR track_id IN (SELECT t.id FROM tracks t
|
||||||
|
WHERE t.song_key = (SELECT s.song_key FROM tracks s WHERE s.id = sqlc.arg(track_id)::uuid)))
|
||||||
|
RETURNING track_id;
|
||||||
|
|
||||||
-- name: ListLikedTrackRows :many
|
-- name: ListLikedTrackRows :many
|
||||||
|
-- One row per liked song: of its copies, the one present on disk, then one
|
||||||
|
-- that is not a held-back video rip, then the one liked first.
|
||||||
SELECT t.* FROM tracks t
|
SELECT t.* FROM tracks t
|
||||||
JOIN general_likes l ON l.track_id = t.id
|
JOIN (SELECT DISTINCT ON (lt.song_key) l.track_id, l.liked_at
|
||||||
WHERE l.user_id = $1
|
FROM general_likes l
|
||||||
ORDER BY l.liked_at DESC
|
JOIN tracks lt ON lt.id = l.track_id
|
||||||
|
WHERE l.user_id = $1
|
||||||
|
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
|
||||||
|
ORDER BY l.liked_at DESC, t.id
|
||||||
LIMIT $2 OFFSET $3;
|
LIMIT $2 OFFSET $3;
|
||||||
|
|
||||||
-- name: CountLikedTracks :one
|
-- name: CountLikedTracks :one
|
||||||
SELECT count(*) FROM general_likes WHERE user_id = $1;
|
-- Liked songs, each counted once however many releases hold it.
|
||||||
|
SELECT count(DISTINCT t.song_key) FROM general_likes l
|
||||||
|
JOIN tracks t ON t.id = l.track_id
|
||||||
|
WHERE l.user_id = $1;
|
||||||
|
|
||||||
-- name: ListLikedTrackIDs :many
|
-- name: ListLikedTrackIDs :many
|
||||||
SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC;
|
SELECT track_id FROM general_likes WHERE user_id = $1 ORDER BY liked_at DESC;
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- Song links (M498 #5438): copies of one song on different releases share a
|
||||||
|
-- song_key (migration 0076).
|
||||||
|
|
||||||
|
-- name: LinkTracksAsSong :one
|
||||||
|
-- Links the tracks into one song, joining any songs they already belong to.
|
||||||
|
-- The song keeps the lowest key among them, so linking the same set again
|
||||||
|
-- changes nothing. Returns that key and how many tracks moved to it.
|
||||||
|
WITH keys AS (
|
||||||
|
SELECT DISTINCT song_key FROM tracks WHERE id = ANY(sqlc.arg(track_ids)::uuid[])
|
||||||
|
), target AS (
|
||||||
|
SELECT song_key FROM keys ORDER BY song_key LIMIT 1
|
||||||
|
), moved AS (
|
||||||
|
UPDATE tracks t
|
||||||
|
SET song_id = (SELECT song_key FROM target)
|
||||||
|
WHERE t.song_key IN (SELECT song_key FROM keys)
|
||||||
|
AND t.song_key <> (SELECT song_key FROM target)
|
||||||
|
RETURNING t.id
|
||||||
|
)
|
||||||
|
SELECT (SELECT song_key FROM target)::uuid AS song_key,
|
||||||
|
(SELECT count(*) FROM moved)::bigint AS moved;
|
||||||
|
|
||||||
|
-- name: ShareSongLikes :many
|
||||||
|
-- A like on any copy of the song becomes a like on every copy, dated the
|
||||||
|
-- earliest. Returns the likes added, for the sync log.
|
||||||
|
INSERT INTO general_likes (user_id, track_id, liked_at)
|
||||||
|
SELECT l.user_id, t.id, min(l.liked_at)
|
||||||
|
FROM general_likes l
|
||||||
|
JOIN tracks lt ON lt.id = l.track_id
|
||||||
|
JOIN tracks t ON t.song_key = lt.song_key
|
||||||
|
WHERE lt.song_key = sqlc.arg(song_key)::uuid
|
||||||
|
GROUP BY l.user_id, t.id
|
||||||
|
ON CONFLICT (user_id, track_id) DO NOTHING
|
||||||
|
RETURNING user_id, track_id;
|
||||||
|
|
||||||
|
-- name: UnlinkDuplicateGroupSongs :exec
|
||||||
|
-- "Not the same song": the group's copies stop sharing a song. Likes already
|
||||||
|
-- shared stay where they are.
|
||||||
|
UPDATE tracks SET song_id = NULL
|
||||||
|
WHERE song_id IS NOT NULL
|
||||||
|
AND id IN (SELECT track_id FROM duplicate_group_members WHERE group_id = sqlc.arg(group_id));
|
||||||
|
|
||||||
|
-- name: ListTrackSongKeys :many
|
||||||
|
-- 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
|
||||||
|
-- A merge keeps the removed copy's song link: when the copy being removed was
|
||||||
|
-- linked to copies on other releases and the survivor was not, the survivor
|
||||||
|
-- joins that song. The copy whose id names the song has no song_id of its
|
||||||
|
-- own, so "linked" is another copy sharing its key, not a song_id.
|
||||||
|
UPDATE tracks s
|
||||||
|
SET song_id = l.song_key
|
||||||
|
FROM tracks l
|
||||||
|
WHERE s.id = sqlc.arg(survivor_id)::uuid
|
||||||
|
AND l.id = sqlc.arg(loser_id)::uuid
|
||||||
|
AND s.song_id IS NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM tracks o
|
||||||
|
WHERE o.song_key = l.song_key AND o.id <> l.id AND o.id <> s.id);
|
||||||
@@ -263,3 +263,111 @@ SELECT t.id,
|
|||||||
-- Total for the admin surface's badge and paging. Uses the same partial index
|
-- Total for the admin surface's badge and paging. Uses the same partial index
|
||||||
-- (tracks_missing_since_idx) as the list above.
|
-- (tracks_missing_since_idx) as the list above.
|
||||||
SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL;
|
SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL;
|
||||||
|
|
||||||
|
-- name: ListSuspectSourceTracks :many
|
||||||
|
-- The admin report of present tracks whose filename looks like a video rip
|
||||||
|
-- (#5410): "(Official Video)", "[Audio]", "Visualizer" and the like.
|
||||||
|
--
|
||||||
|
-- pattern is built in Go (internal/api/admin_suspect_sources.go) from the
|
||||||
|
-- same marker list that labels each row, so the filter and the labels cannot
|
||||||
|
-- drift. It is matched against the basename only: a folder called "Reaction
|
||||||
|
-- Sessions" says nothing about how a file was sourced.
|
||||||
|
--
|
||||||
|
-- Ordered by directory, like ListMissingTracks, so the handler's fold gets
|
||||||
|
-- each folder as one contiguous run.
|
||||||
|
SELECT t.id,
|
||||||
|
t.title,
|
||||||
|
t.file_path,
|
||||||
|
regexp_replace(t.file_path, '/[^/]*$', '') AS directory,
|
||||||
|
t.duration_ms,
|
||||||
|
t.disc_number,
|
||||||
|
t.track_number,
|
||||||
|
albums.id AS album_id,
|
||||||
|
albums.title AS album_title,
|
||||||
|
artists.id AS artist_id,
|
||||||
|
artists.name AS artist_name,
|
||||||
|
t.source_verdict,
|
||||||
|
t.source_verdict_at,
|
||||||
|
searched.at AS searched_at
|
||||||
|
FROM tracks t
|
||||||
|
JOIN albums ON albums.id = t.album_id
|
||||||
|
JOIN artists ON artists.id = t.artist_id
|
||||||
|
-- When the resolver last asked Lidarr to search the album for a better copy
|
||||||
|
-- (#5447).
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT max(l.created_at)::timestamptz AS at FROM audit_log l
|
||||||
|
WHERE l.action = 'lidarr_rip_search' AND l.metadata->>'album_id' = albums.id::text
|
||||||
|
) searched ON true
|
||||||
|
WHERE t.missing_since IS NULL
|
||||||
|
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text
|
||||||
|
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
|
||||||
|
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset);
|
||||||
|
|
||||||
|
-- name: CountSuspectSourceTracks :one
|
||||||
|
-- Total for the report's paging; the same filter as the list above.
|
||||||
|
SELECT COUNT(*) FROM tracks t
|
||||||
|
WHERE t.missing_since IS NULL
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- name: ListAlbumsWithSoleCopyRips :many
|
||||||
|
-- Albums holding a held-back video rip that is the only copy of its song
|
||||||
|
-- (#5447): no present copy that is not a rip shares its song, and no pending
|
||||||
|
-- duplicate group pairs it with one. Lidarr counts such a track fulfilled, so
|
||||||
|
-- the resolver asks it to search the album for a better release. Albums
|
||||||
|
-- searched within the last week are left out, most rips first.
|
||||||
|
SELECT a.id AS album_id,
|
||||||
|
a.release_group_mbid::text AS release_group_mbid,
|
||||||
|
a.title AS album_title,
|
||||||
|
ar.name AS artist_name,
|
||||||
|
count(*)::bigint AS rips
|
||||||
|
FROM tracks t
|
||||||
|
JOIN albums a ON a.id = t.album_id
|
||||||
|
JOIN artists ar ON ar.id = a.artist_id
|
||||||
|
WHERE t.source_verdict = 'suspect'
|
||||||
|
AND t.missing_since IS NULL
|
||||||
|
AND a.release_group_mbid IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM tracks c
|
||||||
|
WHERE c.song_key = t.song_key AND c.id <> t.id
|
||||||
|
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM duplicate_group_members m
|
||||||
|
JOIN duplicate_groups g ON g.id = m.group_id AND g.status = 'pending'
|
||||||
|
JOIN duplicate_group_members m2 ON m2.group_id = g.id AND m2.track_id <> t.id
|
||||||
|
JOIN tracks c ON c.id = m2.track_id
|
||||||
|
WHERE m.track_id = t.id
|
||||||
|
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM audit_log l
|
||||||
|
WHERE l.action = 'lidarr_rip_search'
|
||||||
|
AND l.metadata->>'album_id' = a.id::text
|
||||||
|
AND l.created_at > now() - interval '7 days')
|
||||||
|
GROUP BY a.id, a.release_group_mbid, a.title, ar.name
|
||||||
|
ORDER BY count(*) DESC, a.id
|
||||||
|
LIMIT sqlc.arg(page_limit);
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Duplicate classification (M498 #5435).
|
||||||
|
//
|
||||||
|
// A duplicate group is one recording found more than once. What should happen
|
||||||
|
// to it depends on where the copies sit, and the measured library (milestone
|
||||||
|
// 498's body) splits into four shapes:
|
||||||
|
//
|
||||||
|
// - same_release: every copy is on one album. An album holds a song once, so
|
||||||
|
// the extra copies are the ones to fold away — when Lidarr does not need
|
||||||
|
// them (the resolver checks).
|
||||||
|
// - cross_release: the copies are on different albums under one title. A
|
||||||
|
// single and the album it came from, a deluxe edition and the standard one:
|
||||||
|
// each copy fulfils its own release in Lidarr, so none is deleted.
|
||||||
|
// - mismatch: byte-identical audio on different albums under different
|
||||||
|
// titles. One file carries another song's tags — a wrong-file import.
|
||||||
|
// - review: anything else. The operator decides.
|
||||||
|
|
||||||
|
// DuplicateClass is a group's shape. The values are duplicate_groups.class.
|
||||||
|
type DuplicateClass string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ClassSameRelease DuplicateClass = "same_release"
|
||||||
|
ClassCrossRelease DuplicateClass = "cross_release"
|
||||||
|
ClassMismatch DuplicateClass = "mismatch"
|
||||||
|
ClassReview DuplicateClass = "review"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClassifyMember is what classifying needs to know about one copy.
|
||||||
|
type ClassifyMember struct {
|
||||||
|
AlbumID string
|
||||||
|
Title string
|
||||||
|
ArtistName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyDuplicateGroup decides a group's shape from its tier ("exact" or
|
||||||
|
// "acoustic") and its copies.
|
||||||
|
//
|
||||||
|
// Titles are compared by MatchTitleKey, which drops what a release or an upload
|
||||||
|
// adds to a title (featuring credits, remaster notes, video markers) and keeps
|
||||||
|
// what names a different recording (live, demo, remix, instrumental). So one
|
||||||
|
// album holding "WWW" and "WWW (instrumental)" as an acoustic match is review,
|
||||||
|
// not a copy to fold away: the #3885 case, where they were in fact different
|
||||||
|
// recordings mislabelled. Byte-identical copies on one album are the same file
|
||||||
|
// twice whatever their tags say.
|
||||||
|
func ClassifyDuplicateGroup(tier string, members []ClassifyMember) DuplicateClass {
|
||||||
|
if len(members) < 2 {
|
||||||
|
return ClassReview
|
||||||
|
}
|
||||||
|
oneAlbum, oneTitle := true, true
|
||||||
|
key := MatchTitleKey(members[0].Title, members[0].ArtistName)
|
||||||
|
for _, m := range members[1:] {
|
||||||
|
if m.AlbumID != members[0].AlbumID {
|
||||||
|
oneAlbum = false
|
||||||
|
}
|
||||||
|
if MatchTitleKey(m.Title, m.ArtistName) != key {
|
||||||
|
oneTitle = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exact := tier == string(tierExact)
|
||||||
|
switch {
|
||||||
|
case oneAlbum && (oneTitle || exact):
|
||||||
|
return ClassSameRelease
|
||||||
|
case oneAlbum:
|
||||||
|
return ClassReview
|
||||||
|
case oneTitle:
|
||||||
|
return ClassCrossRelease
|
||||||
|
case exact:
|
||||||
|
return ClassMismatch
|
||||||
|
default:
|
||||||
|
return ClassReview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// A bracketed credit: "(feat. Panda Bear)", "[ft. X]", "(featuring Y)".
|
||||||
|
featBracket = regexp.MustCompile(`(?i)[(\[][^)\]]*\b(feat\.?|ft\.?|featuring)\s[^)\]]*[)\]]`)
|
||||||
|
// A credit trailing the title without brackets: "Doin' It Right ft. Panda Bear".
|
||||||
|
featTrailing = regexp.MustCompile(`(?i)\s(feat\.?|ft\.?|featuring)\s.*$`)
|
||||||
|
// "(2011 Remaster)", "[Remastered]", "- Remastered 2009".
|
||||||
|
remasterBracket = regexp.MustCompile(`(?i)[(\[][^)\]]*remaster[^)\]]*[)\]]`)
|
||||||
|
remasterSuffix = regexp.MustCompile(`(?i)\s-\s[^-]*remaster.*$`)
|
||||||
|
// Any bracketed segment, to test each against the video-rip markers.
|
||||||
|
bracketed = regexp.MustCompile(`[(\[][^)\]]*[)\]]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// MatchTitleKey reduces a title to what identifies the song, for deciding
|
||||||
|
// whether two copies carry the same one.
|
||||||
|
//
|
||||||
|
// Dropped: a leading "Artist - " (a video upload's title), bracketed featuring
|
||||||
|
// credits and remaster notes, and bracketed segments carrying a video-rip marker
|
||||||
|
// ("(Official Video)", "[HD]"). Kept: every other bracketed word, so "(live)",
|
||||||
|
// "(demo)", "(acoustic)", "(remix)" and "(instrumental)" still tell recordings
|
||||||
|
// apart. Then case, "&" against "and", and punctuation and spacing are ignored.
|
||||||
|
func MatchTitleKey(title, artist string) string {
|
||||||
|
t := strings.TrimSpace(title)
|
||||||
|
if a := strings.TrimSpace(artist); a != "" {
|
||||||
|
if prefix := a + " - "; len(t) > len(prefix) && strings.EqualFold(t[:len(prefix)], prefix) {
|
||||||
|
t = t[len(prefix):]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t = featBracket.ReplaceAllString(t, " ")
|
||||||
|
t = remasterBracket.ReplaceAllString(t, " ")
|
||||||
|
t = bracketed.ReplaceAllStringFunc(t, func(seg string) string {
|
||||||
|
for _, re := range suspectSourceRegexps {
|
||||||
|
if re.MatchString(seg) {
|
||||||
|
return " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seg
|
||||||
|
})
|
||||||
|
t = remasterSuffix.ReplaceAllString(t, "")
|
||||||
|
t = featTrailing.ReplaceAllString(t, "")
|
||||||
|
if key := alnumKey(t); key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return alnumKey(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseTitleKey is the lighter comparison for asking whether one release
|
||||||
|
// lists a song twice: case, "&" and punctuation only. Every word stays, so a
|
||||||
|
// box set's "Song" and "Song (demo)" are two songs, as the release means them.
|
||||||
|
func ReleaseTitleKey(title string) string {
|
||||||
|
return alnumKey(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// alnumKey lowercases, reads "&" as "and", and keeps only letters and digits.
|
||||||
|
func alnumKey(s string) string {
|
||||||
|
s = strings.ReplaceAll(strings.ToLower(s), "&", " and ")
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The shapes measured on the operator's library (milestone 498's body).
|
||||||
|
func TestClassifyDuplicateGroup(t *testing.T) {
|
||||||
|
m := func(album, title string) ClassifyMember {
|
||||||
|
return ClassifyMember{AlbumID: album, Title: title, ArtistName: "Gorillaz"}
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
tier string
|
||||||
|
members []ClassifyMember
|
||||||
|
want DuplicateClass
|
||||||
|
}{
|
||||||
|
{"Humanz: one album, one song twice", "exact",
|
||||||
|
[]ClassifyMember{m("humanz", "Saturnz Barz"), m("humanz", "Saturnz Barz (Official Video)")}, ClassSameRelease},
|
||||||
|
{"one album, video upload title with artist prefix", "acoustic",
|
||||||
|
[]ClassifyMember{m("humanz", "Andromeda"), m("humanz", "Gorillaz - Andromeda (Official Audio)")}, ClassSameRelease},
|
||||||
|
{"one album, identical bytes under different titles", "exact",
|
||||||
|
[]ClassifyMember{m("www", "WWW"), m("www", "WWW (instrumental)")}, ClassSameRelease},
|
||||||
|
{"one album, acoustic match, different recordings named", "acoustic",
|
||||||
|
[]ClassifyMember{m("www", "WWW"), m("www", "WWW (instrumental)")}, ClassReview},
|
||||||
|
{"Big Me: the single and the album", "exact",
|
||||||
|
[]ClassifyMember{m("single", "Big Me"), m("album", "Big Me")}, ClassCrossRelease},
|
||||||
|
{"remaster and featuring credits are one song", "acoustic",
|
||||||
|
[]ClassifyMember{m("a", "Feel Good Inc. (2017 Remaster)"), m("b", "Feel Good Inc feat. De La Soul")}, ClassCrossRelease},
|
||||||
|
{"Nervosa / Anorexia: identical bytes, different songs", "exact",
|
||||||
|
[]ClassifyMember{m("a", "Nervosa"), m("b", "Anorexia")}, ClassMismatch},
|
||||||
|
{"studio and live across albums", "acoustic",
|
||||||
|
[]ClassifyMember{m("studio", "Charger"), m("live", "Charger (live)")}, ClassReview},
|
||||||
|
{"a lone copy", "exact", []ClassifyMember{m("a", "Song")}, ClassReview},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := ClassifyDuplicateGroup(tc.tier, tc.members); got != tc.want {
|
||||||
|
t.Errorf("%s: got %s, want %s", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchTitleKey(t *testing.T) {
|
||||||
|
cases := []struct{ title, artist, want string }{
|
||||||
|
{"Saturnz Barz (Official Video)", "Gorillaz", "saturnzbarz"},
|
||||||
|
{"Gorillaz - Saturnz Barz", "Gorillaz", "saturnzbarz"},
|
||||||
|
{"Doin' It Right (Music Video) ft. Panda Bear", "Daft Punk", "doinitright"},
|
||||||
|
{"Song [feat. X]", "", "song"},
|
||||||
|
{"Song - Remastered 2009", "", "song"},
|
||||||
|
{"Rock & Roll", "", "rockandroll"},
|
||||||
|
// What names a different recording stays.
|
||||||
|
{"Song (live)", "", "songlive"},
|
||||||
|
{"Song (demo)", "", "songdemo"},
|
||||||
|
{"Song (Remix)", "", "songremix"},
|
||||||
|
// A title that is nothing but a marker keeps its words rather than vanishing.
|
||||||
|
{"(Official Video)", "", "officialvideo"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := MatchTitleKey(tc.title, tc.artist); got != tc.want {
|
||||||
|
t.Errorf("MatchTitleKey(%q, %q) = %q, want %q", tc.title, tc.artist, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The release check keeps every word: a box set's demo is its own song.
|
||||||
|
func TestReleaseTitleKey(t *testing.T) {
|
||||||
|
if ReleaseTitleKey("Song (demo)") == ReleaseTitleKey("Song") {
|
||||||
|
t.Error("ReleaseTitleKey collapsed a demo into the song")
|
||||||
|
}
|
||||||
|
if ReleaseTitleKey("Hallelujah Money") != ReleaseTitleKey("hallelujah money!") {
|
||||||
|
t.Error("ReleaseTitleKey kept case or punctuation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,16 @@ var ErrDuplicateGroupNotPending = errors.New("library: duplicate group is not pe
|
|||||||
// ErrSurvivorNotInGroup means the copy chosen to keep is not a member of the group.
|
// ErrSurvivorNotInGroup means the copy chosen to keep is not a member of the group.
|
||||||
var ErrSurvivorNotInGroup = errors.New("library: survivor is not a member of the group")
|
var ErrSurvivorNotInGroup = errors.New("library: survivor is not a member of the group")
|
||||||
|
|
||||||
|
// ErrCopyTrackedByLidarr means a copy the merge would remove is one Lidarr maps
|
||||||
|
// to a track of the release it monitors (M498). Removing it would open a hole
|
||||||
|
// that Lidarr fills by downloading the song again, so the merge refuses and
|
||||||
|
// changes nothing.
|
||||||
|
var ErrCopyTrackedByLidarr = errors.New("library: a copy to remove is tracked by Lidarr")
|
||||||
|
|
||||||
|
// RemovableFunc reports whether the file at filePath may be removed. A merge
|
||||||
|
// asks it about every copy it would remove, before touching anything.
|
||||||
|
type RemovableFunc func(filePath string) bool
|
||||||
|
|
||||||
// MergedCopy is one copy a merge kept or removed.
|
// MergedCopy is one copy a merge kept or removed.
|
||||||
type MergedCopy struct {
|
type MergedCopy struct {
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
@@ -75,6 +85,17 @@ type MergeResult struct {
|
|||||||
func MergeDuplicateGroup(
|
func MergeDuplicateGroup(
|
||||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
|
||||||
groupID, survivorID pgtype.UUID,
|
groupID, survivorID pgtype.UUID,
|
||||||
|
) (MergeResult, error) {
|
||||||
|
return MergeDuplicateGroupGuarded(ctx, pool, logger, dataDir, groupID, survivorID, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeDuplicateGroupGuarded is MergeDuplicateGroup with a check on what it may
|
||||||
|
// remove: when removable says no to any copy that would go, the merge returns
|
||||||
|
// ErrCopyTrackedByLidarr (wrapped with the path) and changes nothing. A nil
|
||||||
|
// removable allows every copy.
|
||||||
|
func MergeDuplicateGroupGuarded(
|
||||||
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
|
||||||
|
groupID, survivorID pgtype.UUID, removable RemovableFunc,
|
||||||
) (MergeResult, error) {
|
) (MergeResult, error) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = slog.Default()
|
logger = slog.Default()
|
||||||
@@ -109,6 +130,13 @@ func MergeDuplicateGroup(
|
|||||||
return MergeResult{}, err
|
return MergeResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if removable != nil {
|
||||||
|
for _, l := range losers {
|
||||||
|
if !removable(l.FilePath) {
|
||||||
|
return MergeResult{}, fmt.Errorf("%w: %s", ErrCopyTrackedByLidarr, l.FilePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, l := range losers {
|
for _, l := range losers {
|
||||||
if err := removeTrackFileOnDisk(l.FilePath); err != nil {
|
if err := removeTrackFileOnDisk(l.FilePath); err != nil {
|
||||||
return MergeResult{}, err
|
return MergeResult{}, err
|
||||||
@@ -241,6 +269,9 @@ func foldTrackInto(
|
|||||||
if err := tq.MergeInheritTrackMbid(ctx, dbq.MergeInheritTrackMbidParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
|
if err := tq.MergeInheritTrackMbid(ctx, dbq.MergeInheritTrackMbidParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
|
||||||
return f, fmt.Errorf("inherit recording mbid: %w", err)
|
return f, fmt.Errorf("inherit recording mbid: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := tq.MergeInheritSongLink(ctx, dbq.MergeInheritSongLinkParams{SurvivorID: survivorID, LoserID: loserID}); err != nil {
|
||||||
|
return f, fmt.Errorf("inherit song link: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Everything the loser carried now sits on the survivor, so the CASCADE
|
// Everything the loser carried now sits on the survivor, so the CASCADE
|
||||||
// this delete sets off has nothing left to destroy.
|
// this delete sets off has nothing left to destroy.
|
||||||
|
|||||||
@@ -0,0 +1,880 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
||||||
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Duplicate resolver (M498 #5435, #5437).
|
||||||
|
//
|
||||||
|
// The sweep proposes duplicate groups; the resolver decides what each one is
|
||||||
|
// and settles the ones that are safe to settle without asking. Safety is
|
||||||
|
// Lidarr's to define: Lidarr maps one file to each track of the release it
|
||||||
|
// monitors, and re-downloads any mapped file that disappears. So:
|
||||||
|
//
|
||||||
|
// - A copy Lidarr maps is never removed.
|
||||||
|
// - On one album, copies Lidarr does not map are merged into the copy it does
|
||||||
|
// (or, when it maps none, into the best by ProposeSurvivor).
|
||||||
|
// - When Lidarr maps every copy on one album, the release it monitors lists
|
||||||
|
// the song twice: the Humanz box set, 14 vinyl sides then the digital
|
||||||
|
// medium. The resolver moves Lidarr to a release of that album that lists
|
||||||
|
// each song once. Lidarr then rescans the folder, maps one copy and leaves
|
||||||
|
// the rest unmapped, and the next pass merges them.
|
||||||
|
//
|
||||||
|
// The release change has a fixed point (lesson #4183): it happens only while
|
||||||
|
// the monitored release repeats a title, and the release it chooses repeats
|
||||||
|
// none, so the next pass finds nothing to change.
|
||||||
|
|
||||||
|
// LidarrLibrary is the part of Lidarr's API the resolver uses. *lidarr.Client
|
||||||
|
// satisfies it.
|
||||||
|
type LidarrLibrary interface {
|
||||||
|
ListUnmappedTrackFiles(ctx context.Context) ([]lidarr.TrackFile, error)
|
||||||
|
LookupAlbumByMBID(ctx context.Context, mbid string) (lidarr.LidarrAlbum, error)
|
||||||
|
ListAlbumTracks(ctx context.Context, albumID int) ([]lidarr.ReleaseTrack, error)
|
||||||
|
GetAlbumReleases(ctx context.Context, albumID int) ([]lidarr.AlbumRelease, error)
|
||||||
|
ListReleaseTracks(ctx context.Context, releaseID int) ([]lidarr.ReleaseTrack, error)
|
||||||
|
SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error
|
||||||
|
SearchAlbums(ctx context.Context, albumIDs []int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// resolveMergeCap bounds the merges one pass makes. The first pass after
|
||||||
|
// deploy meets the whole backlog (433 groups measured on 2026-10-09); a
|
||||||
|
// cap spreads it over a few hours, each pass short.
|
||||||
|
resolveMergeCap = 200
|
||||||
|
// resolveReleaseCap bounds the Lidarr release changes one pass makes. Each
|
||||||
|
// makes Lidarr rescan an artist folder.
|
||||||
|
resolveReleaseCap = 10
|
||||||
|
// releaseChangeSettle is how long after a release change the resolver
|
||||||
|
// leaves that album's groups alone. Lidarr unmaps every file of the album
|
||||||
|
// and rescans; until the rescan lands, every copy reads as unmapped, and a
|
||||||
|
// merge then could remove the very file Lidarr is about to map.
|
||||||
|
releaseChangeSettle = 24 * time.Hour
|
||||||
|
// duplicateResolveTick is how often the worker runs a pass.
|
||||||
|
duplicateResolveTick = time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
lidarrStateTracked = "tracked"
|
||||||
|
lidarrStateUnmapped = "unmapped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReleaseChange is one Lidarr release the resolver switched.
|
||||||
|
type ReleaseChange struct {
|
||||||
|
AlbumTitle string
|
||||||
|
ArtistName string
|
||||||
|
From, To lidarr.AlbumRelease
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuplicateResolveResult tallies one pass.
|
||||||
|
type DuplicateResolveResult struct {
|
||||||
|
Groups int
|
||||||
|
Classes map[DuplicateClass]int
|
||||||
|
LidarrConsulted bool
|
||||||
|
Merged int
|
||||||
|
MergeFailed int
|
||||||
|
ReleaseChanges []ReleaseChange
|
||||||
|
// SongsLinked counts cross-release groups newly linked as one song.
|
||||||
|
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
|
||||||
|
// RipSearches counts albums Lidarr was asked to search for a better copy
|
||||||
|
// of a rip that has no clean copy (#5447).
|
||||||
|
RipSearches int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LidarrPathKey is the part of a path Minstrel and Lidarr agree on: the last
|
||||||
|
// three components (artist folder, album folder, file). Both see the library
|
||||||
|
// under their own mount; on the operator's deploy both happen to say /music,
|
||||||
|
// and the key keeps the match from depending on that.
|
||||||
|
func LidarrPathKey(p string) string {
|
||||||
|
parts := strings.Split(strings.Trim(p, "/"), "/")
|
||||||
|
if len(parts) > 3 {
|
||||||
|
parts = parts[len(parts)-3:]
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveGroup is one pending group and its present copies.
|
||||||
|
type resolveGroup struct {
|
||||||
|
id pgtype.UUID
|
||||||
|
tier string
|
||||||
|
members []dbq.ListDuplicateGroupsForResolveRow
|
||||||
|
class DuplicateClass
|
||||||
|
note string
|
||||||
|
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 {
|
||||||
|
n := 0
|
||||||
|
for _, s := range g.states {
|
||||||
|
if s == lidarrStateTracked {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveDuplicates runs one pass. lid is nil when Lidarr is disabled; act is
|
||||||
|
// the operator's auto-resolve setting. Without Lidarr, or with act off, the
|
||||||
|
// pass only classifies: nothing is removed when Lidarr cannot say what is safe.
|
||||||
|
func ResolveDuplicates(
|
||||||
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, lid LidarrLibrary, act bool,
|
||||||
|
) (DuplicateResolveResult, error) {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
q := dbq.New(pool)
|
||||||
|
res := DuplicateResolveResult{Classes: map[DuplicateClass]int{}}
|
||||||
|
|
||||||
|
rows, err := q.ListDuplicateGroupsForResolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("list duplicate groups: %w", err)
|
||||||
|
}
|
||||||
|
groups := foldResolveGroups(rows)
|
||||||
|
res.Groups = len(groups)
|
||||||
|
|
||||||
|
var unmapped map[string]bool
|
||||||
|
if lid != nil {
|
||||||
|
files, err := lid.ListUnmappedTrackFiles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("duplicate resolve: Lidarr's unmapped files unavailable; classifying only", "err", err)
|
||||||
|
} else {
|
||||||
|
unmapped = make(map[string]bool, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
unmapped[LidarrPathKey(f.Path)] = true
|
||||||
|
}
|
||||||
|
res.LidarrConsulted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range groups {
|
||||||
|
cm := make([]ClassifyMember, len(g.members))
|
||||||
|
g.states = make([]string, len(g.members))
|
||||||
|
for i, m := range g.members {
|
||||||
|
cm[i] = ClassifyMember{AlbumID: syncpkg.FormatUUID(m.AlbumID), Title: m.Title, ArtistName: m.ArtistName}
|
||||||
|
if res.LidarrConsulted {
|
||||||
|
if unmapped[LidarrPathKey(m.FilePath)] {
|
||||||
|
g.states[i] = lidarrStateUnmapped
|
||||||
|
} else {
|
||||||
|
g.states[i] = lidarrStateTracked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.class = ClassifyDuplicateGroup(g.tier, cm)
|
||||||
|
g.note = resolveNote(g)
|
||||||
|
res.Classes[g.class]++
|
||||||
|
}
|
||||||
|
if err := writeResolveVerdicts(ctx, q, groups); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !act {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
res.SongsLinked = linked
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !res.LidarrConsulted {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
settling, err := recentlyChangedAlbums(ctx, q, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merges first, on the state Lidarr reported at the start of the pass.
|
||||||
|
removable := func(p string) bool { return unmapped[LidarrPathKey(p)] }
|
||||||
|
for _, g := range groups {
|
||||||
|
if res.Merged >= resolveMergeCap {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return res, ctx.Err()
|
||||||
|
}
|
||||||
|
if !autoMergeable(g) || g.settling(settling) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := autoMerge(ctx, pool, logger, dataDir, g, removable); err != nil {
|
||||||
|
res.MergeFailed++
|
||||||
|
logger.Warn("duplicate resolve: merge failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Merged++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then release changes, for albums where Lidarr maps every copy.
|
||||||
|
changes, err := changeRepeatingReleases(ctx, q, pool, logger, lid, groups, settling)
|
||||||
|
res.ReleaseChanges = changes
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last, rips with no clean copy anywhere: ask Lidarr for something better.
|
||||||
|
searched, err := searchSoleCopyRips(ctx, q, pool, logger, lid)
|
||||||
|
res.RipSearches = searched
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("duplicate resolve: rip search failed", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Merged > 0 || len(changes) > 0 {
|
||||||
|
notifyAdmins(ctx, notifications.KindDuplicatesResolved, notifications.Payload{
|
||||||
|
Count: int64(res.Merged + len(changes)),
|
||||||
|
Detail: resolvedDetail(res.Merged, changes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkCrossRelease links each cross-release group's copies as one song
|
||||||
|
// (#5438): a like on one is a like on all, and the Liked list, shuffle and the
|
||||||
|
// mixes count the song once. Linking an already-linked group changes nothing,
|
||||||
|
// so this runs every pass; likes are shared only when a link is new, since a
|
||||||
|
// like made after it reaches every copy as it is made.
|
||||||
|
func linkCrossRelease(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, groups []*resolveGroup) (int, error) {
|
||||||
|
linked := 0
|
||||||
|
for _, g := range groups {
|
||||||
|
if g.class != ClassCrossRelease {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return linked, ctx.Err()
|
||||||
|
}
|
||||||
|
ids := make([]pgtype.UUID, len(g.members))
|
||||||
|
for i, m := range g.members {
|
||||||
|
ids[i] = m.TrackID
|
||||||
|
}
|
||||||
|
moved, err := linkSong(ctx, pool, ids)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("duplicate resolve: song link failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if moved {
|
||||||
|
linked++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return linked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkSong links the tracks as one song and, when that changed anything,
|
||||||
|
// shares their likes across the song and logs each added like for sync.
|
||||||
|
func linkSong(ctx context.Context, pool *pgxpool.Pool, ids []pgtype.UUID) (bool, error) {
|
||||||
|
tx, err := pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
tq := dbq.New(tx)
|
||||||
|
link, err := tq.LinkTracksAsSong(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("link: %w", err)
|
||||||
|
}
|
||||||
|
if link.Moved == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
added, err := tq.ShareSongLikes(ctx, link.SongKey)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("share likes: %w", err)
|
||||||
|
}
|
||||||
|
likeIDs := make([]string, len(added))
|
||||||
|
for i, a := range added {
|
||||||
|
likeIDs[i] = syncpkg.EncodeLikeID(syncpkg.FormatUUID(a.UserID), syncpkg.FormatUUID(a.TrackID))
|
||||||
|
}
|
||||||
|
if err := syncpkg.LogChanges(ctx, tx, syncpkg.EntityLikeTrack, likeIDs, syncpkg.OpUpsert); err != nil {
|
||||||
|
return false, fmt.Errorf("log shared likes: %w", err)
|
||||||
|
}
|
||||||
|
return true, tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ripSearchCap bounds the albums one pass asks Lidarr to search, so the first
|
||||||
|
// pass after deploy does not queue the whole backlog of rips at once.
|
||||||
|
const ripSearchCap = 10
|
||||||
|
|
||||||
|
// searchSoleCopyRips asks Lidarr to search each album holding a video rip that
|
||||||
|
// is the only copy of its song (#5447, operator's choice: option 2). While
|
||||||
|
// Lidarr maps the rip it counts the track fulfilled, so nothing else asks for
|
||||||
|
// better. An album search grabs only when the quality profile allows an
|
||||||
|
// upgrade, so this removes nothing and may change nothing: the rip stays held
|
||||||
|
// back either way. An album is searched at most once a week (the query reads
|
||||||
|
// the audit log), and an album Lidarr does not hold is skipped.
|
||||||
|
func searchSoleCopyRips(
|
||||||
|
ctx context.Context, q *dbq.Queries, pool *pgxpool.Pool, logger *slog.Logger, lid LidarrLibrary,
|
||||||
|
) (int, error) {
|
||||||
|
albums, err := q.ListAlbumsWithSoleCopyRips(ctx, ripSearchCap)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("list albums with sole-copy rips: %w", err)
|
||||||
|
}
|
||||||
|
type found struct {
|
||||||
|
row dbq.ListAlbumsWithSoleCopyRipsRow
|
||||||
|
lidarrID int
|
||||||
|
}
|
||||||
|
var targets []found
|
||||||
|
for _, a := range albums {
|
||||||
|
la, err := lid.LookupAlbumByMBID(ctx, a.ReleaseGroupMbid)
|
||||||
|
if errors.Is(err, lidarr.ErrNotFound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("look up %s: %w", a.ReleaseGroupMbid, err)
|
||||||
|
}
|
||||||
|
targets = append(targets, found{row: a, lidarrID: la.ID})
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
ids := make([]int, len(targets))
|
||||||
|
for i, t := range targets {
|
||||||
|
ids[i] = t.lidarrID
|
||||||
|
}
|
||||||
|
if err := lid.SearchAlbums(ctx, ids); err != nil {
|
||||||
|
return 0, fmt.Errorf("search albums: %w", err)
|
||||||
|
}
|
||||||
|
for _, t := range targets {
|
||||||
|
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionLidarrRipSearch, map[string]any{
|
||||||
|
"album_id": syncpkg.FormatUUID(t.row.AlbumID),
|
||||||
|
"album_title": t.row.AlbumTitle,
|
||||||
|
"artist_name": t.row.ArtistName,
|
||||||
|
"lidarr_album_id": t.lidarrID,
|
||||||
|
"rips": t.row.Rips,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return len(targets), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func foldResolveGroups(rows []dbq.ListDuplicateGroupsForResolveRow) []*resolveGroup {
|
||||||
|
var groups []*resolveGroup
|
||||||
|
for _, r := range rows {
|
||||||
|
if n := len(groups); n == 0 || groups[n-1].id != r.GroupID {
|
||||||
|
groups = append(groups, &resolveGroup{id: r.GroupID, tier: r.Tier})
|
||||||
|
}
|
||||||
|
g := groups[len(groups)-1]
|
||||||
|
g.members = append(g.members, r)
|
||||||
|
}
|
||||||
|
// A group whose other copies went missing has nothing left to resolve.
|
||||||
|
out := groups[:0]
|
||||||
|
for _, g := range groups {
|
||||||
|
if len(g.members) >= 2 {
|
||||||
|
out = append(out, g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveNote is what the report says beside a group the resolver leaves for
|
||||||
|
// the operator, when there is something to say.
|
||||||
|
func resolveNote(g *resolveGroup) string {
|
||||||
|
switch g.class {
|
||||||
|
case ClassSameRelease:
|
||||||
|
if g.tracked() > 1 {
|
||||||
|
return "Lidarr tracks every copy as its own track, so removing one would make Lidarr download it again."
|
||||||
|
}
|
||||||
|
case ClassCrossRelease:
|
||||||
|
return "Each copy belongs to its own release, and Lidarr keeps each one. Nothing is removed; they count as one song."
|
||||||
|
case ClassMismatch:
|
||||||
|
return "Identical audio filed under different titles: one file carries another song's tags."
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResolveVerdicts(ctx context.Context, q *dbq.Queries, groups []*resolveGroup) error {
|
||||||
|
classes := dbq.SetDuplicateGroupClassesParams{}
|
||||||
|
states := dbq.SetDuplicateMemberLidarrStatesParams{}
|
||||||
|
for _, g := range groups {
|
||||||
|
classes.GroupIds = append(classes.GroupIds, g.id)
|
||||||
|
classes.Classes = append(classes.Classes, string(g.class))
|
||||||
|
classes.Notes = append(classes.Notes, g.note)
|
||||||
|
for i, m := range g.members {
|
||||||
|
states.GroupIds = append(states.GroupIds, g.id)
|
||||||
|
states.TrackIds = append(states.TrackIds, m.TrackID)
|
||||||
|
states.States = append(states.States, g.states[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(classes.GroupIds) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := q.SetDuplicateGroupClasses(ctx, classes); err != nil {
|
||||||
|
return fmt.Errorf("write duplicate classes: %w", err)
|
||||||
|
}
|
||||||
|
if err := q.SetDuplicateMemberLidarrStates(ctx, states); err != nil {
|
||||||
|
return fmt.Errorf("write lidarr states: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// survivorCandidates builds what ProposeSurvivor weighs for a group's copies.
|
||||||
|
func survivorCandidates(g *resolveGroup) []SurvivorCandidate {
|
||||||
|
cands := make([]SurvivorCandidate, len(g.members))
|
||||||
|
for i, m := range g.members {
|
||||||
|
cands[i] = SurvivorCandidate{
|
||||||
|
TrackID: syncpkg.FormatUUID(m.TrackID),
|
||||||
|
FileFormat: m.FileFormat,
|
||||||
|
FileSize: m.FileSize,
|
||||||
|
AddedAt: m.AddedAt.Time,
|
||||||
|
LidarrTracked: g.states[i] == lidarrStateTracked,
|
||||||
|
TagFit: TagFitScore(m.TrackNumber != nil, m.PositionClash, m.FilePath, m.HasMbid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cands
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoMerge merges one group into the copy ProposeSurvivor picks, which is the
|
||||||
|
// copy Lidarr tracks when there is one. removable is the guard: a copy Lidarr
|
||||||
|
// maps can never be among those removed, whatever the survivor rule said.
|
||||||
|
func autoMerge(
|
||||||
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, g *resolveGroup, removable RemovableFunc,
|
||||||
|
) error {
|
||||||
|
survivorKey, reason := ProposeSurvivor(survivorCandidates(g))
|
||||||
|
var survivorID pgtype.UUID
|
||||||
|
if err := survivorID.Scan(survivorKey); err != nil {
|
||||||
|
return fmt.Errorf("parse survivor id: %w", err)
|
||||||
|
}
|
||||||
|
res, err := MergeDuplicateGroupGuarded(ctx, pool, logger, dataDir, g.id, survivorID, removable)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := dbq.New(pool).MarkDuplicateGroupResolvedAutomatically(ctx, g.id); err != nil {
|
||||||
|
logger.Warn("duplicate resolve: marking the merge automatic failed", "group_id", syncpkg.FormatUUID(g.id), "err", err)
|
||||||
|
}
|
||||||
|
removed := make([]map[string]string, 0, len(res.Removed))
|
||||||
|
for _, c := range res.Removed {
|
||||||
|
removed = append(removed, map[string]string{"track_id": syncpkg.FormatUUID(c.TrackID), "file_path": c.FilePath})
|
||||||
|
}
|
||||||
|
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionDuplicateMerge, map[string]any{
|
||||||
|
"automatic": true,
|
||||||
|
"group_id": syncpkg.FormatUUID(g.id),
|
||||||
|
"tier": res.Tier,
|
||||||
|
"class": string(g.class),
|
||||||
|
"survivor_track_id": syncpkg.FormatUUID(res.Survivor.TrackID),
|
||||||
|
"survivor_path": res.Survivor.FilePath,
|
||||||
|
"survivor_reason": reason,
|
||||||
|
"removed": removed,
|
||||||
|
"moved": map[string]any{
|
||||||
|
"play_events": res.PlayEvents, "skip_events": res.SkipEvents,
|
||||||
|
"likes": res.Likes, "playlist_entries": res.PlaylistEntries,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentlyChangedAlbums is the Minstrel albums whose Lidarr release the
|
||||||
|
// resolver changed within releaseChangeSettle, read from the audit log so a
|
||||||
|
// restart does not forget them.
|
||||||
|
func recentlyChangedAlbums(ctx context.Context, q *dbq.Queries, now time.Time) (map[string]bool, error) {
|
||||||
|
rows, err := q.ListAuditLogByActions(ctx, dbq.ListAuditLogByActionsParams{
|
||||||
|
Actions: []string{string(audit.ActionLidarrReleaseChange)}, SystemOnly: true,
|
||||||
|
PageLimit: 200, PageOffset: 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read recent release changes: %w", err)
|
||||||
|
}
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, r := range rows {
|
||||||
|
if now.Sub(r.CreatedAt.Time) > releaseChangeSettle {
|
||||||
|
break // newest first
|
||||||
|
}
|
||||||
|
var meta struct {
|
||||||
|
AlbumID string `json:"album_id"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(r.Metadata, &meta) == nil && meta.AlbumID != "" {
|
||||||
|
out[meta.AlbumID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// repeatingAlbum is a Minstrel album where Lidarr maps more than one copy of a
|
||||||
|
// song, and the groups that showed it.
|
||||||
|
type repeatingAlbum struct {
|
||||||
|
albumID pgtype.UUID
|
||||||
|
releaseGroupMbid string
|
||||||
|
title, artist string
|
||||||
|
groups []*resolveGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func changeRepeatingReleases(
|
||||||
|
ctx context.Context, q *dbq.Queries, pool *pgxpool.Pool, logger *slog.Logger,
|
||||||
|
lid LidarrLibrary, groups []*resolveGroup, settling map[string]bool,
|
||||||
|
) ([]ReleaseChange, error) {
|
||||||
|
albums := map[string]*repeatingAlbum{}
|
||||||
|
for _, g := range groups {
|
||||||
|
if g.class != ClassSameRelease || g.tracked() < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := g.members[0]
|
||||||
|
key := syncpkg.FormatUUID(m.AlbumID)
|
||||||
|
if settling[key] || m.ReleaseGroupMbid == nil || *m.ReleaseGroupMbid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a := albums[key]
|
||||||
|
if a == nil {
|
||||||
|
a = &repeatingAlbum{albumID: m.AlbumID, releaseGroupMbid: *m.ReleaseGroupMbid, title: m.AlbumTitle, artist: m.ArtistName}
|
||||||
|
albums[key] = a
|
||||||
|
}
|
||||||
|
a.groups = append(a.groups, g)
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(albums))
|
||||||
|
for k := range albums {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
var changes []ReleaseChange
|
||||||
|
for _, k := range keys {
|
||||||
|
if len(changes) >= resolveReleaseCap {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return changes, ctx.Err()
|
||||||
|
}
|
||||||
|
a := albums[k]
|
||||||
|
change, note, err := changeRelease(ctx, q, lid, a)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("duplicate resolve: Lidarr release check failed", "album", a.title, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if change != nil {
|
||||||
|
changes = append(changes, *change)
|
||||||
|
audit.WriteOrLog(ctx, pool, logger, pgtype.UUID{}, pgtype.UUID{}, audit.ActionLidarrReleaseChange, map[string]any{
|
||||||
|
"automatic": true,
|
||||||
|
"album_id": k,
|
||||||
|
"album_title": a.title,
|
||||||
|
"artist_name": a.artist,
|
||||||
|
"from_release": releaseLabel(change.From),
|
||||||
|
"to_release": releaseLabel(change.To),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if note != "" {
|
||||||
|
if err := setGroupNotes(ctx, q, a.groups, note); err != nil {
|
||||||
|
return changes, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeRelease checks one album's monitored release and, when it lists a
|
||||||
|
// song twice, moves Lidarr to the release that lists each song once and best
|
||||||
|
// covers what is on disk. It returns the change made (nil when none) and the
|
||||||
|
// note for the album's groups.
|
||||||
|
func changeRelease(
|
||||||
|
ctx context.Context, q *dbq.Queries, lid LidarrLibrary, a *repeatingAlbum,
|
||||||
|
) (*ReleaseChange, string, error) {
|
||||||
|
la, err := lid.LookupAlbumByMBID(ctx, a.releaseGroupMbid)
|
||||||
|
if errors.Is(err, lidarr.ErrNotFound) {
|
||||||
|
return nil, "Lidarr does not hold this album, so its copies are left for you.", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
current, err := lid.ListAlbumTracks(ctx, la.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if !releaseRepeats(current) {
|
||||||
|
return nil, "Lidarr maps each copy to a different track of this album, so none can go without a download.", nil
|
||||||
|
}
|
||||||
|
releases, err := lid.GetAlbumReleases(ctx, la.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
titles, err := q.ListAlbumPresentTrackTitles(ctx, a.albumID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("list album titles: %w", err)
|
||||||
|
}
|
||||||
|
onDisk := map[string]bool{}
|
||||||
|
for _, t := range titles {
|
||||||
|
if k := ReleaseTitleKey(t); k != "" {
|
||||||
|
onDisk[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var from lidarr.AlbumRelease
|
||||||
|
var candidates []releaseCandidate
|
||||||
|
for _, r := range releases {
|
||||||
|
if r.Monitored {
|
||||||
|
from = r
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tracks, err := lid.ListReleaseTracks(ctx, r.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if len(tracks) == 0 || releaseRepeats(tracks) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, releaseCandidate{release: r, coverage: coverage(tracks, onDisk)})
|
||||||
|
}
|
||||||
|
best, ok := pickRelease(candidates, coverage(current, onDisk))
|
||||||
|
if !ok {
|
||||||
|
return nil, "Lidarr's release of this album lists songs twice, and no other release lists each once while keeping what is on disk.", nil
|
||||||
|
}
|
||||||
|
if err := lid.SetMonitoredRelease(ctx, la.ID, best.ID); err != nil {
|
||||||
|
// Lidarr's album update unlinks the album's files and queues the rescan
|
||||||
|
// before it answers, and on a large album that outlasts the client's
|
||||||
|
// timeout while the change still lands (Cracker Island on the first
|
||||||
|
// deploy pass). An unanswered PUT is read back: when the release did
|
||||||
|
// change, it is recorded like any other, so the album gets its settle
|
||||||
|
// window and the report shows it.
|
||||||
|
if !errors.Is(err, lidarr.ErrUnreachable) || !releaseMonitored(ctx, lid, la.ID, best.ID) {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
note := fmt.Sprintf("Lidarr now monitors the %s release, which lists each song once. The extra copies are merged once Lidarr has rescanned.", releaseLabel(best))
|
||||||
|
return &ReleaseChange{AlbumTitle: a.title, ArtistName: a.artist, From: from, To: best}, note, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseMonitored reads whether the album's monitored release is now
|
||||||
|
// releaseID. A failed read answers false: the change is not assumed.
|
||||||
|
func releaseMonitored(ctx context.Context, lid LidarrLibrary, albumID, releaseID int) bool {
|
||||||
|
releases, err := lid.GetAlbumReleases(ctx, albumID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range releases {
|
||||||
|
if r.ID == releaseID {
|
||||||
|
return r.Monitored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseCandidate struct {
|
||||||
|
release lidarr.AlbumRelease
|
||||||
|
coverage int
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickRelease chooses among releases that list each song once: the one
|
||||||
|
// covering most of the titles on disk, then the fewest tracks (the plainest
|
||||||
|
// edition that holds them), then a digital one, then the lowest id so the
|
||||||
|
// choice is stable. It refuses a release covering fewer titles on disk than
|
||||||
|
// the current one does: the change must not drop a song Lidarr now keeps.
|
||||||
|
func pickRelease(cands []releaseCandidate, currentCoverage int) (lidarr.AlbumRelease, bool) {
|
||||||
|
if len(cands) == 0 {
|
||||||
|
return lidarr.AlbumRelease{}, false
|
||||||
|
}
|
||||||
|
sort.SliceStable(cands, func(i, j int) bool {
|
||||||
|
a, b := cands[i], cands[j]
|
||||||
|
if a.coverage != b.coverage {
|
||||||
|
return a.coverage > b.coverage
|
||||||
|
}
|
||||||
|
if a.release.TrackCount != b.release.TrackCount {
|
||||||
|
return a.release.TrackCount < b.release.TrackCount
|
||||||
|
}
|
||||||
|
if da, db := isDigital(a.release), isDigital(b.release); da != db {
|
||||||
|
return da
|
||||||
|
}
|
||||||
|
return a.release.ID < b.release.ID
|
||||||
|
})
|
||||||
|
if cands[0].coverage < currentCoverage {
|
||||||
|
return lidarr.AlbumRelease{}, false
|
||||||
|
}
|
||||||
|
return cands[0].release, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDigital(r lidarr.AlbumRelease) bool {
|
||||||
|
return strings.Contains(strings.ToLower(r.Format), "digital")
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseRepeats reports whether a release lists one title more than once.
|
||||||
|
func releaseRepeats(tracks []lidarr.ReleaseTrack) bool {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, t := range tracks {
|
||||||
|
k := ReleaseTitleKey(t.Title)
|
||||||
|
if k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[k] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
seen[k] = true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverage counts the distinct titles on disk that the release lists.
|
||||||
|
func coverage(tracks []lidarr.ReleaseTrack, onDisk map[string]bool) int {
|
||||||
|
hit := map[string]bool{}
|
||||||
|
for _, t := range tracks {
|
||||||
|
if k := ReleaseTitleKey(t.Title); onDisk[k] {
|
||||||
|
hit[k] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(hit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseLabel(r lidarr.AlbumRelease) string {
|
||||||
|
label := r.Format
|
||||||
|
if label == "" {
|
||||||
|
label = r.Title
|
||||||
|
}
|
||||||
|
if r.Disambiguation != "" {
|
||||||
|
label = r.Disambiguation + ", " + label
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%d tracks)", label, r.TrackCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGroupNotes(ctx context.Context, q *dbq.Queries, groups []*resolveGroup, note string) error {
|
||||||
|
p := dbq.SetDuplicateGroupClassesParams{}
|
||||||
|
for _, g := range groups {
|
||||||
|
g.note = note
|
||||||
|
p.GroupIds = append(p.GroupIds, g.id)
|
||||||
|
p.Classes = append(p.Classes, string(g.class))
|
||||||
|
p.Notes = append(p.Notes, note)
|
||||||
|
}
|
||||||
|
if err := q.SetDuplicateGroupClasses(ctx, p); err != nil {
|
||||||
|
return fmt.Errorf("write duplicate notes: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvedDetail(merged int, changes []ReleaseChange) string {
|
||||||
|
var parts []string
|
||||||
|
if merged > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("Merged %d %s Lidarr does not need.", merged, pluralWord(merged, "copy", "copies")))
|
||||||
|
}
|
||||||
|
switch len(changes) {
|
||||||
|
case 0:
|
||||||
|
case 1:
|
||||||
|
c := changes[0]
|
||||||
|
parts = append(parts, fmt.Sprintf("Lidarr now monitors the %s release of %s by %s, which lists each song once.",
|
||||||
|
releaseLabel(c.To), c.AlbumTitle, c.ArtistName))
|
||||||
|
default:
|
||||||
|
parts = append(parts, fmt.Sprintf("Lidarr now monitors a release that lists each song once for %d albums.", len(changes)))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluralWord(n int, one, many string) string {
|
||||||
|
if n == 1 {
|
||||||
|
return one
|
||||||
|
}
|
||||||
|
return many
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuplicateResolveWorker runs a resolver pass every hour.
|
||||||
|
type DuplicateResolveWorker struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
logger *slog.Logger
|
||||||
|
dataDir string
|
||||||
|
settings *FingerprintSettingsService
|
||||||
|
lidarr func() LidarrLibrary
|
||||||
|
tick time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDuplicateResolveWorker builds a worker with the production cadence.
|
||||||
|
// lidarrFn is asked each pass, so a Lidarr setting saved in admin takes effect
|
||||||
|
// without a restart; it returns nil while Lidarr is disabled.
|
||||||
|
func NewDuplicateResolveWorker(
|
||||||
|
pool *pgxpool.Pool, logger *slog.Logger, dataDir string,
|
||||||
|
settings *FingerprintSettingsService, lidarrFn func() LidarrLibrary,
|
||||||
|
) *DuplicateResolveWorker {
|
||||||
|
return &DuplicateResolveWorker{
|
||||||
|
pool: pool, logger: logger, dataDir: dataDir, settings: settings, lidarr: lidarrFn, tick: duplicateResolveTick,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run blocks until ctx is cancelled, running a pass at start and then each tick.
|
||||||
|
func (w *DuplicateResolveWorker) Run(ctx context.Context) {
|
||||||
|
w.tickOnce(ctx)
|
||||||
|
t := time.NewTicker(w.tick)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
w.tickOnce(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tickOnce contains one pass so nothing it does can stop the next tick (rule 157).
|
||||||
|
func (w *DuplicateResolveWorker) tickOnce(ctx context.Context) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
w.logger.Error("duplicate resolve: tick panicked", "panic", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// A sweep in flight is rewriting the groups; the next tick sees its result.
|
||||||
|
_, err := dbq.New(w.pool).GetInFlightDuplicateSweep(ctx)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return
|
||||||
|
case !errors.Is(err, pgx.ErrNoRows):
|
||||||
|
if ctx.Err() == nil {
|
||||||
|
w.logger.Warn("duplicate resolve: sweep check failed", "err", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var lid LidarrLibrary
|
||||||
|
if w.lidarr != nil {
|
||||||
|
lid = w.lidarr()
|
||||||
|
}
|
||||||
|
res, err := ResolveDuplicates(ctx, w.pool, w.logger, w.dataDir, lid, w.settings.Get().AutoResolve)
|
||||||
|
if err != nil && ctx.Err() == nil {
|
||||||
|
w.logger.Warn("duplicate resolve: pass failed", "err", err)
|
||||||
|
}
|
||||||
|
w.logger.Info("duplicate resolve complete",
|
||||||
|
"groups", res.Groups, "lidarr", res.LidarrConsulted, "merged", res.Merged,
|
||||||
|
"merge_failed", res.MergeFailed, "release_changes", len(res.ReleaseChanges), "songs_linked", res.SongsLinked,
|
||||||
|
"held_back", res.HeldBack, "released", res.Released, "rip_searches", res.RipSearches)
|
||||||
|
}
|
||||||
@@ -0,0 +1,634 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||||
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLidarrPathKey(t *testing.T) {
|
||||||
|
// Minstrel and Lidarr see the library under different mounts; the key is
|
||||||
|
// what they agree on.
|
||||||
|
a := LidarrPathKey("/music/Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3")
|
||||||
|
b := LidarrPathKey("/data/media/music/Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3")
|
||||||
|
if a != b || a != "Gorillaz/Humanz (2017)/Gorillaz - Humanz - 01 - Ascension.mp3" {
|
||||||
|
t.Errorf("keys differ or are wrong: %q, %q", a, b)
|
||||||
|
}
|
||||||
|
if LidarrPathKey("/music/Gorillaz/Humanz (2017)/a.mp3") == LidarrPathKey("/music/Gorillaz/Demon Days (2005)/a.mp3") {
|
||||||
|
t.Error("two albums' files share a key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rt(titles ...string) []lidarr.ReleaseTrack {
|
||||||
|
out := make([]lidarr.ReleaseTrack, len(titles))
|
||||||
|
for i, title := range titles {
|
||||||
|
out[i] = lidarr.ReleaseTrack{ID: i + 1, Title: title}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseRepeats(t *testing.T) {
|
||||||
|
if !releaseRepeats(rt("Ascension", "Strobelite", "Ascension")) {
|
||||||
|
t.Error("the box set lists Ascension twice, but no repeat was found")
|
||||||
|
}
|
||||||
|
if releaseRepeats(rt("Charger", "Charger (demo)")) {
|
||||||
|
t.Error("a demo was read as a repeat of the song")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Humanz releases as Lidarr lists them (2026-10-09): the box set is
|
||||||
|
// monitored, and the 40-track digital release is the one that lists each song
|
||||||
|
// once and covers what is on disk.
|
||||||
|
func TestPickRelease(t *testing.T) {
|
||||||
|
digital40 := lidarr.AlbumRelease{ID: 10149, Format: "Digital Media", TrackCount: 40}
|
||||||
|
deluxe26 := lidarr.AlbumRelease{ID: 10150, Format: "CD", TrackCount: 26}
|
||||||
|
standard20 := lidarr.AlbumRelease{ID: 10151, Format: "Digital Media", TrackCount: 20}
|
||||||
|
|
||||||
|
got, ok := pickRelease([]releaseCandidate{
|
||||||
|
{release: standard20, coverage: 20}, {release: deluxe26, coverage: 26}, {release: digital40, coverage: 34},
|
||||||
|
}, 34)
|
||||||
|
if !ok || got.ID != digital40.ID {
|
||||||
|
t.Errorf("picked %+v (ok=%v), want the 40-track digital release", got, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal coverage: the plainer edition, then digital.
|
||||||
|
cd := lidarr.AlbumRelease{ID: 1, Format: "CD", TrackCount: 26}
|
||||||
|
web := lidarr.AlbumRelease{ID: 2, Format: "Digital Media", TrackCount: 26}
|
||||||
|
if got, _ := pickRelease([]releaseCandidate{{release: cd, coverage: 26}, {release: web, coverage: 26}}, 26); got.ID != web.ID {
|
||||||
|
t.Errorf("picked %+v, want the digital one of two equal releases", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never a release that keeps fewer of the songs on disk than the current one.
|
||||||
|
if got, ok := pickRelease([]releaseCandidate{{release: deluxe26, coverage: 26}}, 34); ok {
|
||||||
|
t.Errorf("picked %+v, which covers fewer songs on disk than the current release", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeLidarr is one Lidarr album with several releases. Switching the
|
||||||
|
// monitored release changes what ListAlbumTracks answers, as in Lidarr.
|
||||||
|
type fakeLidarr struct {
|
||||||
|
unmapped []string
|
||||||
|
unmappedErr error
|
||||||
|
album lidarr.LidarrAlbum
|
||||||
|
releases []lidarr.AlbumRelease
|
||||||
|
releaseTracks map[int][]lidarr.ReleaseTrack
|
||||||
|
setCalls [][2]int
|
||||||
|
searchCalls [][]int
|
||||||
|
// setErr is what SetMonitoredRelease answers after applying the change:
|
||||||
|
// Lidarr timing out on a change that landed.
|
||||||
|
setErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLidarr) ListUnmappedTrackFiles(context.Context) ([]lidarr.TrackFile, error) {
|
||||||
|
out := make([]lidarr.TrackFile, len(f.unmapped))
|
||||||
|
for i, p := range f.unmapped {
|
||||||
|
out[i] = lidarr.TrackFile{ID: i + 1, Path: p}
|
||||||
|
}
|
||||||
|
return out, f.unmappedErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLidarr) LookupAlbumByMBID(_ context.Context, mbid string) (lidarr.LidarrAlbum, error) {
|
||||||
|
if f.album.ForeignAlbumID != mbid {
|
||||||
|
return lidarr.LidarrAlbum{}, lidarr.ErrNotFound
|
||||||
|
}
|
||||||
|
return f.album, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLidarr) ListAlbumTracks(context.Context, int) ([]lidarr.ReleaseTrack, error) {
|
||||||
|
for _, r := range f.releases {
|
||||||
|
if r.Monitored {
|
||||||
|
return f.releaseTracks[r.ID], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLidarr) GetAlbumReleases(context.Context, int) ([]lidarr.AlbumRelease, error) {
|
||||||
|
return append([]lidarr.AlbumRelease(nil), f.releases...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLidarr) ListReleaseTracks(_ context.Context, releaseID int) ([]lidarr.ReleaseTrack, error) {
|
||||||
|
return f.releaseTracks[releaseID], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLidarr) SearchAlbums(_ context.Context, albumIDs []int) error {
|
||||||
|
f.searchCalls = append(f.searchCalls, albumIDs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLidarr) SetMonitoredRelease(_ context.Context, albumID, releaseID int) error {
|
||||||
|
f.setCalls = append(f.setCalls, [2]int{albumID, releaseID})
|
||||||
|
for i := range f.releases {
|
||||||
|
f.releases[i].Monitored = f.releases[i].ID == releaseID
|
||||||
|
}
|
||||||
|
return f.setErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveFixture is the Humanz shape: one album, one song twice, both copies on
|
||||||
|
// disk under an artist/album folder, in one pending exact-tier group.
|
||||||
|
type resolveFixture struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
clean, rip dbq.Track
|
||||||
|
cleanPath, ripPath string
|
||||||
|
groupID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
func newResolveFixture(t *testing.T) resolveFixture {
|
||||||
|
t.Helper()
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
dir := filepath.Join(t.TempDir(), "Gorillaz", "Humanz (2017)")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := resolveFixture{pool: pool}
|
||||||
|
f.cleanPath = filepath.Join(dir, "Gorillaz - Humanz - 03 - Saturnz Barz.mp3")
|
||||||
|
f.ripPath = filepath.Join(dir, "Gorillaz - Humanz - 03 - Saturnz Barz (Official Video).mp3")
|
||||||
|
for _, p := range []string{f.cleanPath, f.ripPath} {
|
||||||
|
if err := os.WriteFile(p, []byte("audio"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var album dbq.Album
|
||||||
|
var artist dbq.Artist
|
||||||
|
f.clean, album, artist = seedTrack(t, pool, f.cleanPath)
|
||||||
|
rip, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||||
|
Title: "Saturnz Barz (Official Video)", AlbumID: album.ID, ArtistID: artist.ID,
|
||||||
|
DurationMs: 1000, FilePath: f.ripPath, FileSize: 100, FileFormat: "mp3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.rip = rip
|
||||||
|
exec := func(sql string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
||||||
|
t.Fatalf("exec %q: %v", sql, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exec(`UPDATE tracks SET title = 'Saturnz Barz' WHERE id = $1`, f.clean.ID)
|
||||||
|
exec(`UPDATE albums SET release_group_mbid = 'rg-humanz' WHERE id = $1`, album.ID)
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO duplicate_groups (member_key, tier) VALUES ('resolve-fixture', 'exact') RETURNING id`,
|
||||||
|
).Scan(&f.groupID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec(`INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`, f.groupID, f.clean.ID, f.rip.ID)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// lidarrPath is where Lidarr, mounted elsewhere, sees a fixture file.
|
||||||
|
func lidarrPath(p string) string { return "/music/" + LidarrPathKey(p) }
|
||||||
|
|
||||||
|
func (f resolveFixture) group(t *testing.T) (status string, class *string, note *string, auto bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := f.pool.QueryRow(context.Background(),
|
||||||
|
`SELECT status, class, resolve_note, resolved_automatically FROM duplicate_groups WHERE id = $1`, f.groupID,
|
||||||
|
).Scan(&status, &class, ¬e, &auto); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return status, class, note, auto
|
||||||
|
}
|
||||||
|
|
||||||
|
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
|
||||||
|
|
||||||
|
// Lidarr maps the clean copy and holds the rip unmapped: the rip goes, the
|
||||||
|
// clean copy stays, and the merge is recorded as the resolver's.
|
||||||
|
func TestResolveDuplicates_MergesTheUnmappedCopy_Integration(t *testing.T) {
|
||||||
|
f := newResolveFixture(t)
|
||||||
|
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
|
||||||
|
|
||||||
|
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if res.Merged != 1 || !res.LidarrConsulted {
|
||||||
|
t.Fatalf("result = %+v, want one merge with Lidarr consulted", res)
|
||||||
|
}
|
||||||
|
if exists(f.ripPath) || !exists(f.cleanPath) {
|
||||||
|
t.Errorf("rip exists=%v clean exists=%v; want only the clean copy left", exists(f.ripPath), exists(f.cleanPath))
|
||||||
|
}
|
||||||
|
status, class, _, auto := f.group(t)
|
||||||
|
if status != "merged" || class == nil || *class != string(ClassSameRelease) || !auto {
|
||||||
|
t.Errorf("group = (%s, %v, auto=%v), want merged same_release by the resolver", status, class, auto)
|
||||||
|
}
|
||||||
|
var audits int
|
||||||
|
if err := f.pool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM audit_log WHERE action = 'duplicate_merge' AND actor_id IS NULL
|
||||||
|
AND metadata->>'group_id' = $1 AND (metadata->>'automatic')::boolean`,
|
||||||
|
syncpkg.FormatUUID(f.groupID)).Scan(&audits); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if audits != 1 {
|
||||||
|
t.Errorf("automatic merge audit rows = %d, want 1", audits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lidarr maps both copies, and its release lists the song twice: nothing is
|
||||||
|
// deleted, the release moves to the one listing each song once, and the next
|
||||||
|
// pass leaves it there (lesson #4183's fixed point).
|
||||||
|
func TestResolveDuplicates_ChangesARepeatingRelease_Integration(t *testing.T) {
|
||||||
|
f := newResolveFixture(t)
|
||||||
|
lid := &fakeLidarr{
|
||||||
|
album: lidarr.LidarrAlbum{ID: 4723, ForeignAlbumID: "rg-humanz", Title: "Humanz"},
|
||||||
|
releases: []lidarr.AlbumRelease{
|
||||||
|
{ID: 10148, Format: "14x12\" Vinyl, Digital Media", TrackCount: 4, Monitored: true},
|
||||||
|
{ID: 10149, Format: "Digital Media", TrackCount: 2},
|
||||||
|
},
|
||||||
|
releaseTracks: map[int][]lidarr.ReleaseTrack{
|
||||||
|
10148: rt("Saturnz Barz", "Ascension", "Saturnz Barz", "Ascension"),
|
||||||
|
10149: rt("Saturnz Barz", "Ascension"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if res.Merged != 0 || !exists(f.ripPath) || !exists(f.cleanPath) {
|
||||||
|
t.Fatalf("merged %d; a copy Lidarr maps was touched", res.Merged)
|
||||||
|
}
|
||||||
|
if len(lid.setCalls) != 1 || lid.setCalls[0] != [2]int{4723, 10149} {
|
||||||
|
t.Fatalf("release changes = %v, want album 4723 to release 10149", lid.setCalls)
|
||||||
|
}
|
||||||
|
status, _, note, _ := f.group(t)
|
||||||
|
if status != "pending" || note == nil || *note == "" {
|
||||||
|
t.Errorf("group = (%s, note %v), want pending with a note saying what changed", status, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next pass: Lidarr has not rescanned yet, so both copies still read
|
||||||
|
// as tracked. The album is settling, so nothing changes.
|
||||||
|
if _, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true); err != nil {
|
||||||
|
t.Fatalf("second resolve: %v", err)
|
||||||
|
}
|
||||||
|
if len(lid.setCalls) != 1 {
|
||||||
|
t.Errorf("release changed again: %v", lid.setCalls)
|
||||||
|
}
|
||||||
|
// And once it has settled, the check itself finds nothing to do: the
|
||||||
|
// release it chose lists each song once.
|
||||||
|
change, _, err := changeRelease(context.Background(), dbq.New(f.pool), lid,
|
||||||
|
&repeatingAlbum{albumID: f.clean.AlbumID, releaseGroupMbid: "rg-humanz"})
|
||||||
|
if err != nil || change != nil || len(lid.setCalls) != 1 {
|
||||||
|
t.Errorf("after the change, changeRelease = (%+v, %v) with %d calls; want no change", change, err, len(lid.setCalls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a release change Lidarr unmaps every file of the album until it has
|
||||||
|
// rescanned. A pass in that window must not read "every copy unmapped" as
|
||||||
|
// licence to merge: the copy it would remove may be the one Lidarr maps next.
|
||||||
|
func TestResolveDuplicates_LeavesAnAlbumAloneWhileLidarrRescans_Integration(t *testing.T) {
|
||||||
|
f := newResolveFixture(t)
|
||||||
|
lid := &fakeLidarr{
|
||||||
|
album: lidarr.LidarrAlbum{ID: 4723, ForeignAlbumID: "rg-humanz"},
|
||||||
|
releases: []lidarr.AlbumRelease{
|
||||||
|
{ID: 10148, TrackCount: 2, Monitored: true}, {ID: 10149, TrackCount: 1},
|
||||||
|
},
|
||||||
|
releaseTracks: map[int][]lidarr.ReleaseTrack{10148: rt("Saturnz Barz", "Saturnz Barz"), 10149: rt("Saturnz Barz")},
|
||||||
|
}
|
||||||
|
if _, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lid.unmapped = []string{lidarrPath(f.cleanPath), lidarrPath(f.ripPath)}
|
||||||
|
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", lid, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Merged != 0 || !exists(f.ripPath) || !exists(f.cleanPath) {
|
||||||
|
t.Errorf("merged %d during the rescan window", res.Merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without Lidarr, or with auto-resolve off, the pass classifies and removes
|
||||||
|
// nothing.
|
||||||
|
func TestResolveDuplicates_ClassifiesOnlyWithoutLidarrOrPermission_Integration(t *testing.T) {
|
||||||
|
for name, run := range map[string]func(resolveFixture) (DuplicateResolveResult, error){
|
||||||
|
"lidarr disabled": func(f resolveFixture) (DuplicateResolveResult, error) {
|
||||||
|
return ResolveDuplicates(context.Background(), f.pool, nil, "", nil, true)
|
||||||
|
},
|
||||||
|
"lidarr unreachable": func(f resolveFixture) (DuplicateResolveResult, error) {
|
||||||
|
return ResolveDuplicates(context.Background(), f.pool, nil, "", &fakeLidarr{unmappedErr: errors.New("timeout")}, true)
|
||||||
|
},
|
||||||
|
"auto-resolve off": func(f resolveFixture) (DuplicateResolveResult, error) {
|
||||||
|
lid := &fakeLidarr{unmapped: []string{lidarrPath(f.ripPath)}}
|
||||||
|
return ResolveDuplicates(context.Background(), f.pool, nil, "", lid, false)
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
f := newResolveFixture(t)
|
||||||
|
res, err := run(f)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Merged != 0 || !exists(f.ripPath) {
|
||||||
|
t.Errorf("merged %d", res.Merged)
|
||||||
|
}
|
||||||
|
status, class, _, _ := f.group(t)
|
||||||
|
if status != "pending" || class == nil || *class != string(ClassSameRelease) {
|
||||||
|
t.Errorf("group = (%s, %v), want pending and classified", status, class)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The guard itself: a merge asked to remove a copy the check refuses changes
|
||||||
|
// nothing, file or row.
|
||||||
|
func TestMergeDuplicateGroupGuarded_RefusesATrackedCopy_Integration(t *testing.T) {
|
||||||
|
f := newResolveFixture(t)
|
||||||
|
refuse := func(p string) bool { return p != f.ripPath }
|
||||||
|
_, err := MergeDuplicateGroupGuarded(context.Background(), f.pool, nil, "", f.groupID, f.clean.ID, refuse)
|
||||||
|
if !errors.Is(err, ErrCopyTrackedByLidarr) {
|
||||||
|
t.Fatalf("err = %v, want ErrCopyTrackedByLidarr", err)
|
||||||
|
}
|
||||||
|
if !exists(f.ripPath) {
|
||||||
|
t.Error("the refused copy's file was removed")
|
||||||
|
}
|
||||||
|
if status, _, _, _ := f.group(t); status != "pending" {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rip that is the only copy of its song: Lidarr is asked to search its album
|
||||||
|
// for a better release (#5447), once a week at most, and the rip is not touched.
|
||||||
|
// A rip with a clean copy elsewhere is not searched for.
|
||||||
|
func TestResolveDuplicates_SearchesForASoleCopyRip_Integration(t *testing.T) {
|
||||||
|
f := newCrossReleaseRipFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
exec := func(sql string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := f.pool.Exec(ctx, sql, args...); err != nil {
|
||||||
|
t.Fatalf("exec %q: %v", sql, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The single's rip has a clean copy on the album: never searched for.
|
||||||
|
exec(`UPDATE albums SET release_group_mbid = 'rg-single' WHERE id = $1`, f.rip.AlbumID)
|
||||||
|
lid := &fakeLidarr{album: lidarr.LidarrAlbum{ID: 77, ForeignAlbumID: "rg-single"}}
|
||||||
|
if _, err := ResolveDuplicates(ctx, f.pool, nil, "", lid, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(lid.searchCalls) != 0 {
|
||||||
|
t.Fatalf("searched %v while a clean copy exists", lid.searchCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the group and the song link, the rip is the only copy.
|
||||||
|
exec(`DELETE FROM duplicate_groups WHERE id = $1`, f.groupID)
|
||||||
|
exec(`UPDATE tracks SET song_id = NULL WHERE id IN ($1, $2)`, f.rip.ID, f.clean.ID)
|
||||||
|
res, err := ResolveDuplicates(ctx, f.pool, nil, "", lid, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.RipSearches != 1 || len(lid.searchCalls) != 1 || len(lid.searchCalls[0]) != 1 || lid.searchCalls[0][0] != 77 {
|
||||||
|
t.Fatalf("rip searches %d, calls %v; want album 77 searched once", res.RipSearches, lid.searchCalls)
|
||||||
|
}
|
||||||
|
if !exists(f.ripPath) {
|
||||||
|
t.Error("the rip was removed; a search must not touch it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next pass, inside the week, leaves the album alone.
|
||||||
|
if res, err := ResolveDuplicates(ctx, f.pool, nil, "", lid, true); err != nil || res.RipSearches != 0 {
|
||||||
|
t.Errorf("second pass searched %d (%v), want 0", res.RipSearches, err)
|
||||||
|
}
|
||||||
|
var searchedAt pgtype.Timestamptz
|
||||||
|
rows, err := dbq.New(f.pool).ListSuspectSourceTracks(ctx, dbq.ListSuspectSourceTracksParams{
|
||||||
|
Pattern: SuspectSourcePattern, PageLimit: 10,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.ID == f.rip.ID {
|
||||||
|
searchedAt = r.SearchedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !searchedAt.Valid {
|
||||||
|
t.Error("the suspect-sources report doesn't show the search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lidarr applies the release change but the PUT times out (Cracker Island on
|
||||||
|
// the first deploy pass). The change is read back and recorded, so the album
|
||||||
|
// settles; when it did not land, the error stands and nothing is recorded.
|
||||||
|
func TestResolveDuplicates_RecordsAReleaseChangeLidarrDidNotAnswer_Integration(t *testing.T) {
|
||||||
|
for name, landed := range map[string]bool{"landed": true, "did not land": false} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
f := newResolveFixture(t)
|
||||||
|
lid := &fakeLidarr{
|
||||||
|
album: lidarr.LidarrAlbum{ID: 4706, ForeignAlbumID: "rg-humanz", Title: "Cracker Island"},
|
||||||
|
releases: []lidarr.AlbumRelease{
|
||||||
|
{ID: 1, Format: "Digital Media", TrackCount: 4, Monitored: true},
|
||||||
|
{ID: 2, Format: "Digital Media", TrackCount: 2},
|
||||||
|
},
|
||||||
|
releaseTracks: map[int][]lidarr.ReleaseTrack{
|
||||||
|
1: rt("Saturnz Barz", "Ascension", "Saturnz Barz", "Ascension"),
|
||||||
|
2: rt("Saturnz Barz", "Ascension"),
|
||||||
|
},
|
||||||
|
setErr: fmt.Errorf("%w: context deadline exceeded", lidarr.ErrUnreachable),
|
||||||
|
}
|
||||||
|
if !landed {
|
||||||
|
lid.setErr = fmt.Errorf("%w: connection refused", lidarr.ErrUnreachable)
|
||||||
|
}
|
||||||
|
// ResetDB keeps audit_log, so other tests' release changes are
|
||||||
|
// still in it: count what this pass adds.
|
||||||
|
audited := func() (n int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := f.pool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM audit_log WHERE action = 'lidarr_release_change'`).Scan(&n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
before := audited()
|
||||||
|
res, err := ResolveDuplicates(context.Background(), f.pool, nil, "", &undoingLidarr{fakeLidarr: lid, undo: !landed}, true)
|
||||||
|
if landed {
|
||||||
|
if err != nil || len(res.ReleaseChanges) != 1 {
|
||||||
|
t.Fatalf("changes %d (%v), want the landed change recorded", len(res.ReleaseChanges), err)
|
||||||
|
}
|
||||||
|
} else if len(res.ReleaseChanges) != 0 {
|
||||||
|
t.Fatalf("recorded %d changes that did not land", len(res.ReleaseChanges))
|
||||||
|
}
|
||||||
|
if got, want := audited()-before, map[bool]int{true: 1, false: 0}[landed]; got != want {
|
||||||
|
t.Errorf("audited %d release changes, want %d", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// undoingLidarr is a Lidarr whose release change fails without landing when
|
||||||
|
// undo is set: the PUT never reached it.
|
||||||
|
type undoingLidarr struct {
|
||||||
|
*fakeLidarr
|
||||||
|
undo bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *undoingLidarr) SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error {
|
||||||
|
if !u.undo {
|
||||||
|
return u.fakeLidarr.SetMonitoredRelease(ctx, albumID, releaseID)
|
||||||
|
}
|
||||||
|
u.setCalls = append(u.setCalls, [2]int{albumID, releaseID})
|
||||||
|
return u.setErr
|
||||||
|
}
|
||||||
@@ -13,6 +13,36 @@ type SurvivorCandidate struct {
|
|||||||
FileFormat string
|
FileFormat string
|
||||||
FileSize int64
|
FileSize int64
|
||||||
AddedAt time.Time
|
AddedAt time.Time
|
||||||
|
|
||||||
|
// LidarrTracked: Lidarr maps this file to a track of the release it
|
||||||
|
// monitors. Removing it opens a hole Lidarr downloads again (M498), so a
|
||||||
|
// tracked copy outranks everything else. False when Lidarr was not asked.
|
||||||
|
LidarrTracked bool
|
||||||
|
// TagFit is TagFitScore: how well the copy's tags and name fit the album.
|
||||||
|
TagFit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagFitScore counts the signs that a copy belongs where it is filed (M498
|
||||||
|
// #5436), one point each:
|
||||||
|
// - it has a track number that no other track on its album also claims
|
||||||
|
// - its file name carries no video-rip marker (SourceMarkersFor)
|
||||||
|
// - it has a MusicBrainz recording id
|
||||||
|
//
|
||||||
|
// Two copies of one song differ in these far more often than in anything a
|
||||||
|
// listener hears: the Humanz rips differ by a few bytes, and file size picked
|
||||||
|
// the wrong one in 6 of 21 groups.
|
||||||
|
func TagFitScore(hasTrackNumber, positionClash bool, filePath string, hasMBID bool) int {
|
||||||
|
score := 0
|
||||||
|
if hasTrackNumber && !positionClash {
|
||||||
|
score++
|
||||||
|
}
|
||||||
|
if len(SourceMarkersFor(filePath)) == 0 {
|
||||||
|
score++
|
||||||
|
}
|
||||||
|
if hasMBID {
|
||||||
|
score++
|
||||||
|
}
|
||||||
|
return score
|
||||||
}
|
}
|
||||||
|
|
||||||
// losslessFormats are the scanned extensions that are lossless by definition.
|
// losslessFormats are the scanned extensions that are lossless by definition.
|
||||||
@@ -26,6 +56,9 @@ var losslessFormats = map[string]bool{"flac": true, "wav": true}
|
|||||||
// the report shows it and the merge (#3911) lets the operator choose another.
|
// the report shows it and the merge (#3911) lets the operator choose another.
|
||||||
//
|
//
|
||||||
// In order:
|
// In order:
|
||||||
|
// 0. the copy Lidarr maps, then the copy whose tags fit the album best
|
||||||
|
// (TagFitScore). Both are zero for every copy when the caller does not
|
||||||
|
// know them, and the order falls through to the quality rules below
|
||||||
// 1. lossless over lossy — the one difference no later step can recover
|
// 1. lossless over lossy — the one difference no later step can recover
|
||||||
// 2. the larger file — for one recording at one duration that is the higher
|
// 2. the larger file — for one recording at one duration that is the higher
|
||||||
// bitrate. The scanner does not record bitrate (tracks.bitrate is never
|
// bitrate. The scanner does not record bitrate (tracks.bitrate is never
|
||||||
@@ -48,6 +81,10 @@ func ProposeSurvivor(cands []SurvivorCandidate) (trackID, reason string) {
|
|||||||
// runner-up — the rule that actually decided, not every rule it passed.
|
// runner-up — the rule that actually decided, not every rule it passed.
|
||||||
next := ranked[1]
|
next := ranked[1]
|
||||||
switch {
|
switch {
|
||||||
|
case best.LidarrTracked != next.LidarrTracked:
|
||||||
|
return best.TrackID, "the copy Lidarr tracks"
|
||||||
|
case best.TagFit != next.TagFit:
|
||||||
|
return best.TrackID, "tags fit the album"
|
||||||
case isLossless(best) != isLossless(next):
|
case isLossless(best) != isLossless(next):
|
||||||
return best.TrackID, "lossless (" + strings.ToLower(best.FileFormat) + ")"
|
return best.TrackID, "lossless (" + strings.ToLower(best.FileFormat) + ")"
|
||||||
case best.FileSize != next.FileSize:
|
case best.FileSize != next.FileSize:
|
||||||
@@ -60,6 +97,12 @@ func ProposeSurvivor(cands []SurvivorCandidate) (trackID, reason string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func survivorBefore(a, b SurvivorCandidate) bool {
|
func survivorBefore(a, b SurvivorCandidate) bool {
|
||||||
|
if a.LidarrTracked != b.LidarrTracked {
|
||||||
|
return a.LidarrTracked
|
||||||
|
}
|
||||||
|
if a.TagFit != b.TagFit {
|
||||||
|
return a.TagFit > b.TagFit
|
||||||
|
}
|
||||||
if isLossless(a) != isLossless(b) {
|
if isLossless(a) != isLossless(b) {
|
||||||
return isLossless(a)
|
return isLossless(a)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,3 +89,66 @@ func TestProposeSurvivor_ReasonIsTheDecidingRule(t *testing.T) {
|
|||||||
t.Fatalf("got (%q, %q), want (flac-big, largest file)", id, reason)
|
t.Fatalf("got (%q, %q), want (flac-big, largest file)", id, reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M498 #5436: the copy Lidarr maps outranks everything, then the copy whose
|
||||||
|
// tags fit the album; quality decides only between copies equal on both. Each
|
||||||
|
// case is built so the older rules would choose the other copy, so a reorder
|
||||||
|
// that drops the new rules fails.
|
||||||
|
func TestProposeSurvivor_LidarrThenTagFit(t *testing.T) {
|
||||||
|
at := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cands []SurvivorCandidate
|
||||||
|
wantID string
|
||||||
|
wantReason string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "tracked beats lossless and better tags",
|
||||||
|
cands: []SurvivorCandidate{
|
||||||
|
{TrackID: "flac", FileFormat: "flac", FileSize: 40_000_000, AddedAt: at, TagFit: 3},
|
||||||
|
{TrackID: "tracked", FileFormat: "mp3", FileSize: 5_000_000, AddedAt: at, TagFit: 1, LidarrTracked: true},
|
||||||
|
},
|
||||||
|
wantID: "tracked", wantReason: "the copy Lidarr tracks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The Humanz shape: two rips a few bytes apart, one carrying a
|
||||||
|
// video marker and clashing with another track's position.
|
||||||
|
name: "tag fit beats a few bytes",
|
||||||
|
cands: []SurvivorCandidate{
|
||||||
|
{TrackID: "rip", FileFormat: "mp3", FileSize: 5_104_498, AddedAt: at, TagFit: 1},
|
||||||
|
{TrackID: "clean", FileFormat: "mp3", FileSize: 5_104_492, AddedAt: at, TagFit: 3},
|
||||||
|
},
|
||||||
|
wantID: "clean", wantReason: "tags fit the album",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
id, reason := ProposeSurvivor(tc.cands)
|
||||||
|
if id != tc.wantID || reason != tc.wantReason {
|
||||||
|
t.Fatalf("ProposeSurvivor = (%q, %q), want (%q, %q)", id, reason, tc.wantID, tc.wantReason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagFitScore(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
hasTrackNumber bool
|
||||||
|
clash bool
|
||||||
|
path string
|
||||||
|
hasMBID bool
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"clean, numbered, identified", true, false, "/music/A/B/A - B - 01 - Song.flac", true, 3},
|
||||||
|
{"position taken by another track", true, true, "/music/A/B/A - B - 01 - Song.flac", true, 2},
|
||||||
|
{"no track number", false, false, "/music/A/B/Song.flac", true, 2},
|
||||||
|
{"video rip", true, false, "/music/A/B/A - B - 01 - Song (Official Video).mp3", true, 2},
|
||||||
|
{"rip with nothing else", false, false, "/music/A/B/Song (Official Video).mp3", false, 0},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := TagFitScore(tc.hasTrackNumber, tc.clash, tc.path, tc.hasMBID); got != tc.want {
|
||||||
|
t.Errorf("%s: TagFitScore = %d, want %d", tc.name, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ type FingerprintSettings struct {
|
|||||||
AcousticMaxBitErrorRate float64
|
AcousticMaxBitErrorRate float64
|
||||||
BackfillConcurrency int32
|
BackfillConcurrency int32
|
||||||
SweepIntervalHours int32
|
SweepIntervalHours int32
|
||||||
|
// AutoResolve lets the duplicate resolver (M498) act on its own: merge
|
||||||
|
// copies Lidarr does not need and change a Lidarr release that lists songs
|
||||||
|
// twice. Off, it only classifies, and every group waits for the operator.
|
||||||
|
AutoResolve bool
|
||||||
// UpdatedAt is when the settings were last saved. Set by the database;
|
// UpdatedAt is when the settings were last saved. Set by the database;
|
||||||
// ignored by Set.
|
// ignored by Set.
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
@@ -68,6 +72,7 @@ var DefaultFingerprintSettings = FingerprintSettings{
|
|||||||
AcousticMaxBitErrorRate: defaultAcousticMaxBitErrorRate,
|
AcousticMaxBitErrorRate: defaultAcousticMaxBitErrorRate,
|
||||||
BackfillConcurrency: fingerprintBackfillConcurrency,
|
BackfillConcurrency: fingerprintBackfillConcurrency,
|
||||||
SweepIntervalHours: defaultSweepIntervalHours,
|
SweepIntervalHours: defaultSweepIntervalHours,
|
||||||
|
AutoResolve: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrFingerprintSettingOutOfRange is returned by Set for a value migration
|
// ErrFingerprintSettingOutOfRange is returned by Set for a value migration
|
||||||
@@ -121,6 +126,7 @@ func (s *FingerprintSettingsService) Set(ctx context.Context, in FingerprintSett
|
|||||||
AcousticMaxBitErrorRate: in.AcousticMaxBitErrorRate,
|
AcousticMaxBitErrorRate: in.AcousticMaxBitErrorRate,
|
||||||
BackfillConcurrency: in.BackfillConcurrency,
|
BackfillConcurrency: in.BackfillConcurrency,
|
||||||
SweepIntervalHours: in.SweepIntervalHours,
|
SweepIntervalHours: in.SweepIntervalHours,
|
||||||
|
AutoResolve: in.AutoResolve,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return FingerprintSettings{}, fmt.Errorf("fingerprint settings: save: %w", err)
|
return FingerprintSettings{}, fmt.Errorf("fingerprint settings: save: %w", err)
|
||||||
@@ -158,6 +164,7 @@ func fingerprintSettingsFromRow(row dbq.FingerprintSetting) FingerprintSettings
|
|||||||
AcousticMaxBitErrorRate: row.AcousticMaxBitErrorRate,
|
AcousticMaxBitErrorRate: row.AcousticMaxBitErrorRate,
|
||||||
BackfillConcurrency: row.BackfillConcurrency,
|
BackfillConcurrency: row.BackfillConcurrency,
|
||||||
SweepIntervalHours: row.SweepIntervalHours,
|
SweepIntervalHours: row.SweepIntervalHours,
|
||||||
|
AutoResolve: row.AutoResolve,
|
||||||
UpdatedAt: row.UpdatedAt.Time,
|
UpdatedAt: row.UpdatedAt.Time,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ func TestFingerprintSettingsService_Integration(t *testing.T) {
|
|||||||
AcousticMaxBitErrorRate: minAcousticMaxBitErrorRate,
|
AcousticMaxBitErrorRate: minAcousticMaxBitErrorRate,
|
||||||
BackfillConcurrency: minBackfillConcurrency,
|
BackfillConcurrency: minBackfillConcurrency,
|
||||||
SweepIntervalHours: minSweepIntervalHours,
|
SweepIntervalHours: minSweepIntervalHours,
|
||||||
|
AutoResolve: false,
|
||||||
}
|
}
|
||||||
highest := FingerprintSettings{
|
highest := FingerprintSettings{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@@ -109,6 +110,7 @@ func TestFingerprintSettingsService_Integration(t *testing.T) {
|
|||||||
AcousticMaxBitErrorRate: maxAcousticMaxBitErrorRate,
|
AcousticMaxBitErrorRate: maxAcousticMaxBitErrorRate,
|
||||||
BackfillConcurrency: maxBackfillConcurrency,
|
BackfillConcurrency: maxBackfillConcurrency,
|
||||||
SweepIntervalHours: maxSweepIntervalHours,
|
SweepIntervalHours: maxSweepIntervalHours,
|
||||||
|
AutoResolve: true,
|
||||||
}
|
}
|
||||||
for _, step := range []struct {
|
for _, step := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
// songLinkFixture is the single and the album it came from: one song on two
|
||||||
|
// releases, in one pending acoustic group, and a user who liked the single.
|
||||||
|
type songLinkFixture struct {
|
||||||
|
single, albumCut dbq.Track
|
||||||
|
groupID pgtype.UUID
|
||||||
|
user dbq.User
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSongLinkFixture(t *testing.T, pool *pgxpool.Pool) songLinkFixture {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
exec := func(sql string, args ...any) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
||||||
|
t.Fatalf("exec %q: %v", sql, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Link Artist", SortName: "Link Artist"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
track := func(album, path string) dbq.Track {
|
||||||
|
t.Helper()
|
||||||
|
a, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: album, SortTitle: album, ArtistID: artist.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||||
|
Title: "Feel Good Inc.", AlbumID: a.ID, ArtistID: artist.ID,
|
||||||
|
DurationMs: 1000, FilePath: path, FileSize: 100, FileFormat: "mp3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
f := songLinkFixture{
|
||||||
|
single: track("Feel Good Inc. (single)", "/music/Link Artist/Feel Good Inc/01 Feel Good Inc.mp3"),
|
||||||
|
albumCut: track("Demon Days", "/music/Link Artist/Demon Days/06 Feel Good Inc.mp3"),
|
||||||
|
}
|
||||||
|
f.user, err = q.CreateUser(ctx, dbq.CreateUserParams{
|
||||||
|
Username: dbtest.TestUserPrefix + "song-link", PasswordHash: "x", ApiTokenHash: "song-link-token",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO duplicate_groups (member_key, tier) VALUES ('song-link', 'acoustic') RETURNING id`,
|
||||||
|
).Scan(&f.groupID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
exec(`INSERT INTO duplicate_group_members (group_id, track_id) VALUES ($1, $2), ($1, $3)`, f.groupID, f.single.ID, f.albumCut.ID)
|
||||||
|
exec(`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, f.user.ID, f.single.ID)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cross-release group becomes one song: its copies share a song key, the
|
||||||
|
// like on the single reaches the album cut (and is logged for sync), the Liked
|
||||||
|
// list shows the song once, a like or unlike on either copy reaches both, and
|
||||||
|
// "not the same song" undoes the link.
|
||||||
|
func TestCrossReleaseCopiesCountAsOneSong_Integration(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
f := newSongLinkFixture(t, pool)
|
||||||
|
|
||||||
|
res, err := ResolveDuplicates(ctx, pool, nil, "", nil, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Classes[ClassCrossRelease] != 1 || res.SongsLinked != 1 {
|
||||||
|
t.Fatalf("classes %v, linked %d; want one cross-release group linked", res.Classes, res.SongsLinked)
|
||||||
|
}
|
||||||
|
keys := songKeys(t, q, f.single.ID, f.albumCut.ID)
|
||||||
|
if keys[0] != keys[1] {
|
||||||
|
t.Fatalf("song keys differ after linking: %v", keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
liked, err := q.ListLikedTrackIDs(ctx, f.user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(liked) != 2 {
|
||||||
|
t.Errorf("liked track ids = %d, want both copies", len(liked))
|
||||||
|
}
|
||||||
|
var logged int
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM library_changes WHERE entity_type = 'like_track' AND op = 'upsert'`,
|
||||||
|
).Scan(&logged); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if logged != 1 {
|
||||||
|
t.Errorf("logged %d shared likes, want 1 (the album cut)", logged)
|
||||||
|
}
|
||||||
|
if n, err := q.CountLikedTracks(ctx, f.user.ID); err != nil || n != 1 {
|
||||||
|
t.Errorf("CountLikedTracks = %d (%v), want 1 song", n, err)
|
||||||
|
}
|
||||||
|
rows, err := q.ListLikedTrackRows(ctx, dbq.ListLikedTrackRowsParams{UserID: f.user.ID, Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Errorf("Liked list has %d rows, want the song once", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second pass changes nothing.
|
||||||
|
if res, err := ResolveDuplicates(ctx, pool, nil, "", nil, true); err != nil || res.SongsLinked != 0 {
|
||||||
|
t.Errorf("second pass linked %d (%v), want 0", res.SongsLinked, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unliked, err := q.UnlikeTrack(ctx, dbq.UnlikeTrackParams{UserID: f.user.ID, TrackID: f.albumCut.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(unliked) != 2 {
|
||||||
|
t.Errorf("unlike removed %d likes, want both copies", len(unliked))
|
||||||
|
}
|
||||||
|
added, err := q.LikeTrack(ctx, dbq.LikeTrackParams{UserID: f.user.ID, TrackID: f.single.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(added) != 2 {
|
||||||
|
t.Errorf("like added %d, want both copies", len(added))
|
||||||
|
}
|
||||||
|
if again, err := q.LikeTrack(ctx, dbq.LikeTrackParams{UserID: f.user.ID, TrackID: f.single.ID}); err != nil || len(again) != 0 {
|
||||||
|
t.Errorf("repeat like added %d (%v), want 0", len(again), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.UnlinkDuplicateGroupSongs(ctx, f.groupID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keys = songKeys(t, q, f.single.ID, f.albumCut.ID)
|
||||||
|
if keys[0] == keys[1] {
|
||||||
|
t.Error("song keys still shared after unlinking")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without the operator's permission the resolver links nothing.
|
||||||
|
func TestCrossReleaseLinkNeedsAutoResolve_Integration(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
f := newSongLinkFixture(t, pool)
|
||||||
|
if _, err := ResolveDuplicates(ctx, pool, nil, "", nil, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keys := songKeys(t, q, f.single.ID, f.albumCut.ID)
|
||||||
|
if keys[0] == keys[1] {
|
||||||
|
t.Error("linked with auto-resolve off")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A merge keeps the removed copy's link to the song on another release,
|
||||||
|
// whichever copy's id names the song. That copy has no song_id of its own,
|
||||||
|
// and before the fix its link was lost whenever the random ids made it the
|
||||||
|
// key (the flaky failure in run 8891).
|
||||||
|
func TestMergeInheritsSongLink_Integration(t *testing.T) {
|
||||||
|
for _, removeAlbumCut := range []bool{true, false} {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
f := newSongLinkFixture(t, pool)
|
||||||
|
if _, err := q.LinkTracksAsSong(ctx, []pgtype.UUID{f.single.ID, f.albumCut.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loser, kept := f.albumCut, f.single
|
||||||
|
if !removeAlbumCut {
|
||||||
|
loser, kept = f.single, f.albumCut
|
||||||
|
}
|
||||||
|
// A third, unlinked copy of the removed track stands in for a survivor.
|
||||||
|
other, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||||
|
Title: loser.Title, AlbumID: loser.AlbumID, ArtistID: loser.ArtistID,
|
||||||
|
DurationMs: 1000, FilePath: loser.FilePath + ".copy.mp3", FileSize: 90, FileFormat: "mp3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := q.MergeInheritSongLink(ctx, dbq.MergeInheritSongLinkParams{SurvivorID: other.ID, LoserID: loser.ID}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := q.DeleteTrack(ctx, loser.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keys := songKeys(t, q, other.ID, kept.ID)
|
||||||
|
if keys[0] != keys[1] {
|
||||||
|
t.Errorf("removing the %s: survivor did not join the song", map[bool]string{true: "album cut", false: "single"}[removeAlbumCut])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func songKeys(t *testing.T, q *dbq.Queries, ids ...pgtype.UUID) []pgtype.UUID {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := q.ListTrackSongKeys(context.Background(), ids)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
byID := map[pgtype.UUID]pgtype.UUID{}
|
||||||
|
for _, r := range rows {
|
||||||
|
byID[r.ID] = r.SongKey
|
||||||
|
}
|
||||||
|
out := make([]pgtype.UUID, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
out[i] = byID[id]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sourceMarker is one sign that a file was ripped from a video rather than
|
||||||
|
// taken from a release: words a video title carries and an album track does
|
||||||
|
// not (#5410).
|
||||||
|
//
|
||||||
|
// Each pattern is used twice, so it is written in the subset that Postgres's
|
||||||
|
// regex flavour and Go's RE2 read the same way: no \b (Postgres spells word
|
||||||
|
// boundaries \y), no lookaround, and brackets written as (\(|\[) rather than
|
||||||
|
// as a bracket expression. The SQL side matches it case-insensitively with ~*,
|
||||||
|
// the Go side with (?i); [^a-zA-Z] is spelled out so neither has to fold case
|
||||||
|
// inside a negated class.
|
||||||
|
type sourceMarker struct {
|
||||||
|
label string
|
||||||
|
pattern string
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspectSourceMarkers was calibrated against the operator's library on
|
||||||
|
// 2026-10-08. Two candidates were left out on purpose:
|
||||||
|
// - "live in"/"live at": 229 files, nearly all from real live albums.
|
||||||
|
// - a bare "reaction": it caught Beck's "Chain Reaction". The marker below
|
||||||
|
// wants the phrases a reaction video actually uses.
|
||||||
|
var suspectSourceMarkers = []sourceMarker{
|
||||||
|
{"music video", `official[^a-zA-Z]*(music[^a-zA-Z]*)?video|music[^a-zA-Z]*video`},
|
||||||
|
{"official audio", `official[^a-zA-Z]*audio`},
|
||||||
|
{"lyric video", `lyrics?[^a-zA-Z]*video`},
|
||||||
|
{"visualiser", `visuali[sz]er`},
|
||||||
|
{"reaction", `reaction[^a-zA-Z]*(video|mashup)|reacts?[^a-zA-Z]+to[^a-zA-Z]|first[^a-zA-Z]*time[^a-zA-Z]*(hearing|listening)`},
|
||||||
|
{"MV", `(^|[^a-zA-Z])(mv|m/v)([^a-zA-Z]|$)`},
|
||||||
|
{"[Audio]", `(\(|\[)audio(\)|\])`},
|
||||||
|
{"[HD]", `(\(|\[)(hd|hq|4k)(\)|\])`},
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuspectSourcePattern is every marker as one alternation, for the SQL filter
|
||||||
|
// behind the admin suspect-sources report.
|
||||||
|
var SuspectSourcePattern = func() string {
|
||||||
|
parts := make([]string, len(suspectSourceMarkers))
|
||||||
|
for i, m := range suspectSourceMarkers {
|
||||||
|
parts[i] = "(" + m.pattern + ")"
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "|")
|
||||||
|
}()
|
||||||
|
|
||||||
|
var suspectSourceRegexps = func() []*regexp.Regexp {
|
||||||
|
out := make([]*regexp.Regexp, len(suspectSourceMarkers))
|
||||||
|
for i, m := range suspectSourceMarkers {
|
||||||
|
out[i] = regexp.MustCompile("(?i)" + m.pattern)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}()
|
||||||
|
|
||||||
|
// SourceMarkersFor returns the labels of every marker the file's basename
|
||||||
|
// carries, in list order. Empty for a file the SQL filter would not return.
|
||||||
|
func SourceMarkersFor(filePath string) []string {
|
||||||
|
base := path.Base(filePath)
|
||||||
|
labels := make([]string, 0, 2)
|
||||||
|
for i, re := range suspectSourceRegexps {
|
||||||
|
if re.MatchString(base) {
|
||||||
|
labels = append(labels, suspectSourceMarkers[i].label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Real basenames from the operator's library (2026-10-08), each with the
|
||||||
|
// labels it should carry. The negatives are the near misses that shaped the
|
||||||
|
// list: a song called "Chain Reaction", a live album, an ordinary track.
|
||||||
|
func TestSourceMarkersFor(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
base string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"Daft Punk - Random Access Memories - 06 - Daft Punk - Doin' It Right (Music Video) ft. Panda Bear.mp3", []string{"music video"}},
|
||||||
|
{"Gorillaz - Humanz - 03 - Gorillaz - Saturnz Barz (Official Video).mp3", []string{"music video"}},
|
||||||
|
{"Gorillaz - Humanz - 05 - Gorillaz - Andromeda (Official Audio).mp3", []string{"official audio"}},
|
||||||
|
{"Gorillaz - Gorillaz - 06 - Gorillaz - P45 (Visualizer).mp3", []string{"visualiser"}},
|
||||||
|
{"Gorillaz - Demon Days - 16 - Gorillaz - Don Quixote's Christmas Bonanza (Visualiser).mp3", []string{"visualiser"}},
|
||||||
|
{"Artist - Album - 01 - Artist - Song (Lyric Video).mp3", []string{"lyric video"}},
|
||||||
|
{"Gorillaz - Humanz - 02 - FIRST TIME HEARING Gorillaz - Ascension REACTION.mp3", []string{"reaction"}},
|
||||||
|
{"Watsky - INTENTION - 05 - MANIAC Reacts to Watsky - AWW SHiT.mp3", []string{"reaction"}},
|
||||||
|
{"米津玄師 - diorama - 09 - 【MV】米津玄師 - 恋と病熱.mp3", []string{"MV"}},
|
||||||
|
{"Andora - Ego - 01 - Andora - Ego (feat. Will Stetson) MV.mp3", []string{"MV"}},
|
||||||
|
{"Jimmy Eat World - Something(s) Loud - 05 - Jimmy Eat World - Call to Love (Audio).mp3", []string{"[Audio]"}},
|
||||||
|
{"Record Heat - World War IV - 03 - Record Heat - Front Seat Feelin' [Audio].mp3", []string{"[Audio]"}},
|
||||||
|
{"Aphex Twin - Come To Daddy - 03 - Aphex Twin - Bucephalus Bouncing Ball (HQ).mp3", []string{"[HD]"}},
|
||||||
|
{"Artist - Album - 01 - Artist - Song (Official Lyric Video) [4K].mp3", []string{"lyric video", "[HD]"}},
|
||||||
|
|
||||||
|
{"Beck - Guero - 15 - Beck - Chain Reaction.mp3", nil},
|
||||||
|
{"Nirvana - MTV Unplugged in New York - 01 - About a Girl (live in New York).flac", nil},
|
||||||
|
{"Boards of Canada - Music Has the Right to Children - 05 - Roygbiv.flac", nil},
|
||||||
|
{"Artist - Album - 01 - Mvula.flac", nil},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := SourceMarkersFor("/music/x/" + c.base)
|
||||||
|
if len(got) == 0 && len(c.want) == 0 {
|
||||||
|
continue // nil and empty both mean "no markers"
|
||||||
|
}
|
||||||
|
if !slices.Equal(got, c.want) {
|
||||||
|
t.Errorf("%s:\n got %v\n want %v", c.base, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The folder is not evidence: a marker word in a directory name must not
|
||||||
|
// flag the files inside it.
|
||||||
|
func TestSourceMarkersFor_ReadsOnlyTheBasename(t *testing.T) {
|
||||||
|
if got := SourceMarkersFor("/music/Official Video Collection/01 - Song.flac"); len(got) != 0 {
|
||||||
|
t.Errorf("directory name flagged the file: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package lidarr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What Lidarr holds on disk and how it maps it (Scribe milestone #498).
|
||||||
|
//
|
||||||
|
// Lidarr maps one file to each track of the ONE release it monitors per album.
|
||||||
|
// A file it holds but maps to no track is "unmapped": removing it opens no hole.
|
||||||
|
// Removing a mapped file does, and Lidarr fills the hole by downloading again —
|
||||||
|
// which is why Minstrel's duplicate resolver asks before it deletes anything.
|
||||||
|
|
||||||
|
// TrackFile is a file Lidarr holds on disk.
|
||||||
|
type TrackFile struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
AlbumID int `json:"albumId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUnmappedTrackFiles hits GET /api/v1/trackfile?unmapped=true: files under
|
||||||
|
// Lidarr's root folders that it has scanned but matched to no track.
|
||||||
|
func (c *Client) ListUnmappedTrackFiles(ctx context.Context) ([]TrackFile, error) {
|
||||||
|
resp, err := c.get(ctx, "/api/v1/trackfile", url.Values{"unmapped": {"true"}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
var out []TrackFile
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: decode unmapped track files: %v", ErrInvalidPayload, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlbumRelease is one edition of an album as Lidarr knows it: the 14×12" vinyl
|
||||||
|
// box set, the 2×CD deluxe, the digital standard. Exactly one is Monitored.
|
||||||
|
type AlbumRelease struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Disambiguation string `json:"disambiguation"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
TrackCount int `json:"trackCount"`
|
||||||
|
MediumCount int `json:"mediumCount"`
|
||||||
|
Monitored bool `json:"monitored"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseTrack is one track of a release.
|
||||||
|
type ReleaseTrack struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
MediumNumber int `json:"mediumNumber"`
|
||||||
|
TrackFileID int `json:"trackFileId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlbumReleases hits GET /api/v1/album/{id} and returns the album's releases.
|
||||||
|
func (c *Client) GetAlbumReleases(ctx context.Context, albumID int) ([]AlbumRelease, error) {
|
||||||
|
resp, err := c.get(ctx, "/api/v1/album/"+strconv.Itoa(albumID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
var album struct {
|
||||||
|
Releases []AlbumRelease `json:"releases"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&album); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: decode album: %v", ErrInvalidPayload, err)
|
||||||
|
}
|
||||||
|
return album.Releases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAlbumTracks hits GET /api/v1/track?albumId= : the tracks of the release
|
||||||
|
// Lidarr monitors for that album.
|
||||||
|
func (c *Client) ListAlbumTracks(ctx context.Context, albumID int) ([]ReleaseTrack, error) {
|
||||||
|
return c.listTracks(ctx, url.Values{"albumId": {strconv.Itoa(albumID)}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReleaseTracks hits GET /api/v1/track?albumReleaseId= : the tracks of one
|
||||||
|
// release, monitored or not.
|
||||||
|
func (c *Client) ListReleaseTracks(ctx context.Context, releaseID int) ([]ReleaseTrack, error) {
|
||||||
|
return c.listTracks(ctx, url.Values{"albumReleaseId": {strconv.Itoa(releaseID)}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) listTracks(ctx context.Context, q url.Values) ([]ReleaseTrack, error) {
|
||||||
|
resp, err := c.get(ctx, "/api/v1/track", q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
var out []ReleaseTrack
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: decode tracks: %v", ErrInvalidPayload, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMonitoredRelease makes releaseID the album's monitored release.
|
||||||
|
//
|
||||||
|
// It is the round trip Lidarr's own "edit album" makes: GET the album resource,
|
||||||
|
// flip `monitored` on its releases, PUT the whole resource back. Lidarr's
|
||||||
|
// UpdateAlbum publishes AlbumEditedEvent, and when the monitored release changed,
|
||||||
|
// AlbumEditedService unlinks the album's track files and queues
|
||||||
|
// RescanFoldersCommand for the artist's folder, which maps the files on disk to
|
||||||
|
// the new release's tracks. Files the new release has no track for come out of
|
||||||
|
// that rescan unmapped. (Lidarr source, develop: Music/Services/
|
||||||
|
// AlbumEditedService.cs and AlbumService.UpdateAlbum.)
|
||||||
|
//
|
||||||
|
// The resource is kept as raw JSON so every field Lidarr sent goes back
|
||||||
|
// unchanged; only the releases' monitored flags differ. Returns ErrNotFound when
|
||||||
|
// the album has no release with that id.
|
||||||
|
func (c *Client) SetMonitoredRelease(ctx context.Context, albumID, releaseID int) error {
|
||||||
|
path := "/api/v1/album/" + strconv.Itoa(albumID)
|
||||||
|
resp, err := c.get(ctx, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var album map[string]any
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&album)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: decode album: %v", ErrInvalidPayload, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
releases, _ := album["releases"].([]any)
|
||||||
|
found := false
|
||||||
|
for _, r := range releases {
|
||||||
|
rel, ok := r.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, _ := rel["id"].(float64)
|
||||||
|
match := int(id) == releaseID
|
||||||
|
rel["monitored"] = match
|
||||||
|
found = found || match
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("%w: release %d on album %d", ErrNotFound, releaseID, albumID)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(album)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: marshal album: %v", ErrInvalidPayload, err)
|
||||||
|
}
|
||||||
|
putResp, err := c.put(ctx, path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = putResp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchAlbums asks Lidarr to search its indexers for the albums
|
||||||
|
// (POST /api/v1/command, AlbumSearch). Lidarr grabs a release only when it
|
||||||
|
// beats the files the album already has under the quality profile, so for an
|
||||||
|
// album whose tracks are all present this is an upgrade search: harmless when
|
||||||
|
// nothing better exists. The command is queued; the call does not wait for it.
|
||||||
|
func (c *Client) SearchAlbums(ctx context.Context, albumIDs []int) error {
|
||||||
|
if len(albumIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(map[string]any{"name": "AlbumSearch", "albumIds": albumIDs})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: marshal command: %v", ErrInvalidPayload, err)
|
||||||
|
}
|
||||||
|
resp, err := c.post(ctx, "/api/v1/command", body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package lidarr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListUnmappedTrackFiles(t *testing.T) {
|
||||||
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/trackfile" || r.URL.Query().Get("unmapped") != "true" {
|
||||||
|
t.Errorf("request = %s?%s, want /api/v1/trackfile?unmapped=true", r.URL.Path, r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[{"id":7,"path":"/music/A/B (2017)/A - B - 02 - X.mp3","albumId":0}]`))
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
got, err := c.ListUnmappedTrackFiles(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUnmappedTrackFiles: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].ID != 7 || got[0].Path != "/music/A/B (2017)/A - B - 02 - X.mp3" {
|
||||||
|
t.Errorf("got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseReads(t *testing.T) {
|
||||||
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/v1/album/4723":
|
||||||
|
_, _ = w.Write([]byte(`{"id":4723,"releases":[
|
||||||
|
{"id":10148,"title":"Humanz","format":"14x12\" Vinyl, Digital Media","trackCount":68,"mediumCount":15,"monitored":true},
|
||||||
|
{"id":10149,"title":"Humanz","format":"Digital Media","trackCount":40,"mediumCount":1,"monitored":false}]}`))
|
||||||
|
case r.URL.Path == "/api/v1/track" && r.URL.Query().Get("albumId") == "4723":
|
||||||
|
_, _ = w.Write([]byte(`[{"id":1,"title":"Ascension","mediumNumber":1,"trackFileId":9}]`))
|
||||||
|
case r.URL.Path == "/api/v1/track" && r.URL.Query().Get("albumReleaseId") == "10149":
|
||||||
|
_, _ = w.Write([]byte(`[{"id":2,"title":"Ascension","mediumNumber":1,"trackFileId":0}]`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request %s?%s", r.URL.Path, r.URL.RawQuery)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
rels, err := c.GetAlbumReleases(ctx, 4723)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAlbumReleases: %v", err)
|
||||||
|
}
|
||||||
|
if len(rels) != 2 || !rels[0].Monitored || rels[0].MediumCount != 15 || rels[1].TrackCount != 40 {
|
||||||
|
t.Errorf("releases = %+v", rels)
|
||||||
|
}
|
||||||
|
tr, err := c.ListAlbumTracks(ctx, 4723)
|
||||||
|
if err != nil || len(tr) != 1 || tr[0].TrackFileID != 9 {
|
||||||
|
t.Errorf("ListAlbumTracks = %+v, %v", tr, err)
|
||||||
|
}
|
||||||
|
tr, err = c.ListReleaseTracks(ctx, 10149)
|
||||||
|
if err != nil || len(tr) != 1 || tr[0].Title != "Ascension" {
|
||||||
|
t.Errorf("ListReleaseTracks = %+v, %v", tr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The PUT carries the whole resource back, with only the releases' monitored
|
||||||
|
// flags changed: exactly the chosen release is monitored, and a field Minstrel
|
||||||
|
// does not model survives the round trip.
|
||||||
|
func TestSetMonitoredRelease_RoundTripsTheResource(t *testing.T) {
|
||||||
|
var put map[string]any
|
||||||
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/album/4723" {
|
||||||
|
t.Errorf("path = %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
_, _ = w.Write([]byte(`{"id":4723,"anyReleaseOk":false,"secondaryTypes":["Studio"],"releases":[
|
||||||
|
{"id":10148,"monitored":true},{"id":10149,"monitored":false}]}`))
|
||||||
|
case http.MethodPut:
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
if err := json.Unmarshal(b, &put); err != nil {
|
||||||
|
t.Errorf("PUT body: %v", err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if err := c.SetMonitoredRelease(context.Background(), 4723, 10149); err != nil {
|
||||||
|
t.Fatalf("SetMonitoredRelease: %v", err)
|
||||||
|
}
|
||||||
|
rels := put["releases"].([]any)
|
||||||
|
if m := rels[0].(map[string]any)["monitored"]; m != false {
|
||||||
|
t.Errorf("old release monitored = %v, want false", m)
|
||||||
|
}
|
||||||
|
if m := rels[1].(map[string]any)["monitored"]; m != true {
|
||||||
|
t.Errorf("new release monitored = %v, want true", m)
|
||||||
|
}
|
||||||
|
if st, _ := put["secondaryTypes"].([]any); len(st) != 1 || st[0] != "Studio" {
|
||||||
|
t.Errorf("secondaryTypes = %v, want it carried through unchanged", put["secondaryTypes"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetMonitoredRelease_UnknownReleaseSendsNothing(t *testing.T) {
|
||||||
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPut {
|
||||||
|
t.Error("PUT sent for a release the album does not have")
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"id":4723,"releases":[{"id":10148,"monitored":true}]}`))
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if err := c.SetMonitoredRelease(context.Background(), 4723, 999); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchAlbums_PostsTheCommand(t *testing.T) {
|
||||||
|
var got map[string]any
|
||||||
|
c, srv := newTestClient(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/command" {
|
||||||
|
t.Errorf("request = %s %s, want POST /api/v1/command", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(b, &got)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_, _ = w.Write([]byte(`{"id":1,"name":"AlbumSearch"}`))
|
||||||
|
})
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if err := c.SearchAlbums(context.Background(), []int{4723, 12}); err != nil {
|
||||||
|
t.Fatalf("SearchAlbums: %v", err)
|
||||||
|
}
|
||||||
|
ids, _ := got["albumIds"].([]any)
|
||||||
|
if got["name"] != "AlbumSearch" || len(ids) != 2 || ids[0] != float64(4723) || ids[1] != float64(12) {
|
||||||
|
t.Errorf("command = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,9 @@
|
|||||||
package notifications
|
package notifications
|
||||||
|
|
||||||
// Kind names one sort of notification. The set is CHECK-gated in migration
|
// Kind names one sort of notification. The set is CHECK-gated in migration
|
||||||
// 0073 (rule 36): a new kind adds its value there, in the same change, and
|
// 0073, swapped since by 0075 (rule 36): a new kind adds its value in a
|
||||||
// TestKindsMatchMigrationCheck fails until it does.
|
// migration of the same change, and TestEveryKindPassesTheSchemaChecks
|
||||||
|
// fails until it does.
|
||||||
type Kind string
|
type Kind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,7 +23,11 @@ const (
|
|||||||
KindScanFailed Kind = "scan_failed"
|
KindScanFailed Kind = "scan_failed"
|
||||||
KindTracksMissing Kind = "tracks_missing"
|
KindTracksMissing Kind = "tracks_missing"
|
||||||
KindDuplicatesFound Kind = "duplicates_found"
|
KindDuplicatesFound Kind = "duplicates_found"
|
||||||
KindPlaybackErrors Kind = "playback_errors"
|
// KindDuplicatesResolved: the duplicate resolver (M498) merged copies or
|
||||||
|
// changed a Lidarr release on its own. The operator asked for that to
|
||||||
|
// happen automatically; this is how it stays visible.
|
||||||
|
KindDuplicatesResolved Kind = "duplicates_resolved"
|
||||||
|
KindPlaybackErrors Kind = "playback_errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Audience is who a kind can reach. Admin kinds are never offered to, or
|
// Audience is who a kind can reach. Admin kinds are never offered to, or
|
||||||
@@ -77,6 +82,9 @@ type spec struct {
|
|||||||
var (
|
var (
|
||||||
allOn = Channels{Inbox: true, Phone: true, Email: true}
|
allOn = Channels{Inbox: true, Phone: true, Email: true}
|
||||||
healthAlert = Channels{Inbox: true, Phone: true, Email: false}
|
healthAlert = Channels{Inbox: true, Phone: true, Email: false}
|
||||||
|
// inboxOnly: news an admin may want to read but never needs to be
|
||||||
|
// interrupted for.
|
||||||
|
inboxOnly = Channels{Inbox: true}
|
||||||
)
|
)
|
||||||
|
|
||||||
var specs = map[Kind]spec{
|
var specs = map[Kind]spec{
|
||||||
@@ -88,7 +96,9 @@ var specs = map[Kind]spec{
|
|||||||
KindScanFailed: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert},
|
KindScanFailed: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert},
|
||||||
KindTracksMissing: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert},
|
KindTracksMissing: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: healthAlert},
|
||||||
KindDuplicatesFound: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
|
KindDuplicatesFound: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
|
||||||
KindPlaybackErrors: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
|
// Each pass reports what it did itself, so the counts add up.
|
||||||
|
KindDuplicatesResolved: {audience: AudienceAdmin, coalesce: true, sumCount: true, group: EmailBatch, defaults: inboxOnly},
|
||||||
|
KindPlaybackErrors: {audience: AudienceAdmin, coalesce: true, group: EmailBatch, defaults: healthAlert},
|
||||||
}
|
}
|
||||||
|
|
||||||
// order is the display order for settings screens: the requester's own kinds
|
// order is the display order for settings screens: the requester's own kinds
|
||||||
@@ -102,6 +112,7 @@ var order = []Kind{
|
|||||||
KindScanFailed,
|
KindScanFailed,
|
||||||
KindTracksMissing,
|
KindTracksMissing,
|
||||||
KindDuplicatesFound,
|
KindDuplicatesFound,
|
||||||
|
KindDuplicatesResolved,
|
||||||
KindPlaybackErrors,
|
KindPlaybackErrors,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestKinds_OrderAndSpecsAgree(t *testing.T) {
|
|||||||
func TestKinds_AdminKindsDefaultEmailOffExceptRequestQueue(t *testing.T) {
|
func TestKinds_AdminKindsDefaultEmailOffExceptRequestQueue(t *testing.T) {
|
||||||
// The burst-prone health kinds stay out of email unless an admin opts
|
// The burst-prone health kinds stay out of email unless an admin opts
|
||||||
// in; the request queue and quarantine flags are things an admin acts on.
|
// in; the request queue and quarantine flags are things an admin acts on.
|
||||||
for _, k := range []Kind{KindScanFailed, KindTracksMissing, KindDuplicatesFound, KindPlaybackErrors} {
|
for _, k := range []Kind{KindScanFailed, KindTracksMissing, KindDuplicatesFound, KindDuplicatesResolved, KindPlaybackErrors} {
|
||||||
require.True(t, k.AdminOnly(), k)
|
require.True(t, k.AdminOnly(), k)
|
||||||
require.False(t, k.Defaults().Email, k)
|
require.False(t, k.Defaults().Email, k)
|
||||||
require.NotEmpty(t, k.coalesceKey(), "%s should coalesce", k)
|
require.NotEmpty(t, k.coalesceKey(), "%s should coalesce", k)
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ type Payload struct {
|
|||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
// Count is the coalesced kinds' running total.
|
// Count is the coalesced kinds' running total.
|
||||||
Count int64 `json:"count,omitempty"`
|
Count int64 `json:"count,omitempty"`
|
||||||
// Detail is free text for scan_failed (the error message).
|
// Detail is free text: scan_failed's error message, and what the duplicate
|
||||||
|
// resolver changed in Lidarr for duplicates_resolved.
|
||||||
Detail string `json:"detail,omitempty"`
|
Detail string `json:"detail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +116,16 @@ func Render(kind Kind, payload []byte) Rendered {
|
|||||||
Body: "The duplicate sweep found tracks holding the same recording.",
|
Body: "The duplicate sweep found tracks holding the same recording.",
|
||||||
Link: "/admin/duplicates",
|
Link: "/admin/duplicates",
|
||||||
}
|
}
|
||||||
|
case KindDuplicatesResolved:
|
||||||
|
body := "Copies Lidarr does not need were merged into the one it tracks."
|
||||||
|
if p.Detail != "" {
|
||||||
|
body = p.Detail
|
||||||
|
}
|
||||||
|
return Rendered{
|
||||||
|
Title: plural(p.Count, "duplicate resolved automatically", "duplicates resolved automatically"),
|
||||||
|
Body: body,
|
||||||
|
Link: "/admin/duplicates?view=resolved",
|
||||||
|
}
|
||||||
case KindPlaybackErrors:
|
case KindPlaybackErrors:
|
||||||
return Rendered{
|
return Rendered{
|
||||||
Title: plural(p.Count, "playback error reported", "playback errors reported"),
|
Title: plural(p.Count, "playback error reported", "playback errors reported"),
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package playlists
|
||||||
|
|
||||||
|
// Bridges for the external playlists_test package, whose integration helpers
|
||||||
|
// (newPool, seedTrack) live there.
|
||||||
|
|
||||||
|
// RankedCandidate is rankedCandidate, for tests.
|
||||||
|
type RankedCandidate = rankedCandidate
|
||||||
|
|
||||||
|
// OneCopyPerSong is oneCopyPerSong, for tests.
|
||||||
|
var OneCopyPerSong = oneCopyPerSong
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package playlists_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 := []playlists.RankedCandidate{
|
||||||
|
{TrackID: single.ID}, {TrackID: rip.ID}, {TrackID: albumCut.ID}, {TrackID: other.ID}, {TrackID: clean.ID},
|
||||||
|
}
|
||||||
|
out, err := playlists.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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1070,6 +1070,10 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
|
|||||||
return pgtype.UUID{}, fmt.Errorf("insert playlist row: %w", err)
|
return pgtype.UUID{}, fmt.Errorf("insert playlist row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tracks, err = oneCopyPerSong(ctx, qtx, tracks)
|
||||||
|
if err != nil {
|
||||||
|
return pgtype.UUID{}, err
|
||||||
|
}
|
||||||
for _, t := range tracks {
|
for _, t := range tracks {
|
||||||
var pickKind *string
|
var pickKind *string
|
||||||
if t.PickKind != "" {
|
if t.PickKind != "" {
|
||||||
@@ -1096,6 +1100,40 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
|
|||||||
return p.ID, nil
|
return p.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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. 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) {
|
||||||
|
ids := make([]pgtype.UUID, len(tracks))
|
||||||
|
for i, t := range tracks {
|
||||||
|
ids[i] = t.TrackID
|
||||||
|
}
|
||||||
|
rows, err := qtx.ListTrackSongKeys(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("song keys: %w", err)
|
||||||
|
}
|
||||||
|
songOf := make(map[pgtype.UUID]dbq.ListTrackSongKeysRow, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
songOf[r.ID] = r
|
||||||
|
}
|
||||||
|
seen := make(map[pgtype.UUID]bool, len(tracks))
|
||||||
|
out := tracks[:0:0]
|
||||||
|
for _, t := range tracks {
|
||||||
|
row, ok := songOf[t.TrackID]
|
||||||
|
if ok && row.HeldBack {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
song := row.SongKey
|
||||||
|
if ok && seen[song] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[song] = true
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// uuidStringPL renders a pgtype.UUID as the canonical 8-4-4-4-12 form.
|
// uuidStringPL renders a pgtype.UUID as the canonical 8-4-4-4-12 form.
|
||||||
// (pgtype.UUID's String method exists on some pgx versions but not others;
|
// (pgtype.UUID's String method exists on some pgx versions but not others;
|
||||||
// also the playlists package needs this independent of test helpers.)
|
// also the playlists package needs this independent of test helpers.)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -104,11 +112,24 @@ func Shuffle(
|
|||||||
deferred := make([]Candidate, 0, len(scored)-limit)
|
deferred := make([]Candidate, 0, len(scored)-limit)
|
||||||
artistCount := map[pgtype.UUID]int{}
|
artistCount := map[pgtype.UUID]int{}
|
||||||
albumCount := map[pgtype.UUID]int{}
|
albumCount := map[pgtype.UUID]int{}
|
||||||
|
// One song on several releases is played once (M498 #5438): after its
|
||||||
|
// best-scoring copy, the others are dropped, not held back.
|
||||||
|
songs := map[pgtype.UUID]bool{}
|
||||||
|
repeat := func(c Candidate) bool { return c.Track.SongKey.Valid && songs[c.Track.SongKey] }
|
||||||
|
take := func(c Candidate) {
|
||||||
|
if c.Track.SongKey.Valid {
|
||||||
|
songs[c.Track.SongKey] = true
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
|
||||||
for _, s := range scored {
|
for _, s := range scored {
|
||||||
if len(out) == limit {
|
if len(out) == limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if heldBack(s.c) || repeat(s.c) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
overArtist := caps.MaxPerArtist > 0 && artistCount[s.c.Track.ArtistID] >= caps.MaxPerArtist
|
overArtist := caps.MaxPerArtist > 0 && artistCount[s.c.Track.ArtistID] >= caps.MaxPerArtist
|
||||||
overAlbum := caps.MaxPerAlbum > 0 && albumCount[s.c.Track.AlbumID] >= caps.MaxPerAlbum
|
overAlbum := caps.MaxPerAlbum > 0 && albumCount[s.c.Track.AlbumID] >= caps.MaxPerAlbum
|
||||||
if overArtist || overAlbum {
|
if overArtist || overAlbum {
|
||||||
@@ -118,7 +139,7 @@ func Shuffle(
|
|||||||
}
|
}
|
||||||
artistCount[s.c.Track.ArtistID]++
|
artistCount[s.c.Track.ArtistID]++
|
||||||
albumCount[s.c.Track.AlbumID]++
|
albumCount[s.c.Track.AlbumID]++
|
||||||
out = append(out, s.c)
|
take(s.c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass two: the caps could not fill the request, so relax them rather
|
// Pass two: the caps could not fill the request, so relax them rather
|
||||||
@@ -128,7 +149,10 @@ func Shuffle(
|
|||||||
if len(out) == limit {
|
if len(out) == limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
out = append(out, c)
|
if repeat(c) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
take(c)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,3 +84,45 @@ func titles(cs []Candidate) []string {
|
|||||||
// pure tests; the Track.Title field is the human-readable handle. Track ID
|
// pure tests; the Track.Title field is the human-readable handle. Track ID
|
||||||
// is left as zero pgtype.UUID and never compared.
|
// is left as zero pgtype.UUID and never compared.
|
||||||
var _ pgtype.UUID
|
var _ pgtype.UUID
|
||||||
|
|
||||||
|
// One song on two releases is played once, in its better-scoring copy's place,
|
||||||
|
// and a capped copy held back for pass two does not slip back in either.
|
||||||
|
func TestShuffle_OneCopyPerSong(t *testing.T) {
|
||||||
|
song := func(c Candidate, key string) Candidate {
|
||||||
|
_ = c.Track.SongKey.Scan("00000000-0000-0000-0000-" + key)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
album := pgtype.UUID{Bytes: [16]byte{9}, Valid: true}
|
||||||
|
single := song(cand("000000000001", ScoringInputs{IsGeneralLiked: true}), "00000000000a")
|
||||||
|
albumCut := song(cand("000000000002", ScoringInputs{}), "00000000000a")
|
||||||
|
other := cand("000000000003", ScoringInputs{})
|
||||||
|
single.Track.AlbumID, albumCut.Track.AlbumID = album, pgtype.UUID{Bytes: [16]byte{8}, Valid: true}
|
||||||
|
|
||||||
|
out := Shuffle([]Candidate{albumCut, single, other}, defaultWeights(), time.Now(), fixedRNG(0.5), 10, DiversityCaps{})
|
||||||
|
if got := titles(out); len(got) != 2 || got[0] != "000000000001" {
|
||||||
|
t.Errorf("got %v, want the liked single once and the other song", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single is capped out of pass one; pass two takes one copy, not both.
|
||||||
|
capped := cand("000000000004", ScoringInputs{IsGeneralLiked: true})
|
||||||
|
capped.Track.AlbumID = album
|
||||||
|
out = Shuffle([]Candidate{capped, single, albumCut}, defaultWeights(), time.Now(), fixedRNG(0.5), 3, DiversityCaps{MaxPerAlbum: 1})
|
||||||
|
if len(out) != 2 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,17 @@ func (a lidarrUnmonitorAdapter) UnmonitorTrack(ctx context.Context, trackMbid, a
|
|||||||
return c.UnmonitorTrack(ctx, trackMbid, albumMbid)
|
return c.UnmonitorTrack(ctx, trackMbid, albumMbid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUnmappedTrackFiles lets the duplicate merge refuse to remove a copy
|
||||||
|
// Lidarr maps (M498). tracks.ErrLidarrDisabled tells it there is no Lidarr to
|
||||||
|
// protect.
|
||||||
|
func (a lidarrUnmonitorAdapter) ListUnmappedTrackFiles(ctx context.Context) ([]lidarr.TrackFile, error) {
|
||||||
|
c := a.fn()
|
||||||
|
if c == nil {
|
||||||
|
return nil, tracks.ErrLidarrDisabled
|
||||||
|
}
|
||||||
|
return c.ListUnmappedTrackFiles(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an
|
// ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an
|
||||||
// interface so tests can stub it without touching the DB. The progressCb
|
// interface so tests can stub it without touching the DB. The progressCb
|
||||||
// parameter (added in m7-scan-progress) lets the orchestrator drive partial-
|
// parameter (added in m7-scan-progress) lets the orchestrator drive partial-
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -39,12 +40,13 @@ func (m *mediaHandlers) handleStar(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if trackID.Valid {
|
if trackID.Valid {
|
||||||
rows, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID})
|
// Stars the song: every copy of it (M498 #5438).
|
||||||
|
liked, err := q.LikeTrack(r.Context(), dbq.LikeTrackParams{UserID: user.ID, TrackID: trackID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteFail(w, r, ErrGeneric, "Could not star track")
|
WriteFail(w, r, ErrGeneric, "Could not star track")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rows == 1 {
|
if slices.Contains(liked, trackID) {
|
||||||
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, trackID, m.logger)
|
_ = playevents.CaptureContextualLikeIfPlaying(r.Context(), q, user.ID, trackID, m.logger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,7 +77,7 @@ func (m *mediaHandlers) handleUnstar(w http.ResponseWriter, r *http.Request) {
|
|||||||
params := r.URL.Query()
|
params := r.URL.Query()
|
||||||
q := dbq.New(m.pool)
|
q := dbq.New(m.pool)
|
||||||
if t, ok := parseUUID(params.Get("id")); ok {
|
if t, ok := parseUUID(params.Get("id")); ok {
|
||||||
_ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t})
|
_, _ = q.UnlikeTrack(r.Context(), dbq.UnlikeTrackParams{UserID: user.ID, TrackID: t})
|
||||||
_ = playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, t)
|
_ = playevents.SoftDeleteContextualLikes(r.Context(), q, user.ID, t)
|
||||||
}
|
}
|
||||||
if a, ok := parseUUID(params.Get("albumId")); ok {
|
if a, ok := parseUUID(params.Get("albumId")); ok {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||||
"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"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,6 +41,22 @@ import (
|
|||||||
// same pointer.
|
// same pointer.
|
||||||
var ErrNotFound = apierror.ErrNotFound
|
var ErrNotFound = apierror.ErrNotFound
|
||||||
|
|
||||||
|
// ErrLidarrDisabled is what a LidarrUnmonitorer that also lists Lidarr's
|
||||||
|
// unmapped files returns when Lidarr is turned off: there is then no Lidarr
|
||||||
|
// record a removal could break, and a merge goes ahead unguarded.
|
||||||
|
var ErrLidarrDisabled = errors.New("tracks: lidarr disabled")
|
||||||
|
|
||||||
|
// ErrLidarrUnavailable means Lidarr is on but did not answer, so a merge cannot
|
||||||
|
// confirm the copies it would remove are ones Lidarr does not need (M498), and
|
||||||
|
// refuses rather than guess.
|
||||||
|
var ErrLidarrUnavailable = errors.New("tracks: lidarr did not answer")
|
||||||
|
|
||||||
|
// lidarrFileLister is the optional part of the Lidarr dependency a merge uses to
|
||||||
|
// learn which files Lidarr holds without mapping to any track.
|
||||||
|
type lidarrFileLister interface {
|
||||||
|
ListUnmappedTrackFiles(ctx context.Context) ([]lidarr.TrackFile, error)
|
||||||
|
}
|
||||||
|
|
||||||
// LidarrUnmonitorer is the subset of *lidarr.Client RemoveTrack uses.
|
// LidarrUnmonitorer is the subset of *lidarr.Client RemoveTrack uses.
|
||||||
// Defined as an interface so tests can stub without spinning up a Lidarr
|
// Defined as an interface so tests can stub without spinning up a Lidarr
|
||||||
// httptest server. UnmonitorTrack failures are non-fatal at the service
|
// httptest server. UnmonitorTrack failures are non-fatal at the service
|
||||||
@@ -151,7 +168,9 @@ func (s *Service) RemoveTrack(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MergeDuplicates merges a duplicate group into the copy to keep
|
// MergeDuplicates merges a duplicate group into the copy to keep
|
||||||
// (library.MergeDuplicateGroup), then — when asked — tells Lidarr to stop
|
// (library.MergeDuplicateGroupGuarded, refusing to remove a copy Lidarr maps:
|
||||||
|
// library.ErrCopyTrackedByLidarr, or ErrLidarrUnavailable when Lidarr cannot
|
||||||
|
// say), then — when asked — tells Lidarr to stop
|
||||||
// monitoring the removed copies so it does not download them again, and records
|
// monitoring the removed copies so it does not download them again, and records
|
||||||
// the merge in the audit log.
|
// the merge in the audit log.
|
||||||
//
|
//
|
||||||
@@ -161,7 +180,11 @@ func (s *Service) RemoveTrack(
|
|||||||
func (s *Service) MergeDuplicates(
|
func (s *Service) MergeDuplicates(
|
||||||
ctx context.Context, groupID, survivorID, actorID pgtype.UUID, unmonitor bool,
|
ctx context.Context, groupID, survivorID, actorID pgtype.UUID, unmonitor bool,
|
||||||
) (res library.MergeResult, lidarrUnmonitorFailed bool, err error) {
|
) (res library.MergeResult, lidarrUnmonitorFailed bool, err error) {
|
||||||
res, err = library.MergeDuplicateGroup(ctx, s.pool, s.logger, s.dataDir, groupID, survivorID)
|
removable, err := s.lidarrRemovable(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return res, false, err
|
||||||
|
}
|
||||||
|
res, err = library.MergeDuplicateGroupGuarded(ctx, s.pool, s.logger, s.dataDir, groupID, survivorID, removable)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return res, false, err
|
return res, false, err
|
||||||
}
|
}
|
||||||
@@ -203,6 +226,29 @@ func (s *Service) MergeDuplicates(
|
|||||||
return res, lidarrUnmonitorFailed, nil
|
return res, lidarrUnmonitorFailed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lidarrRemovable is the merge's guard (M498): only a copy Lidarr holds without
|
||||||
|
// mapping it to a track may be removed, because removing a mapped one makes
|
||||||
|
// Lidarr download it again. Nil, allowing everything, when Lidarr is disabled
|
||||||
|
// or the dependency cannot list files.
|
||||||
|
func (s *Service) lidarrRemovable(ctx context.Context) (library.RemovableFunc, error) {
|
||||||
|
lister, ok := s.lidarr.(lidarrFileLister)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
files, err := lister.ListUnmappedTrackFiles(ctx)
|
||||||
|
if errors.Is(err, ErrLidarrDisabled) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrLidarrUnavailable, err)
|
||||||
|
}
|
||||||
|
unmapped := make(map[string]bool, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
unmapped[library.LidarrPathKey(f.Path)] = true
|
||||||
|
}
|
||||||
|
return func(p string) bool { return unmapped[library.LidarrPathKey(p)] }, nil
|
||||||
|
}
|
||||||
|
|
||||||
// sameLidarrTrack reports whether two copies are the same Lidarr track: one
|
// sameLidarrTrack reports whether two copies are the same Lidarr track: one
|
||||||
// recording on one album. Lidarr monitors per album track, so when the removed
|
// recording on one album. Lidarr monitors per album track, so when the removed
|
||||||
// copy is a second file of the kept copy's own album track — the #3885 case —
|
// copy is a second file of the kept copy's own album track — the #3885 case —
|
||||||
|
|||||||
Generated
+668
-1062
File diff suppressed because it is too large
Load Diff
+11
-8
@@ -13,24 +13,27 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-static": "^4.0.0",
|
"@sveltejs/adapter-static": "^4.0.0",
|
||||||
"@sveltejs/kit": "^2.8.0",
|
"@sveltejs/kit": "^3.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
"autoprefixer": "^10.5.0",
|
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "^8.5.15",
|
"svelte": "^5.57.1",
|
||||||
"svelte": "^5.1.9",
|
"svelte-check": "^4.7.5",
|
||||||
"svelte-check": "^4.4.8",
|
"tailwindcss": "^4.3.3",
|
||||||
"tailwindcss": "^3.4.14",
|
"typescript": "^6.0.0",
|
||||||
"typescript": "^5.6.3",
|
"vite": "^8.0.12",
|
||||||
"vite": "^8.0.0",
|
|
||||||
"vitest": "^5.0.0"
|
"vitest": "^5.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neodrag/svelte": "^2.3.3",
|
"@neodrag/svelte": "^2.3.3",
|
||||||
"@tanstack/svelte-query": "^5.90.2",
|
"@tanstack/svelte-query": "^5.90.2",
|
||||||
"lucide-svelte": "^1.0.1"
|
"lucide-svelte": "^1.0.1"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"#lib/*": "./src/lib/*",
|
||||||
|
"#test-utils/*": "./src/test-utils/*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
+71
-4
@@ -1,8 +1,75 @@
|
|||||||
@import './lib/styles/tokens.generated.css';
|
@import './lib/styles/tokens.generated.css' layer(base);
|
||||||
|
|
||||||
@tailwind base;
|
@import 'tailwindcss';
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
/* The FabledSword tokens as Tailwind's theme. `inline` makes each utility emit
|
||||||
|
* the var(--fs-*) itself, so a theme swap that redefines the tokens reaches the
|
||||||
|
* utilities without going through a second variable. */
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--fs-obsidian);
|
||||||
|
|
||||||
|
--color-surface: var(--fs-iron);
|
||||||
|
--color-surface-hover: var(--fs-slate);
|
||||||
|
|
||||||
|
--color-border: var(--fs-pewter);
|
||||||
|
|
||||||
|
--color-text-primary: var(--fs-parchment);
|
||||||
|
--color-text-secondary: var(--fs-vellum);
|
||||||
|
--color-text-muted: var(--fs-ash);
|
||||||
|
|
||||||
|
--color-action-primary: var(--fs-moss);
|
||||||
|
--color-action-secondary: var(--fs-bronze);
|
||||||
|
--color-action-destructive: var(--fs-oxblood);
|
||||||
|
--color-action-fg: var(--fs-on-action);
|
||||||
|
|
||||||
|
--color-accent: var(--fs-accent);
|
||||||
|
--color-accent-tint: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||||
|
--color-accent-fg: var(--fs-accent-fg);
|
||||||
|
|
||||||
|
--color-warning: var(--fs-warning);
|
||||||
|
--color-warning-fg: var(--fs-warning-fg);
|
||||||
|
|
||||||
|
--color-error: var(--fs-error);
|
||||||
|
--color-error-fg: var(--fs-error-fg);
|
||||||
|
|
||||||
|
--color-info: var(--fs-info);
|
||||||
|
--color-info-fg: var(--fs-info-fg);
|
||||||
|
|
||||||
|
--radius-sm: var(--fs-radius-sm);
|
||||||
|
--radius-md: var(--fs-radius-md);
|
||||||
|
--radius-lg: var(--fs-radius-lg);
|
||||||
|
--radius-xl: var(--fs-radius-xl);
|
||||||
|
|
||||||
|
--font-display: var(--fs-font-display);
|
||||||
|
--font-sans: var(--fs-font-body);
|
||||||
|
--font-mono: var(--fs-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tailwind 3's preflight defaults that 4 changed (#5021). Kept so the upgrade
|
||||||
|
* changes nothing on screen; each can go once the elements relying on it name
|
||||||
|
* the value themselves.
|
||||||
|
* - A border with no colour utility was gray-200; 4 makes it currentcolor.
|
||||||
|
* - Placeholder text was gray-400; 4 makes it the text colour at 50%.
|
||||||
|
* - Buttons had a pointer cursor; 4 leaves the browser default. */
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: var(--color-gray-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:not(:disabled),
|
||||||
|
[role='button']:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { dismissDuplicateGroup, listDuplicates, mergeDuplicateGroup, runDuplicateSweep } from './admin';
|
import {
|
||||||
|
dismissDuplicateGroup,
|
||||||
|
duplicatesNextOffset,
|
||||||
|
listDuplicates,
|
||||||
|
listResolvedDuplicates,
|
||||||
|
mergeDuplicateGroup,
|
||||||
|
runDuplicateSweep
|
||||||
|
} from './admin';
|
||||||
|
import type { AdminDuplicatesResponse } from './types';
|
||||||
|
|
||||||
vi.mock('./client', () => ({
|
vi.mock('./client', () => ({
|
||||||
api: { get: vi.fn(), post: vi.fn() }
|
api: { get: vi.fn(), post: vi.fn() }
|
||||||
@@ -10,10 +18,27 @@ import { api } from './client';
|
|||||||
describe('admin duplicates API', () => {
|
describe('admin duplicates API', () => {
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
it('listDuplicates GETs the paged report', async () => {
|
it('listDuplicates GETs one tab of the paged report', async () => {
|
||||||
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ groups: [] });
|
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ groups: [] });
|
||||||
await listDuplicates(25, 25);
|
await listDuplicates('cross_release', 25);
|
||||||
expect(api.get).toHaveBeenCalledWith('/api/admin/library/duplicates?limit=25&offset=25');
|
expect(api.get).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/library/duplicates?view=cross_release&limit=25&offset=25'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listResolvedDuplicates GETs what the resolver did', async () => {
|
||||||
|
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ items: [] });
|
||||||
|
await listResolvedDuplicates(50);
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/admin/library/duplicates/resolved?limit=25&offset=50');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The next offset counts groups, and stops at the total or on an empty page.
|
||||||
|
it('duplicatesNextOffset pages by groups', () => {
|
||||||
|
const page = (offset: number, n: number, total: number) =>
|
||||||
|
({ offset, total, groups: Array.from({ length: n }, () => ({})) }) as unknown as AdminDuplicatesResponse;
|
||||||
|
expect(duplicatesNextOffset(page(0, 25, 60))).toBe(25);
|
||||||
|
expect(duplicatesNextOffset(page(50, 10, 60))).toBeUndefined();
|
||||||
|
expect(duplicatesNextOffset(page(25, 0, 60))).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runDuplicateSweep POSTs the trigger', async () => {
|
it('runDuplicateSweep POSTs the trigger', async () => {
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ describe('admin fingerprint coverage API', () => {
|
|||||||
chromaprint_length_sec: 120,
|
chromaprint_length_sec: 120,
|
||||||
acoustic_max_bit_error_rate: 0.15,
|
acoustic_max_bit_error_rate: 0.15,
|
||||||
backfill_concurrency: 2,
|
backfill_concurrency: 2,
|
||||||
sweep_interval_hours: 1
|
sweep_interval_hours: 1,
|
||||||
|
auto_resolve: true
|
||||||
};
|
};
|
||||||
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settings);
|
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settings);
|
||||||
(api.put as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settings);
|
(api.put as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(settings);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { suspectSourcesNextOffset } from './admin';
|
||||||
|
import type { AdminSuspectResponse, AdminSuspectTrack } from './types';
|
||||||
|
|
||||||
|
function track(id: string): AdminSuspectTrack {
|
||||||
|
return {
|
||||||
|
track_id: id,
|
||||||
|
title: id,
|
||||||
|
artist_id: 'ar',
|
||||||
|
artist_name: 'Artist',
|
||||||
|
album_id: 'al',
|
||||||
|
album_title: 'Album',
|
||||||
|
file_path: `/music/${id}.mp3`,
|
||||||
|
duration_sec: 180,
|
||||||
|
disc_number: null,
|
||||||
|
track_number: null,
|
||||||
|
markers: [],
|
||||||
|
verdict: null,
|
||||||
|
verdict_at: null,
|
||||||
|
searched_at: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(offset: number, total: number): AdminSuspectResponse {
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
limit: 50,
|
||||||
|
offset,
|
||||||
|
groups: [
|
||||||
|
{ directory: 'a', tracks: [track('1'), track('2')] },
|
||||||
|
{ directory: 'b', tracks: [track('3')] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit and total are in tracks, so the next offset must count tracks across
|
||||||
|
// groups, not the number of groups.
|
||||||
|
describe('suspectSourcesNextOffset', () => {
|
||||||
|
test('counts tracks, not groups', () => {
|
||||||
|
expect(suspectSourcesNextOffset(page(0, 10))).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stops at the total', () => {
|
||||||
|
expect(suspectSourcesNextOffset(page(7, 10))).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
+83
-11
@@ -1,11 +1,15 @@
|
|||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import { qk } from './queries';
|
import { qk } from './queries';
|
||||||
import type {
|
import type {
|
||||||
ActionResult,
|
ActionResult,
|
||||||
AdminMissingResponse,
|
AdminMissingResponse,
|
||||||
|
AdminSuspectResponse,
|
||||||
|
SuspectVerdict,
|
||||||
AdminDuplicatesResponse,
|
AdminDuplicatesResponse,
|
||||||
|
DuplicatesView,
|
||||||
MergeDuplicateResult,
|
MergeDuplicateResult,
|
||||||
|
ResolvedDuplicatesResponse,
|
||||||
AdminPlaybackError,
|
AdminPlaybackError,
|
||||||
AdminQuarantineRow,
|
AdminQuarantineRow,
|
||||||
LidarrConfig,
|
LidarrConfig,
|
||||||
@@ -352,6 +356,9 @@ export type FingerprintSettings = {
|
|||||||
acoustic_max_bit_error_rate: number;
|
acoustic_max_bit_error_rate: number;
|
||||||
backfill_concurrency: number;
|
backfill_concurrency: number;
|
||||||
sweep_interval_hours: number;
|
sweep_interval_hours: number;
|
||||||
|
// Lets the duplicate resolver act on its own (M498): merge copies Lidarr
|
||||||
|
// doesn't need and change a Lidarr release that lists songs twice.
|
||||||
|
auto_resolve: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getFingerprintSettings(): Promise<FingerprintSettings> {
|
export async function getFingerprintSettings(): Promise<FingerprintSettings> {
|
||||||
@@ -828,24 +835,55 @@ export async function updatePublicUrl(publicUrl: string): Promise<NetworkSetting
|
|||||||
|
|
||||||
// Duplicates report (#3912) -------------------------------------------------
|
// Duplicates report (#3912) -------------------------------------------------
|
||||||
|
|
||||||
|
export const DUPLICATES_PAGE_SIZE = 25;
|
||||||
|
|
||||||
export async function listDuplicates(
|
export async function listDuplicates(
|
||||||
|
view: DuplicatesView = 'review',
|
||||||
offset: number = 0,
|
offset: number = 0,
|
||||||
limit: number = 25
|
limit: number = DUPLICATES_PAGE_SIZE
|
||||||
): Promise<AdminDuplicatesResponse> {
|
): Promise<AdminDuplicatesResponse> {
|
||||||
return api.get<AdminDuplicatesResponse>(
|
return api.get<AdminDuplicatesResponse>(
|
||||||
`/api/admin/library/duplicates?limit=${limit}&offset=${offset}`
|
`/api/admin/library/duplicates?view=${view}&limit=${limit}&offset=${offset}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Takes a plain offset, like createMissingFilesQuery, so a $derived caller
|
export function duplicatesNextOffset(last: AdminDuplicatesResponse): number | undefined {
|
||||||
// re-creates the query on paging. Polls while a sweep might be running: the
|
const loaded = last.offset + last.groups.length;
|
||||||
// page is where the operator waits for one to finish.
|
return last.groups.length === 0 || loaded >= last.total ? undefined : loaded;
|
||||||
export function createDuplicatesQuery(offset: number = 0, limit: number = 25) {
|
}
|
||||||
return createQuery({
|
|
||||||
queryKey: qk.adminDuplicates(offset),
|
// Loads as the page scrolls (rule 172), one tab at a time. Polls so a sweep
|
||||||
queryFn: () => listDuplicates(offset, limit),
|
// or resolver pass finishing shows up while the operator is on the page.
|
||||||
|
export function createDuplicatesQuery(view: DuplicatesView = 'review') {
|
||||||
|
return createInfiniteQuery({
|
||||||
|
queryKey: qk.adminDuplicates(view),
|
||||||
|
queryFn: ({ pageParam }) => listDuplicates(view, pageParam as number),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: duplicatesNextOffset,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
refetchInterval: 15_000
|
refetchInterval: 30_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listResolvedDuplicates(offset: number = 0): Promise<ResolvedDuplicatesResponse> {
|
||||||
|
return api.get<ResolvedDuplicatesResponse>(
|
||||||
|
`/api/admin/library/duplicates/resolved?limit=${DUPLICATES_PAGE_SIZE}&offset=${offset}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvedDuplicatesNextOffset(last: ResolvedDuplicatesResponse): number | undefined {
|
||||||
|
const loaded = last.offset + last.items.length;
|
||||||
|
return last.items.length === 0 || loaded >= last.total ? undefined : loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the resolver did by itself (M498), newest first.
|
||||||
|
export function createResolvedDuplicatesQuery() {
|
||||||
|
return createInfiniteQuery({
|
||||||
|
queryKey: qk.adminResolvedDuplicates(),
|
||||||
|
queryFn: ({ pageParam }) => listResolvedDuplicates(pageParam as number),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: resolvedDuplicatesNextOffset,
|
||||||
|
staleTime: 60_000
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,6 +934,40 @@ export function createMissingFilesQuery(offset: number = 0, limit: number = 50)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Suspect sources (#5410) --------------------------------------------------
|
||||||
|
|
||||||
|
export const SUSPECT_SOURCES_PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export async function listSuspectSources(offset: number = 0): Promise<AdminSuspectResponse> {
|
||||||
|
return api.get<AdminSuspectResponse>(
|
||||||
|
`/api/admin/library/suspect-sources?limit=${SUSPECT_SOURCES_PAGE_SIZE}&offset=${offset}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next offset counts tracks, not groups: limit and total are in tracks.
|
||||||
|
export function suspectSourcesNextOffset(last: AdminSuspectResponse): number | undefined {
|
||||||
|
const loaded = last.offset + last.groups.reduce((n, g) => n + g.tracks.length, 0);
|
||||||
|
return loaded >= last.total ? undefined : loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads as the page scrolls (rule 172). Changes only when files are added or
|
||||||
|
// removed, so it is not re-fetched on every focus.
|
||||||
|
export function createSuspectSourcesQuery() {
|
||||||
|
return createInfiniteQuery({
|
||||||
|
queryKey: qk.adminSuspectSources(),
|
||||||
|
queryFn: ({ pageParam }) => listSuspectSources(pageParam as number),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: suspectSourcesNextOffset,
|
||||||
|
staleTime: 120_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// '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 = {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ describe('api.get/post/del', () => {
|
|||||||
describe('apiFetch 401 interceptor', () => {
|
describe('apiFetch 401 interceptor', () => {
|
||||||
test('401 response triggers auth.logout({silent:true}) and still throws', async () => {
|
test('401 response triggers auth.logout({silent:true}) and still throws', async () => {
|
||||||
const logoutSpy = vi.fn();
|
const logoutSpy = vi.fn();
|
||||||
vi.doMock('$lib/auth/store.svelte', () => ({
|
vi.doMock('#lib/auth/store.svelte.js', () => ({
|
||||||
logout: logoutSpy,
|
logout: logoutSpy,
|
||||||
login: vi.fn(),
|
login: vi.fn(),
|
||||||
bootstrap: vi.fn(),
|
bootstrap: vi.fn(),
|
||||||
@@ -97,6 +97,6 @@ describe('apiFetch 401 interceptor', () => {
|
|||||||
status: 401
|
status: 401
|
||||||
});
|
});
|
||||||
expect(logoutSpy).toHaveBeenCalledWith({ silent: true });
|
expect(logoutSpy).toHaveBeenCalledWith({ silent: true });
|
||||||
vi.doUnmock('$lib/auth/store.svelte');
|
vi.doUnmock('#lib/auth/store.svelte.js');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export async function apiFetch(path: string, init?: RequestInit): Promise<unknow
|
|||||||
// Lazy import: auth/store imports from this file, so a top-level
|
// Lazy import: auth/store imports from this file, so a top-level
|
||||||
// import would be circular. By the time any 401 actually happens
|
// import would be circular. By the time any 401 actually happens
|
||||||
// at runtime, both modules have finished loading.
|
// at runtime, both modules have finished loading.
|
||||||
const { logout } = await import('$lib/auth/store.svelte');
|
const { logout } = await import('#lib/auth/store.svelte.js');
|
||||||
await logout({ silent: true });
|
await logout({ silent: true });
|
||||||
}
|
}
|
||||||
// Two error envelope shapes ship in this codebase today:
|
// Two error envelope shapes ship in this codebase today:
|
||||||
|
|||||||
@@ -122,5 +122,6 @@ export const NOTIFICATION_KIND_LABELS: Record<string, string> = {
|
|||||||
scan_failed: 'Library scan failed',
|
scan_failed: 'Library scan failed',
|
||||||
tracks_missing: 'Tracks gone missing',
|
tracks_missing: 'Tracks gone missing',
|
||||||
duplicates_found: 'Duplicates to review',
|
duplicates_found: 'Duplicates to review',
|
||||||
|
duplicates_resolved: 'Duplicates resolved',
|
||||||
playback_errors: 'Playback errors'
|
playback_errors: 'Playback errors'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ export const qk = {
|
|||||||
['adminDiagnostics', f] as const,
|
['adminDiagnostics', f] as const,
|
||||||
adminMissingFiles: (offset?: number) =>
|
adminMissingFiles: (offset?: number) =>
|
||||||
['adminMissingFiles', { offset: offset ?? 0 }] as const,
|
['adminMissingFiles', { offset: offset ?? 0 }] as const,
|
||||||
adminDuplicates: (offset?: number) =>
|
adminDuplicates: (view: string = 'review') =>
|
||||||
['adminDuplicates', { offset: offset ?? 0 }] as const,
|
['adminDuplicates', { view }] as const,
|
||||||
|
adminResolvedDuplicates: () => ['adminResolvedDuplicates'] as const,
|
||||||
|
adminSuspectSources: () => ['adminSuspectSources'] as const,
|
||||||
adminDiagnosticDevices: (userId?: string) =>
|
adminDiagnosticDevices: (userId?: string) =>
|
||||||
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
|
['adminDiagnosticDevices', { userId: userId ?? 'all' }] as const,
|
||||||
smtpConfig: () => ['smtpConfig'] as const,
|
smtpConfig: () => ['smtpConfig'] as const,
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export type TrackRef = {
|
|||||||
disc_number?: number;
|
disc_number?: number;
|
||||||
duration_sec: number;
|
duration_sec: number;
|
||||||
stream_url: string;
|
stream_url: string;
|
||||||
|
// #5418: the file is gone from the library (missing_since is set). Sent on
|
||||||
|
// direct lookups such as album detail and search; lists that pick tracks
|
||||||
|
// for playback leave missing files out instead.
|
||||||
|
unavailable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ArtistDetail = ArtistRef & {
|
export type ArtistDetail = ArtistRef & {
|
||||||
@@ -419,6 +423,45 @@ export type AdminMissingResponse = {
|
|||||||
groups: AdminMissingGroup[];
|
groups: AdminMissingGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Suspect sources (#5410) --------------------------------------------------
|
||||||
|
|
||||||
|
// A present track whose filename carries a video-rip marker. markers are the
|
||||||
|
// reasons, read from the file's basename: "music video", "[Audio]", "MV"...
|
||||||
|
export type AdminSuspectTrack = {
|
||||||
|
track_id: string;
|
||||||
|
title: string;
|
||||||
|
artist_id: string;
|
||||||
|
artist_name: string;
|
||||||
|
album_id: string;
|
||||||
|
album_title: string;
|
||||||
|
file_path: string;
|
||||||
|
duration_sec: number;
|
||||||
|
disc_number: number | null;
|
||||||
|
track_number: number | null;
|
||||||
|
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;
|
||||||
|
// When Lidarr was last asked to search the album for a better copy (#5447).
|
||||||
|
searched_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SuspectVerdict = 'suspect' | 'fine';
|
||||||
|
|
||||||
|
// A folder's worth of flagged tracks; the server orders and groups by folder.
|
||||||
|
export type AdminSuspectGroup = {
|
||||||
|
directory: string;
|
||||||
|
tracks: AdminSuspectTrack[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminSuspectResponse = {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
groups: AdminSuspectGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
// Duplicates report (#3912) -------------------------------------------------
|
// Duplicates report (#3912) -------------------------------------------------
|
||||||
|
|
||||||
// One copy in a proposed duplicate group. like_count and play_count cover every
|
// One copy in a proposed duplicate group. like_count and play_count cover every
|
||||||
@@ -436,8 +479,17 @@ export type AdminDuplicateMember = {
|
|||||||
added_at: string;
|
added_at: string;
|
||||||
like_count: number;
|
like_count: number;
|
||||||
play_count: number;
|
play_count: number;
|
||||||
|
// Lidarr's view of the file as of the resolver's last pass (M498): tracked
|
||||||
|
// means Lidarr maps it to a track, so removing it would make Lidarr download
|
||||||
|
// it again. null when Lidarr wasn't asked.
|
||||||
|
lidarr_state: 'tracked' | 'unmapped' | null;
|
||||||
|
disc_number: number | null;
|
||||||
|
track_number: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The resolver's verdict on a group (M498). null until its first pass.
|
||||||
|
export type DuplicateClass = 'same_release' | 'cross_release' | 'mismatch' | 'review';
|
||||||
|
|
||||||
// exact: identical encoded audio. acoustic: the same recording, differently
|
// exact: identical encoded audio. acoustic: the same recording, differently
|
||||||
// encoded; worst_bit_error_rate is the weakest link between any two members.
|
// encoded; worst_bit_error_rate is the weakest link between any two members.
|
||||||
export type AdminDuplicateGroup = {
|
export type AdminDuplicateGroup = {
|
||||||
@@ -447,6 +499,9 @@ export type AdminDuplicateGroup = {
|
|||||||
detected_at: string;
|
detected_at: string;
|
||||||
survivor_track_id: string;
|
survivor_track_id: string;
|
||||||
survivor_reason: string;
|
survivor_reason: string;
|
||||||
|
class: DuplicateClass | null;
|
||||||
|
// Why the resolver left the group for the operator, when it had something to say.
|
||||||
|
resolve_note: string | null;
|
||||||
members: AdminDuplicateMember[];
|
members: AdminDuplicateMember[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -478,8 +533,40 @@ export type AdminDuplicatesResponse = {
|
|||||||
pending: number;
|
pending: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
view: DuplicatesView;
|
||||||
|
// Each tab's size, for its pill.
|
||||||
|
counts: { review: number; cross_release: number; resolved: number };
|
||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
groups: AdminDuplicateGroup[];
|
groups: AdminDuplicateGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The report's group tabs. 'resolved' is the third tab, served separately.
|
||||||
|
export type DuplicatesView = 'review' | 'cross_release';
|
||||||
|
|
||||||
|
// One thing the resolver did on its own (M498), from the audit log.
|
||||||
|
export type ResolvedDuplicateActivity =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
action: 'duplicate_merge';
|
||||||
|
created_at: string;
|
||||||
|
details: {
|
||||||
|
survivor_path?: string;
|
||||||
|
survivor_reason?: string;
|
||||||
|
removed?: { track_id: string; file_path: string }[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
action: 'lidarr_release_change';
|
||||||
|
created_at: string;
|
||||||
|
details: { album_title?: string; artist_name?: string; from_release?: string; to_release?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedDuplicatesResponse = {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
items: ResolvedDuplicateActivity[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { api, type User, type LoginResponse } from '$lib/api/client';
|
import { api, type User, type LoginResponse } from '#lib/api/client.js';
|
||||||
import { putMyTimezone } from '$lib/api/me';
|
import { putMyTimezone } from '#lib/api/me.js';
|
||||||
import { queryClient } from '$lib/query/client';
|
import { queryClient } from '#lib/query/client.js';
|
||||||
import { signalSessionEnd } from './sessionEnd.svelte';
|
import { signalSessionEnd } from './sessionEnd.svelte';
|
||||||
import { user, setUser } from './user.svelte';
|
import { user, setUser } from './user.svelte';
|
||||||
|
|
||||||
// Re-export so existing `import { user } from '$lib/auth/store.svelte'`
|
// Re-export so existing `import { user } from '#lib/auth/store.svelte'`
|
||||||
// callers keep working. New code can import directly from auth/user.svelte.
|
// callers keep working. New code can import directly from auth/user.svelte.
|
||||||
export { user };
|
export { user };
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { apiClientMock } from '../../test-utils/mocks/client';
|
import { apiClientMock } from '../../test-utils/mocks/client';
|
||||||
|
|
||||||
vi.mock('$lib/api/client', () => apiClientMock());
|
vi.mock('#lib/api/client.js', () => apiClientMock());
|
||||||
|
|
||||||
vi.mock('$lib/query/client', () => ({
|
vi.mock('#lib/query/client.js', () => ({
|
||||||
queryClient: { clear: vi.fn() }
|
queryClient: { clear: vi.fn() }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '#lib/api/client.js';
|
||||||
import { queryClient } from '$lib/query/client';
|
import { queryClient } from '#lib/query/client.js';
|
||||||
import { bootstrap, login, logout, user } from './store.svelte';
|
import { bootstrap, login, logout, user } from './store.svelte';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// component that needs the current user can import `user` from either
|
// component that needs the current user can import `user` from either
|
||||||
// this module or auth/store — auth/store re-exports it for backward
|
// this module or auth/store — auth/store re-exports it for backward
|
||||||
// compatibility.
|
// compatibility.
|
||||||
import type { User } from '$lib/api/client';
|
import type { User } from '#lib/api/client.js';
|
||||||
|
|
||||||
let _user = $state<User | null>(null);
|
let _user = $state<User | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
updateAcoustIDSettings,
|
updateAcoustIDSettings,
|
||||||
type AcoustIDOverview,
|
type AcoustIDOverview,
|
||||||
type AcoustIDUnsettled
|
type AcoustIDUnsettled
|
||||||
} from '$lib/api/admin';
|
} from '#lib/api/admin.js';
|
||||||
import { errMessage } from '$lib/api/errors';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
// AcoustID recording-id lookup (M401): fills the MusicBrainz ids that
|
// AcoustID recording-id lookup (M401): fills the MusicBrainz ids that
|
||||||
// untagged tracks lack, so the similarity arms can see them. Off until the
|
// untagged tracks lack, so the similarity arms can see them. Off until the
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
const rowClass = 'flex items-center justify-between gap-4 py-2';
|
const rowClass = 'flex items-center justify-between gap-4 py-2';
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'rounded-md border border-border bg-background px-3 py-1.5 text-sm text-text-primary ' +
|
'rounded-md border border-border bg-background px-3 py-1.5 text-sm text-text-primary ' +
|
||||||
'placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent';
|
'placeholder:text-text-muted focus:outline-hidden focus:ring-2 focus:ring-accent';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="acoustid-card">
|
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="acoustid-card">
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
||||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
|
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
|
||||||
disabled:cursor-not-allowed disabled:opacity-50"
|
disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={!dirty || saving || !scoreOk}
|
disabled={!dirty || saving || !scoreOk}
|
||||||
onclick={save}
|
onclick={save}
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md border border-border px-4 py-2 text-sm text-text-primary hover:bg-background
|
class="rounded-md border border-border px-4 py-2 text-sm text-text-primary hover:bg-background
|
||||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
|
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
|
||||||
disabled:cursor-not-allowed disabled:opacity-50"
|
disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={!overview.ready || overview.status.running || starting}
|
disabled={!overview.ready || overview.status.running || starting}
|
||||||
title={overview.ready ? undefined : 'Switch the lookup on and add a key first'}
|
title={overview.ready ? undefined : 'Switch the lookup on and add a key first'}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
|
||||||
import type { AcoustIDOverview } from "$lib/api/admin";
|
import type { AcoustIDOverview } from "#lib/api/admin.js";
|
||||||
|
|
||||||
vi.mock("$lib/api/admin", () => ({
|
vi.mock("#lib/api/admin.js", () => ({
|
||||||
getAcoustID: vi.fn(),
|
getAcoustID: vi.fn(),
|
||||||
updateAcoustIDSettings: vi.fn(),
|
updateAcoustIDSettings: vi.fn(),
|
||||||
runAcoustIDLookup: vi.fn(),
|
runAcoustIDLookup: vi.fn(),
|
||||||
listUnsettledAcoustID: vi.fn(),
|
listUnsettledAcoustID: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("$lib/stores/toast.svelte", () => ({ pushToast: vi.fn() }));
|
vi.mock("#lib/stores/toast.svelte.js", () => ({ pushToast: vi.fn() }));
|
||||||
|
|
||||||
import AcoustIDCard from "./AcoustIDCard.svelte";
|
import AcoustIDCard from "./AcoustIDCard.svelte";
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
listUnsettledAcoustID,
|
listUnsettledAcoustID,
|
||||||
runAcoustIDLookup,
|
runAcoustIDLookup,
|
||||||
updateAcoustIDSettings,
|
updateAcoustIDSettings,
|
||||||
} from "$lib/api/admin";
|
} from "#lib/api/admin.js";
|
||||||
|
|
||||||
const base: AcoustIDOverview = {
|
const base: AcoustIDOverview = {
|
||||||
settings: { enabled: true, min_score: 0.85, api_key_set: true },
|
settings: { enabled: true, min_score: 0.85, api_key_set: true },
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
revokeSession,
|
revokeSession,
|
||||||
revokeOtherSessions,
|
revokeOtherSessions,
|
||||||
type ActiveSession
|
type ActiveSession
|
||||||
} from '$lib/api/me';
|
} from '#lib/api/me.js';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
// Self-contained: nothing else in the app reads this data, so it holds its
|
// Self-contained: nothing else in the app reads this data, so it holds its
|
||||||
// own state and reloads explicitly rather than joining the query cache.
|
// own state and reloads explicitly rather than joining the query cache.
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
<li class="flex items-start gap-3 py-3">
|
<li class="flex items-start gap-3 py-3">
|
||||||
<MonitorSmartphone
|
<MonitorSmartphone
|
||||||
size={18}
|
size={18}
|
||||||
class="mt-0.5 flex-shrink-0 text-text-secondary"
|
class="mt-0.5 shrink-0 text-text-secondary"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
{#if !s.current}
|
{#if !s.current}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex-shrink-0 rounded border border-border px-2 py-1 text-sm
|
class="shrink-0 rounded border border-border px-2 py-1 text-sm
|
||||||
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
|
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent
|
||||||
disabled:opacity-50"
|
disabled:opacity-50"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ const listSessions = vi.fn();
|
|||||||
const revokeSession = vi.fn();
|
const revokeSession = vi.fn();
|
||||||
const revokeOtherSessions = vi.fn();
|
const revokeOtherSessions = vi.fn();
|
||||||
|
|
||||||
vi.mock('$lib/api/me', () => ({
|
vi.mock('#lib/api/me.js', () => ({
|
||||||
listSessions: (...a: unknown[]) => listSessions(...a),
|
listSessions: (...a: unknown[]) => listSessions(...a),
|
||||||
revokeSession: (...a: unknown[]) => revokeSession(...a),
|
revokeSession: (...a: unknown[]) => revokeSession(...a),
|
||||||
revokeOtherSessions: (...a: unknown[]) => revokeOtherSessions(...a)
|
revokeOtherSessions: (...a: unknown[]) => revokeOtherSessions(...a)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
|
vi.mock('#lib/stores/toast.svelte.js', () => ({ pushToast: vi.fn() }));
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Plus } from 'lucide-svelte';
|
import { Plus } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { createPlaylistsQuery, appendTracks, createPlaylist } from '$lib/api/playlists';
|
import { createPlaylistsQuery, appendTracks, createPlaylist } from '#lib/api/playlists.js';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '#lib/api/queries.js';
|
||||||
import { errMessage } from '$lib/api/errors';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tracks,
|
tracks,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
import { readable } from 'svelte/store';
|
import { readable } from 'svelte/store';
|
||||||
import { makeTrack } from '$test-utils/fixtures/track';
|
import { makeTrack } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
const playlistsData = vi.hoisted(() => ({
|
const playlistsData = vi.hoisted(() => ({
|
||||||
owned: [
|
owned: [
|
||||||
@@ -43,11 +43,11 @@ const playlistsData = vi.hoisted(() => ({
|
|||||||
public: [] as unknown[]
|
public: [] as unknown[]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/auth/store.svelte', () => ({
|
vi.mock('#lib/auth/store.svelte.js', () => ({
|
||||||
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
|
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/api/playlists', () => ({
|
vi.mock('#lib/api/playlists.js', () => ({
|
||||||
createPlaylistsQuery: () =>
|
createPlaylistsQuery: () =>
|
||||||
readable({ data: playlistsData, isPending: false, isError: false }),
|
readable({ data: playlistsData, isPending: false, isError: false }),
|
||||||
appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
|
appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
|
||||||
@@ -86,7 +86,7 @@ describe('AddToPlaylistMenu', () => {
|
|||||||
|
|
||||||
test('clicking a playlist appends and closes', async () => {
|
test('clicking a playlist appends and closes', async () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
const { appendTracks } = await import('$lib/api/playlists');
|
const { appendTracks } = await import('#lib/api/playlists.js');
|
||||||
render(AddToPlaylistMenu, { props: { tracks: [track], onClose } });
|
render(AddToPlaylistMenu, { props: { tracks: [track], onClose } });
|
||||||
await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ }));
|
await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ }));
|
||||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
{ href: '/admin/quarantine', label: 'Quarantine' },
|
||||||
{ href: '/admin/missing-files', label: 'Missing files' },
|
{ href: '/admin/missing-files', label: 'Missing files' },
|
||||||
{ href: '/admin/duplicates', label: 'Duplicates' },
|
{ href: '/admin/duplicates', label: 'Duplicates' },
|
||||||
|
{ href: '/admin/suspect-sources', label: 'Suspect sources' },
|
||||||
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
||||||
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
||||||
{ href: '/admin/tuning', label: 'Tuning' },
|
{ href: '/admin/tuning', label: 'Tuning' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
import { pageUrlModule } from '$test-utils/mocks/appState';
|
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
const state = vi.hoisted(() => ({
|
||||||
pageUrl: new URL('http://localhost/admin')
|
pageUrl: new URL('http://localhost/admin')
|
||||||
@@ -52,7 +52,7 @@ describe('AdminTabs', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders all ten tabs in order', () => {
|
test('renders all eleven tabs in order', () => {
|
||||||
state.pageUrl = new URL('http://localhost/admin');
|
state.pageUrl = new URL('http://localhost/admin');
|
||||||
render(AdminTabs);
|
render(AdminTabs);
|
||||||
const links = screen.getAllByRole('link');
|
const links = screen.getAllByRole('link');
|
||||||
@@ -67,6 +67,9 @@ describe('AdminTabs', () => {
|
|||||||
// Duplicates follows Missing files: both are library-health reports on
|
// Duplicates follows Missing files: both are library-health reports on
|
||||||
// what the library holds, rather than a queue of user reports.
|
// what the library holds, rather than a queue of user reports.
|
||||||
'Duplicates',
|
'Duplicates',
|
||||||
|
// Suspect sources is the third library-health report: files whose
|
||||||
|
// names say they were ripped from a video.
|
||||||
|
'Suspect sources',
|
||||||
'Playback errors',
|
'Playback errors',
|
||||||
'Diagnostics',
|
'Diagnostics',
|
||||||
'Tuning',
|
'Tuning',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
|
import type { AlbumRef, AlbumDetail } from '#lib/api/types.js';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '#lib/api/client.js';
|
||||||
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
import { enqueueTracks, playQueue } from '#lib/player/store.svelte.js';
|
||||||
import { Play } from 'lucide-svelte';
|
import { Play } from 'lucide-svelte';
|
||||||
import AlbumMenu from './AlbumMenu.svelte';
|
import AlbumMenu from './AlbumMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import CardActionCluster from './CardActionCluster.svelte';
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="art-wrap relative aspect-square w-full overflow-hidden rounded-md
|
class="art-wrap relative aspect-square w-full overflow-hidden rounded-md
|
||||||
shadow-sm transition-all duration-150
|
shadow-xs transition-all duration-150
|
||||||
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
|
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
|
||||||
>
|
>
|
||||||
<Cover
|
<Cover
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import type { AlbumRef, AlbumDetail, TrackRef } from '$lib/api/types';
|
import type { AlbumRef, AlbumDetail, TrackRef } from '#lib/api/types.js';
|
||||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
import { FALLBACK_COVER } from '#lib/media/covers.js';
|
||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { apiClientMock } from '../../test-utils/mocks/client';
|
import { apiClientMock } from '../../test-utils/mocks/client';
|
||||||
import { makeTrack } from '$test-utils/fixtures/track';
|
import { makeTrack } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
vi.mock('$lib/api/client', () => apiClientMock());
|
vi.mock('#lib/api/client.js', () => apiClientMock());
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
enqueueTracks: vi.fn(),
|
enqueueTracks: vi.fn(),
|
||||||
playQueue: vi.fn()
|
playQueue: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('$lib/api/albums', async (orig) => {
|
vi.mock('#lib/api/albums.js', async (orig) => {
|
||||||
const actual = (await orig()) as Record<string, unknown>;
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) };
|
return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('$lib/api/playlists', async (orig) => {
|
vi.mock('#lib/api/playlists.js', async (orig) => {
|
||||||
const { readable } = await import('svelte/store');
|
const { readable } = await import('svelte/store');
|
||||||
const actual = (await orig()) as Record<string, unknown>;
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
@@ -34,8 +34,8 @@ vi.mock('$lib/api/playlists', async (orig) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import AlbumCard from './AlbumCard.svelte';
|
import AlbumCard from './AlbumCard.svelte';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '#lib/api/client.js';
|
||||||
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
import { enqueueTracks, playQueue } from '#lib/player/store.svelte.js';
|
||||||
|
|
||||||
const album: AlbumRef = {
|
const album: AlbumRef = {
|
||||||
id: 'xyz',
|
id: 'xyz',
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
import { MoreVertical, Heart, HeartOff, ListMusic, Plus, Disc3 } from 'lucide-svelte';
|
import { MoreVertical, Heart, HeartOff, ListMusic, Plus, Disc3 } from 'lucide-svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
|
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
|
||||||
import { listAlbumTracks } from '$lib/api/albums';
|
import { listAlbumTracks } from '#lib/api/albums.js';
|
||||||
import { enqueueTracks } from '$lib/player/store.svelte';
|
import { enqueueTracks } from '#lib/player/store.svelte.js';
|
||||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
albumId,
|
albumId,
|
||||||
|
|||||||
@@ -2,28 +2,28 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
import { makeTracks } from '$test-utils/fixtures/track';
|
import { makeTracks } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
|
||||||
|
|
||||||
vi.mock('$lib/api/albums', async (orig) => {
|
vi.mock('#lib/api/albums.js', async (orig) => {
|
||||||
const actual = (await orig()) as Record<string, unknown>;
|
const actual = (await orig()) as Record<string, unknown>;
|
||||||
return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) };
|
return { ...actual, listAlbumTracks: vi.fn().mockResolvedValue([]) };
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
enqueueTracks: vi.fn()
|
enqueueTracks: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import AlbumMenu from './AlbumMenu.svelte';
|
import AlbumMenu from './AlbumMenu.svelte';
|
||||||
import { enqueueTracks } from '$lib/player/store.svelte';
|
import { enqueueTracks } from '#lib/player/store.svelte.js';
|
||||||
import { listAlbumTracks } from '$lib/api/albums';
|
import { listAlbumTracks } from '#lib/api/albums.js';
|
||||||
import { likeEntity } from '$lib/api/likes';
|
import { likeEntity } from '#lib/api/likes.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
const TRACKS: TrackRef[] = makeTracks(2, {
|
const TRACKS: TrackRef[] = makeTracks(2, {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ApiError } from '$lib/api/client';
|
import type { ApiError } from '#lib/api/client.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -1,32 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
import { shuffled } from '#lib/utils/shuffle.js';
|
||||||
import { api } from '$lib/api/client';
|
import type { ArtistRef, TrackRef } from '#lib/api/types.js';
|
||||||
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
|
import { api } from '#lib/api/client.js';
|
||||||
|
import { playQueue, enqueueTracks } from '#lib/player/store.svelte.js';
|
||||||
import { Play } from 'lucide-svelte';
|
import { Play } from 'lucide-svelte';
|
||||||
import ArtistMenu from './ArtistMenu.svelte';
|
import ArtistMenu from './ArtistMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import CardActionCluster from './CardActionCluster.svelte';
|
||||||
import Cover from './Cover.svelte';
|
import Cover from './Cover.svelte';
|
||||||
import { listArtistTracks } from '$lib/api/artists';
|
import { listArtistTracks } from '#lib/api/artists.js';
|
||||||
|
|
||||||
let { artist }: { artist: ArtistRef } = $props();
|
let { artist }: { artist: ArtistRef } = $props();
|
||||||
|
|
||||||
let queueing = $state(false);
|
let queueing = $state(false);
|
||||||
|
|
||||||
function shuffle<T>(items: T[]): T[] {
|
|
||||||
const arr = items.slice();
|
|
||||||
for (let i = arr.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
||||||
}
|
|
||||||
return arr;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onPlayClick(e: MouseEvent) {
|
async function onPlayClick(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`);
|
const tracks = await api.get<TrackRef[]>(`/api/artists/${artist.id}/tracks`);
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
playQueue(shuffle(tracks), 0);
|
playQueue(shuffled(tracks), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAddToQueue(e: MouseEvent) {
|
async function onAddToQueue(e: MouseEvent) {
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { apiClientMock } from '../../test-utils/mocks/client';
|
import { apiClientMock } from '../../test-utils/mocks/client';
|
||||||
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
import type { ArtistRef, TrackRef } from '#lib/api/types.js';
|
||||||
import { makeTrack, makeTracks } from '$test-utils/fixtures/track';
|
import { makeTrack, makeTracks } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
vi.mock('$lib/api/client', () => apiClientMock());
|
vi.mock('#lib/api/client.js', () => apiClientMock());
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
playQueue: vi.fn(),
|
playQueue: vi.fn(),
|
||||||
enqueueTracks: vi.fn()
|
enqueueTracks: vi.fn()
|
||||||
}));
|
}));
|
||||||
vi.mock('$lib/api/artists', () => ({
|
vi.mock('#lib/api/artists.js', () => ({
|
||||||
listArtistTracks: vi.fn().mockResolvedValue([])
|
listArtistTracks: vi.fn().mockResolvedValue([])
|
||||||
}));
|
}));
|
||||||
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
|
||||||
import ArtistCard from './ArtistCard.svelte';
|
import ArtistCard from './ArtistCard.svelte';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '#lib/api/client.js';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '#lib/player/store.svelte.js';
|
||||||
|
|
||||||
const artist: ArtistRef = {
|
const artist: ArtistRef = {
|
||||||
id: 'art-1',
|
id: 'art-1',
|
||||||
@@ -82,7 +82,7 @@ describe('ArtistCard', () => {
|
|||||||
const fakeTracks = [
|
const fakeTracks = [
|
||||||
makeTrack({ artist_id: 'art-1', artist_name: 'BoC' })
|
makeTrack({ artist_id: 'art-1', artist_name: 'BoC' })
|
||||||
];
|
];
|
||||||
const { listArtistTracks } = await import('$lib/api/artists');
|
const { listArtistTracks } = await import('#lib/api/artists.js');
|
||||||
(listArtistTracks as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fakeTracks);
|
(listArtistTracks as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fakeTracks);
|
||||||
render(ArtistCard, { props: { artist } });
|
render(ArtistCard, { props: { artist } });
|
||||||
const btn = screen.getByRole('button', { name: /Add Boards of Canada to queue/i });
|
const btn = screen.getByRole('button', { name: /Add Boards of Canada to queue/i });
|
||||||
@@ -90,7 +90,7 @@ describe('ArtistCard', () => {
|
|||||||
// Allow promise microtask to flush.
|
// Allow promise microtask to flush.
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
expect(listArtistTracks).toHaveBeenCalledWith('art-1');
|
expect(listArtistTracks).toHaveBeenCalledWith('art-1');
|
||||||
const { enqueueTracks } = await import('$lib/player/store.svelte');
|
const { enqueueTracks } = await import('#lib/player/store.svelte.js');
|
||||||
expect(enqueueTracks).toHaveBeenCalledWith(fakeTracks);
|
expect(enqueueTracks).toHaveBeenCalledWith(fakeTracks);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { MoreVertical, Heart, HeartOff, ListMusic, Plus } from 'lucide-svelte';
|
import { MoreVertical, Heart, HeartOff, ListMusic, Plus } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
|
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '#lib/api/likes.js';
|
||||||
import { listArtistTracks } from '$lib/api/artists';
|
import { listArtistTracks } from '#lib/api/artists.js';
|
||||||
import { enqueueTracks } from '$lib/player/store.svelte';
|
import { enqueueTracks } from '#lib/player/store.svelte.js';
|
||||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
artistId,
|
artistId,
|
||||||
|
|||||||
@@ -2,27 +2,27 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
import { makeTracks } from '$test-utils/fixtures/track';
|
import { makeTracks } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
|
||||||
|
|
||||||
vi.mock('$lib/api/artists', () => ({
|
vi.mock('#lib/api/artists.js', () => ({
|
||||||
listArtistTracks: vi.fn().mockResolvedValue([])
|
listArtistTracks: vi.fn().mockResolvedValue([])
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
enqueueTracks: vi.fn()
|
enqueueTracks: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import ArtistMenu from './ArtistMenu.svelte';
|
import ArtistMenu from './ArtistMenu.svelte';
|
||||||
import { enqueueTracks } from '$lib/player/store.svelte';
|
import { enqueueTracks } from '#lib/player/store.svelte.js';
|
||||||
import { listArtistTracks } from '$lib/api/artists';
|
import { listArtistTracks } from '#lib/api/artists.js';
|
||||||
import { likeEntity, unlikeEntity } from '$lib/api/likes';
|
import { likeEntity, unlikeEntity } from '#lib/api/likes.js';
|
||||||
|
|
||||||
const TRACKS: TrackRef[] = makeTracks(2, {
|
const TRACKS: TrackRef[] = makeTracks(2, {
|
||||||
album_id: 'al1', artist_id: 'a1', artist_name: 'Test'
|
album_id: 'al1', artist_id: 'a1', artist_name: 'Test'
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The header of every page that is one collection of tracks, album and
|
||||||
|
// playlist (milestone 494): cover, title, a line or two of facts, then
|
||||||
|
// Play and Shuffle (PlayActions).
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import PlayActions from './PlayActions.svelte';
|
||||||
|
import Cover from './Cover.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
coverUrl,
|
||||||
|
coverAlt = '',
|
||||||
|
playDisabled = false,
|
||||||
|
onPlay,
|
||||||
|
onShuffle,
|
||||||
|
titleExtra,
|
||||||
|
tools,
|
||||||
|
children,
|
||||||
|
actions
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
coverUrl: string | null | undefined;
|
||||||
|
coverAlt?: string;
|
||||||
|
playDisabled?: boolean;
|
||||||
|
onPlay: () => void;
|
||||||
|
onShuffle: () => void;
|
||||||
|
/** Beside the title, e.g. the album's like button. */
|
||||||
|
titleExtra?: Snippet;
|
||||||
|
/** Top right: refresh, link, edit, delete. */
|
||||||
|
tools?: Snippet;
|
||||||
|
/** The facts under the title. */
|
||||||
|
children?: Snippet;
|
||||||
|
/** After Play and Shuffle, e.g. an admin's Refetch cover. */
|
||||||
|
actions?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="mb-6 flex items-start gap-4">
|
||||||
|
<div class="h-32 w-32 shrink-0 overflow-hidden rounded-md md:h-40 md:w-40">
|
||||||
|
<Cover src={coverUrl ?? undefined} alt={coverAlt} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1 space-y-2">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<h1 class="min-w-0 truncate font-display text-2xl font-medium text-text-primary">{title}</h1>
|
||||||
|
{#if titleExtra}{@render titleExtra()}{/if}
|
||||||
|
{#if tools}
|
||||||
|
<div class="ml-auto flex shrink-0 items-center gap-1">{@render tools()}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if children}{@render children()}{/if}
|
||||||
|
|
||||||
|
<div class="pt-1">
|
||||||
|
<PlayActions name={title} disabled={playDisabled} {onPlay} {onShuffle}>
|
||||||
|
{#if actions}{@render actions()}{/if}
|
||||||
|
</PlayActions>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Plus } from 'lucide-svelte';
|
import { Plus } from 'lucide-svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
import { playQueue, enqueueTrack } from '#lib/player/store.svelte.js';
|
||||||
import { coverUrl } from '$lib/media/covers';
|
import { coverUrl } from '#lib/media/covers.js';
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
import LikeButton from './LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
import Cover from './Cover.svelte';
|
import Cover from './Cover.svelte';
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
class="flex w-full items-center gap-2 rounded-md p-1 pr-24 text-left
|
class="flex w-full items-center gap-2 rounded-md p-1 pr-24 text-left
|
||||||
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
>
|
>
|
||||||
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
|
<div class="h-12 w-12 shrink-0 overflow-hidden rounded bg-surface-hover">
|
||||||
<Cover src={cover} />
|
<Cover src={cover} />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '#lib/api/types.js';
|
||||||
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
import { emptyLikesMock } from '../../test-utils/mocks/likes';
|
||||||
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
import { emptyPlaylistsMock } from '../../test-utils/mocks/playlists';
|
||||||
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
|
import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine';
|
||||||
import { makeTracks } from '$test-utils/fixtures/track';
|
import { makeTracks } from '#test-utils/fixtures/track.js';
|
||||||
|
|
||||||
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
|
||||||
|
|
||||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
|
||||||
|
|
||||||
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
vi.mock('#lib/api/playlists.js', () => emptyPlaylistsMock());
|
||||||
|
|
||||||
vi.mock('$lib/auth/store.svelte', () => ({
|
vi.mock('#lib/auth/store.svelte.js', () => ({
|
||||||
user: { get value() { return { id: 'u1', username: 'u', is_admin: false }; } }
|
user: { get value() { return { id: 'u1', username: 'u', is_admin: false }; } }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('#lib/player/store.svelte.js', () => ({
|
||||||
playQueue: vi.fn(),
|
playQueue: vi.fn(),
|
||||||
enqueueTrack: vi.fn(),
|
enqueueTrack: vi.fn(),
|
||||||
playNext: vi.fn()
|
playNext: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import CompactTrackCard from './CompactTrackCard.svelte';
|
import CompactTrackCard from './CompactTrackCard.svelte';
|
||||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
import { playQueue, enqueueTrack } from '#lib/player/store.svelte.js';
|
||||||
|
|
||||||
const titles = ['First', 'Second', 'Third'];
|
const titles = ['First', 'Second', 'Third'];
|
||||||
const tracks: TrackRef[] = makeTracks(3, { artist_name: 'Artist' }).map((t, i) => ({
|
const tracks: TrackRef[] = makeTracks(3, { artist_name: 'Artist' }).map((t, i) => ({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
import { FALLBACK_COVER } from '#lib/media/covers.js';
|
||||||
import { Disc3 } from 'lucide-svelte';
|
import { Disc3 } from 'lucide-svelte';
|
||||||
|
|
||||||
// Shared cover-artwork renderer. The web counterpart of Android's
|
// Shared cover-artwork renderer. The web counterpart of Android's
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Bring ${title} back`}
|
aria-label={`Bring ${title} back`}
|
||||||
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-hidden focus:ring-2 focus:ring-accent"
|
||||||
onclick={() => onUnsnooze?.()}
|
onclick={() => onUnsnooze?.()}
|
||||||
>
|
>
|
||||||
Undo
|
Undo
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`Not right now — hide ${title} for a while`}
|
aria-label={`Not right now — hide ${title} for a while`}
|
||||||
title="Not right now"
|
title="Not right now"
|
||||||
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-hidden focus:ring-2 focus:ring-accent"
|
||||||
onclick={() => onSnooze?.()}
|
onclick={() => onSnooze?.()}
|
||||||
>
|
>
|
||||||
<Clock size={16} strokeWidth={1} />
|
<Clock size={16} strokeWidth={1} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/svelte';
|
import { render, screen } from '@testing-library/svelte';
|
||||||
import { pageUrlModule } from '$test-utils/mocks/appState';
|
import { pageUrlModule } from '#test-utils/mocks/appState.js';
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
const state = vi.hoisted(() => ({
|
||||||
pageUrl: new URL('http://localhost/discover')
|
pageUrl: new URL('http://localhost/discover')
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
getFingerprintSettings,
|
getFingerprintSettings,
|
||||||
updateFingerprintSettings,
|
updateFingerprintSettings,
|
||||||
type FingerprintSettings
|
type FingerprintSettings
|
||||||
} from '$lib/api/admin';
|
} from '#lib/api/admin.js';
|
||||||
import { errMessage } from '$lib/api/errors';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
|
|
||||||
// How Minstrel fingerprints tracks and when it looks for duplicates (#3913).
|
// How Minstrel fingerprints tracks and when it looks for duplicates (#3913).
|
||||||
// Lives on the Duplicates page, beside the results these settings shape.
|
// Lives on the Duplicates page, beside the results these settings shape.
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
|
'mt-1 w-28 rounded border border-border bg-background px-2 py-1 text-sm text-text-primary ' +
|
||||||
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
|
'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
|
||||||
@@ -115,6 +115,19 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-start gap-3">
|
||||||
|
<input type="checkbox" bind:checked={form.auto_resolve} class="mt-1" />
|
||||||
|
<span>
|
||||||
|
<span class="text-sm text-text-primary">Resolve duplicates automatically</span>
|
||||||
|
<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
|
||||||
|
release that lists songs twice. A song kept on several releases counts as one song, and
|
||||||
|
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>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="text-sm text-text-primary">Seconds of audio to fingerprint</span>
|
<span class="text-sm text-text-primary">Seconds of audio to fingerprint</span>
|
||||||
@@ -183,7 +196,7 @@
|
|||||||
class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary"
|
class="flex items-start gap-2 rounded-md bg-surface-hover px-3 py-2 text-xs text-text-secondary"
|
||||||
data-testid="length-warning"
|
data-testid="length-warning"
|
||||||
>
|
>
|
||||||
<TriangleAlert size={14} class="mt-0.5 flex-shrink-0 text-action-destructive" aria-hidden="true" />
|
<TriangleAlert size={14} class="mt-0.5 shrink-0 text-action-destructive" aria-hidden="true" />
|
||||||
<span>
|
<span>
|
||||||
Saving re-fingerprints {libraryTotal > 0
|
Saving re-fingerprints {libraryTotal > 0
|
||||||
? `all ${libraryTotal.toLocaleString()} tracks`
|
? `all ${libraryTotal.toLocaleString()} tracks`
|
||||||
@@ -206,7 +219,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
class="rounded-md bg-action-secondary px-4 py-2 text-sm text-action-fg hover:opacity-90
|
||||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent
|
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent
|
||||||
disabled:cursor-not-allowed disabled:opacity-50"
|
disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={!dirty || saving || problems.length > 0}
|
disabled={!dirty || saving || problems.length > 0}
|
||||||
onclick={save}
|
onclick={save}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user