feat: import with verification, and a Wanted view of its own (M502 #5522)
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 15s
release / go (push) Failing after 1m16s
release / integration (push) Failing after 4m49s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

A finished download is checked against MusicBrainz before anything is
filed: the files are paired with an edition's tracks by position or path,
and AcoustID, tag ids, lengths and titles say whether it is the album.
What is the album is copied into the library as
Artist/Album (Year)/Artist - Album - NN - Title, tagged with Picard's
MusicBrainz ids, and scanned; the download keeps seeding. What is not is
refused: set aside, removed from the client, and the album searched for
again. A download the check cannot settle waits for a person, who imports
it anyway or refuses it. Tracks the library already holds are skipped.

The wanted list leaves Admin for a top-level Wanted view, admin-only and
web-only, with tabs for Wanted, Activity (downloads and their imports,
the ones needing a person first), Missing files, Held, Following (with the
release-type profiles, moved off Integrations) and Blocked. The search
settings gain "File new music into" when the library has several folders.

The search pass logs why each failed search failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 10:36:21 -04:00
co-authored by Claude Opus 5.5
parent 77081f1cef
commit ebbc49b902
63 changed files with 4892 additions and 555 deletions
+2
View File
@@ -19,6 +19,8 @@ import (
const (
BlockOperator = "operator"
BlockFiles = "files"
// BlockImport: the import found its files were not the album (#5522).
BlockImport = "import"
)
// Grab origins, matching grabs_origin_check.
+205
View File
@@ -0,0 +1,205 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
)
// Downloads and their imports (M502 #5522): the Wanted view's Activity tab,
// a person's "import anyway", "refuse" and "retry", and the Blocked tab.
// downloadView is one grab and what its import made of it.
type downloadView struct {
ID string `json:"id"`
WantID *string `json:"want_id"`
WantTitle string `json:"want_title"`
WantArtist string `json:"want_artist"`
Name string `json:"name"`
Indexer string `json:"indexer"`
Client string `json:"client"`
Quality string `json:"quality"`
Origin string `json:"origin"`
GrabbedAt string `json:"grabbed_at"`
EndedAt *string `json:"ended_at"`
EndReason string `json:"end_reason"`
Progress float32 `json:"progress"`
ImportState string `json:"import_state"`
ImportReason string `json:"import_reason"`
ImportedAt *string `json:"imported_at"`
ImportedFiles int32 `json:"imported_files"`
Destination string `json:"destination"`
Edition string `json:"edition,omitempty"`
Evidence *importer.Evidence `json:"evidence,omitempty"`
Files []string `json:"files"`
Skipped int `json:"skipped"`
RehomedFrom string `json:"rehomed_from,omitempty"`
}
func downloadFrom(g dbq.ListGrabsRow) downloadView {
v := downloadView{
ID: uuidToString(g.ID), Name: g.Name, Indexer: g.Indexer, Client: g.Client, Quality: g.Quality,
Origin: g.Origin, GrabbedAt: formatTimestamp(g.GrabbedAt), EndReason: g.EndReason,
Progress: g.Progress, ImportState: g.ImportState, ImportReason: g.ImportReason,
ImportedFiles: g.ImportedFiles, Destination: g.Destination, Files: []string{},
}
if g.WantID.Valid {
id := uuidToString(g.WantID)
v.WantID = &id
}
if g.WantTitle != nil {
v.WantTitle = *g.WantTitle
}
if g.WantArtist != nil {
v.WantArtist = *g.WantArtist
}
if g.EndedAt.Valid {
at := formatTimestamp(g.EndedAt)
v.EndedAt = &at
}
if g.ImportedAt.Valid {
at := formatTimestamp(g.ImportedAt)
v.ImportedAt = &at
}
var det importer.Detail
if len(g.ImportDetail) > 0 && json.Unmarshal(g.ImportDetail, &det) == nil && det.Verdict.Outcome != "" {
ev := det.Verdict.Evidence
v.Evidence, v.Edition, v.Skipped, v.RehomedFrom = &ev, det.Edition, det.Skipped, det.RehomedFrom
if det.Files != nil {
v.Files = det.Files
}
}
return v
}
type downloadsPage struct {
Items []downloadView `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// handleListDownloads implements GET /api/admin/downloads?offset=: running
// downloads first, then the rest newest first.
func (h *handlers) handleListDownloads(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
writeErr(w, apierror.BadRequest("invalid_paging", "limit and offset must be numbers"))
return
}
rows, err := dbq.New(h.pool).ListGrabs(r.Context(), dbq.ListGrabsParams{MaxRows: int32(limit), Skip: int32(offset)})
if err != nil {
writeErrWithLog(w, h.logger, "admin downloads: list", apierror.Internal(err))
return
}
out := downloadsPage{Items: make([]downloadView, 0, len(rows)), Limit: limit, Offset: offset}
for _, g := range rows {
out.Items = append(out.Items, downloadFrom(g))
}
writeJSON(w, http.StatusOK, out)
}
// handleImportAnyway implements POST /api/admin/downloads/{id}/import: a
// download the check held for a person (or one that failed) is imported on
// the next pass whatever the check says.
func (h *handlers) handleImportAnyway(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if _, err := dbq.New(h.pool).ForceGrabImport(r.Context(), id); err != nil {
h.writeImportErr(w, "import anyway", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRetryImport implements POST /api/admin/downloads/{id}/retry.
func (h *handlers) handleRetryImport(w http.ResponseWriter, r *http.Request) {
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if _, err := dbq.New(h.pool).RetryGrabImport(r.Context(), id); err != nil {
h.writeImportErr(w, "retry", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRefuseDownload implements POST /api/admin/downloads/{id}/refuse: the
// release is blocked, the download removed and the album searched again.
func (h *handlers) handleRefuseDownload(w http.ResponseWriter, r *http.Request) {
admin, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
if _, err := h.importer.Refuse(r.Context(), id, admin.ID); err != nil {
h.writeImportErr(w, "refuse", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *handlers) writeImportErr(w http.ResponseWriter, what string, err error) {
switch {
case errors.Is(err, pgx.ErrNoRows), errors.Is(err, importer.ErrNotDecidable):
writeErr(w, apierror.Conflict("not_decidable", "that download is not waiting on a decision"))
default:
writeErrWithLog(w, h.logger, "admin downloads: "+what, apierror.Internal(err))
}
}
type releaseBlockView struct {
InfoHash string `json:"info_hash"`
Name string `json:"name"`
Reason string `json:"reason"`
Detail string `json:"detail"`
BlockedAt string `json:"blocked_at"`
WantTitle string `json:"want_title"`
WantArtist string `json:"want_artist"`
}
type releaseBlocksPage struct {
Items []releaseBlockView `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// handleListReleaseBlocks implements GET /api/admin/release-blocks?offset=:
// every release set aside, by a person or by a check, newest first.
func (h *handlers) handleListReleaseBlocks(w http.ResponseWriter, r *http.Request) {
limit, offset, err := parsePaging(r.URL.Query())
if err != nil {
writeErr(w, apierror.BadRequest("invalid_paging", "limit and offset must be numbers"))
return
}
rows, err := dbq.New(h.pool).ListReleaseBlocksPage(r.Context(), dbq.ListReleaseBlocksPageParams{
MaxRows: int32(limit), Skip: int32(offset)})
if err != nil {
writeErrWithLog(w, h.logger, "admin release blocks: list", apierror.Internal(err))
return
}
out := releaseBlocksPage{Items: make([]releaseBlockView, 0, len(rows)), Limit: limit, Offset: offset}
for _, b := range rows {
v := releaseBlockView{InfoHash: b.InfoHash, Name: b.Name, Reason: b.Reason, Detail: b.Detail,
BlockedAt: formatTimestamp(b.BlockedAt)}
if b.WantTitle != nil {
v.WantTitle = *b.WantTitle
}
if b.WantArtist != nil {
v.WantArtist = *b.WantArtist
}
out.Items = append(out.Items, v)
}
writeJSON(w, http.StatusOK, out)
}
+18 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"net/http"
"slices"
"time"
"github.com/go-chi/chi/v5"
@@ -32,12 +33,20 @@ type searchSettingsView struct {
Tiers []release.TierInfo `json:"tiers"`
LastPassAt *string `json:"last_pass_at"`
LastPass json.RawMessage `json:"last_pass"`
// ImportRoot is the library folder new music is filed into (#5522); ""
// is the first of ScanPaths, the library's folders.
ImportRoot string `json:"import_root"`
ScanPaths []string `json:"scan_paths"`
}
func searchSettings(s dbq.AcquisitionSetting) searchSettingsView {
func searchSettings(s dbq.AcquisitionSetting, roots []string) searchSettingsView {
v := searchSettingsView{
AutoGrab: s.AutoGrab, SearchIntervalHours: s.SearchIntervalHours,
AllowedQualities: s.AllowedQualities, Tiers: release.Tiers, LastPass: s.LastPass,
ImportRoot: s.ImportRoot, ScanPaths: roots,
}
if v.ScanPaths == nil {
v.ScanPaths = []string{}
}
if v.AllowedQualities == nil {
v.AllowedQualities = []string{}
@@ -59,13 +68,14 @@ func (h *handlers) handleGetSearchSettings(w http.ResponseWriter, r *http.Reques
writeErrWithLog(w, h.logger, "admin search settings: get", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, searchSettings(s))
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots))
}
type putSearchSettingsBody struct {
AutoGrab bool `json:"auto_grab"`
SearchIntervalHours int32 `json:"search_interval_hours"`
AllowedQualities []string `json:"allowed_qualities"`
ImportRoot string `json:"import_root"`
}
// handlePutSearchSettings implements PUT /api/admin/acquisition/settings.
@@ -88,14 +98,19 @@ func (h *handlers) handlePutSearchSettings(w http.ResponseWriter, r *http.Reques
return
}
}
if body.ImportRoot != "" && !slices.Contains(h.importer.Roots, body.ImportRoot) {
writeErr(w, apierror.BadRequest("bad_import_root", "file new music into one of the library's folders"))
return
}
s, err := dbq.New(h.pool).UpdateAcquisitionSettings(r.Context(), dbq.UpdateAcquisitionSettingsParams{
AutoGrab: body.AutoGrab, SearchIntervalHours: body.SearchIntervalHours, AllowedQualities: body.AllowedQualities,
ImportRoot: body.ImportRoot,
})
if err != nil {
writeErrWithLog(w, h.logger, "admin search settings: put", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, searchSettings(s))
writeJSON(w, http.StatusOK, searchSettings(s, h.importer.Roots))
}
// candidateView is one release a want's last search found.
+41 -10
View File
@@ -49,11 +49,21 @@ type wantSearchView struct {
Error string `json:"error"`
}
// wantGrabView is the want's latest download, running or ended, and what
// its import made of it (#5522).
type wantGrabView struct {
Name string `json:"name"`
GrabbedAt string `json:"grabbed_at"`
Origin string `json:"origin"`
Quality string `json:"quality"`
ID string `json:"id"`
Name string `json:"name"`
GrabbedAt string `json:"grabbed_at"`
Origin string `json:"origin"`
Quality string `json:"quality"`
Live bool `json:"live"`
EndReason string `json:"end_reason"`
Progress float32 `json:"progress"`
ImportState string `json:"import_state"`
ImportReason string `json:"import_reason"`
ImportedAt *string `json:"imported_at"`
ImportedFiles int32 `json:"imported_files"`
}
// wantCounts sizes each tab of the Wanted page.
@@ -62,6 +72,11 @@ type wantCounts struct {
Missing int64 `json:"missing"`
Held int64 `json:"held"`
Following int64 `json:"following"`
// Activity: downloads running; Attention: of those, the ones waiting on
// a person; Blocked: releases set aside (#5522).
Activity int64 `json:"activity"`
Attention int64 `json:"attention"`
Blocked int64 `json:"blocked"`
}
type wantsPage struct {
@@ -102,7 +117,8 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
}
out := wantsPage{
Items: make([]wantView, 0, len(rows)), Limit: wantsPageSize, Offset: offset,
Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following},
Counts: wantCounts{Wanted: counts.Wanted, Missing: counts.Missing, Held: counts.Held, Following: counts.Following,
Activity: counts.Activity, Attention: counts.Attention, Blocked: counts.Blocked},
}
switch state {
case "missing":
@@ -145,12 +161,19 @@ func (h *handlers) handleListWants(w http.ResponseWriter, r *http.Request) {
}
}
if row.GrabName != nil {
v.Grab = &wantGrabView{Name: *row.GrabName, GrabbedAt: formatTimestamp(row.GrabbedAt)}
if row.GrabOrigin != nil {
v.Grab.Origin = *row.GrabOrigin
v.Grab = &wantGrabView{ID: uuidToString(row.GrabID), Name: *row.GrabName,
GrabbedAt: formatTimestamp(row.GrabbedAt), Live: !row.GrabEndedAt.Valid,
Origin: deref(row.GrabOrigin), Quality: deref(row.GrabQuality), EndReason: deref(row.GrabEndReason),
ImportState: deref(row.GrabImportState), ImportReason: deref(row.GrabImportReason)}
if row.GrabProgress != nil {
v.Grab.Progress = *row.GrabProgress
}
if row.GrabQuality != nil {
v.Grab.Quality = *row.GrabQuality
if row.GrabImportedFiles != nil {
v.Grab.ImportedFiles = *row.GrabImportedFiles
}
if row.GrabImportedAt.Valid {
at := formatTimestamp(row.GrabImportedAt)
v.Grab.ImportedAt = &at
}
}
out.Items = append(out.Items, v)
@@ -425,3 +448,11 @@ func (h *handlers) writeProfileErr(w http.ResponseWriter, err error) {
writeErrWithLog(w, h.logger, "admin release profiles", apierror.Internal(err))
}
}
// deref is a nullable column's text, or "".
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
+19
View File
@@ -19,6 +19,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/importer"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
@@ -78,6 +79,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
catalogue: cat,
wanted: wanted.New(pool, cat, logger),
searcher: wanted.NewSearcher(pool, cat, logger),
importer: importer.New(pool, scanner, cat, nil, scannerRoots(scanner), logger),
catalogueSearches: newSearchCache(),
catalogueArt: catalogue.NewArt(dataDir, artSources(coverSettings)),
leveled: newLeveledRenderer(dataDir, loudSettings, logger),
@@ -257,6 +259,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Post("/wanted/{id}/grab", h.handleGrabCandidate)
admin.Post("/wanted/{id}/block", h.handleBlockCandidate)
admin.Delete("/release-blocks/{hash}", h.handleUnblockRelease)
admin.Get("/release-blocks", h.handleListReleaseBlocks)
admin.Get("/downloads", h.handleListDownloads)
admin.Post("/downloads/{id}/import", h.handleImportAnyway)
admin.Post("/downloads/{id}/retry", h.handleRetryImport)
admin.Post("/downloads/{id}/refuse", h.handleRefuseDownload)
admin.Get("/follows", h.handleListFollows)
admin.Get("/follows/{mbid}", h.handleGetFollow)
admin.Put("/follows/{mbid}", h.handlePutFollow)
@@ -427,6 +434,9 @@ type handlers struct {
wanted *wanted.Service
// searcher searches for wants and grabs releases for them (#5521).
searcher *wanted.Searcher
// importer refuses a download at a person's word (#5522). The import
// loop itself runs in main; this one only acts on a request.
importer *importer.Importer
// catalogueSearches reuses recent MusicBrainz answers; nil asks every
// time, which is what handler tests get.
catalogueSearches *searchCache
@@ -482,3 +492,12 @@ func artSources(s *coverart.SettingsService) catalogue.ArtSources {
}
return s
}
// scannerRoots are the library folders, or none when the API runs without a
// scanner (tests).
func scannerRoots(s *library.Scanner) []string {
if s == nil {
return nil
}
return s.Roots()
}
+719
View File
@@ -0,0 +1,719 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: imports.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const claimGrabImport = `-- name: ClaimGrabImport :one
UPDATE grabs
SET import_state = 'importing', import_attempts = import_attempts + 1,
import_updated_at = now(), progress = 1
WHERE id = $1 AND ended_at IS NULL AND import_state IN ('', 'downloading', 'waiting')
RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination
`
// Takes a finished download for import. The update is the lock: a second
// pass finds nothing to claim.
func (q *Queries) ClaimGrabImport(ctx context.Context, id pgtype.UUID) (Grab, error) {
row := q.db.QueryRow(ctx, claimGrabImport, id)
var i Grab
err := row.Scan(
&i.ID,
&i.WantID,
&i.InfoHash,
&i.Name,
&i.Key,
&i.Indexer,
&i.Source,
&i.Client,
&i.SavePath,
&i.Quality,
&i.Origin,
&i.Forced,
&i.GrabbedBy,
&i.GrabbedAt,
&i.EndedAt,
&i.EndReason,
&i.EndDetail,
&i.Progress,
&i.ImportState,
&i.ImportReason,
&i.ImportDetail,
&i.ImportForced,
&i.ImportAttempts,
&i.ImportUpdatedAt,
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
)
return i, err
}
const forceGrabImport = `-- name: ForceGrabImport :one
UPDATE grabs
SET import_state = '', import_forced = true, import_reason = '', import_updated_at = now()
WHERE id = $1 AND ended_at IS NULL AND import_state IN ('review', 'failed')
RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination
`
// "Import anyway": the next pass imports the download whatever the check said.
func (q *Queries) ForceGrabImport(ctx context.Context, id pgtype.UUID) (Grab, error) {
row := q.db.QueryRow(ctx, forceGrabImport, id)
var i Grab
err := row.Scan(
&i.ID,
&i.WantID,
&i.InfoHash,
&i.Name,
&i.Key,
&i.Indexer,
&i.Source,
&i.Client,
&i.SavePath,
&i.Quality,
&i.Origin,
&i.Forced,
&i.GrabbedBy,
&i.GrabbedAt,
&i.EndedAt,
&i.EndReason,
&i.EndDetail,
&i.Progress,
&i.ImportState,
&i.ImportReason,
&i.ImportDetail,
&i.ImportForced,
&i.ImportAttempts,
&i.ImportUpdatedAt,
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
)
return i, err
}
const getGrab = `-- name: GetGrab :one
SELECT id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination FROM grabs WHERE id = $1
`
func (q *Queries) GetGrab(ctx context.Context, id pgtype.UUID) (Grab, error) {
row := q.db.QueryRow(ctx, getGrab, id)
var i Grab
err := row.Scan(
&i.ID,
&i.WantID,
&i.InfoHash,
&i.Name,
&i.Key,
&i.Indexer,
&i.Source,
&i.Client,
&i.SavePath,
&i.Quality,
&i.Origin,
&i.Forced,
&i.GrabbedBy,
&i.GrabbedAt,
&i.EndedAt,
&i.EndReason,
&i.EndDetail,
&i.Progress,
&i.ImportState,
&i.ImportReason,
&i.ImportDetail,
&i.ImportForced,
&i.ImportAttempts,
&i.ImportUpdatedAt,
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
)
return i, err
}
const heldPositions = `-- name: HeldPositions :many
SELECT DISTINCT COALESCE(t.disc_number, 1)::int AS disc, t.track_number::int AS track
FROM tracks t
JOIN albums a ON a.id = t.album_id
WHERE a.release_group_mbid = $1
AND t.missing_since IS NULL AND t.track_number IS NOT NULL
`
type HeldPositionsRow struct {
Disc int32
Track int32
}
// The disc and track positions the library holds for an album of this group.
func (q *Queries) HeldPositions(ctx context.Context, releaseGroupMbid *string) ([]HeldPositionsRow, error) {
rows, err := q.db.Query(ctx, heldPositions, releaseGroupMbid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []HeldPositionsRow
for rows.Next() {
var i HeldPositionsRow
if err := rows.Scan(&i.Disc, &i.Track); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const heldRecordings = `-- name: HeldRecordings :many
SELECT DISTINCT mbid::text AS mbid FROM tracks
WHERE mbid = ANY($1::text[]) AND missing_since IS NULL
`
// Which of these recordings the library holds a file for.
func (q *Queries) HeldRecordings(ctx context.Context, recordings []string) ([]string, error) {
rows, err := q.db.Query(ctx, heldRecordings, recordings)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var mbid string
if err := rows.Scan(&mbid); err != nil {
return nil, err
}
items = append(items, mbid)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listGrabs = `-- name: ListGrabs :many
SELECT g.id, g.want_id, g.name, g.indexer, g.client, g.quality, g.origin, g.forced,
g.grabbed_at, g.ended_at, g.end_reason, g.end_detail, g.progress, g.import_state,
g.import_reason, g.import_detail, g.import_updated_at, g.imported_at, g.imported_files,
g.destination, w.title AS want_title, w.artist_name AS want_artist
FROM grabs g
LEFT JOIN wants w ON w.id = g.want_id
ORDER BY (g.ended_at IS NULL AND g.import_state IN ('review', 'failed')) DESC,
g.ended_at IS NULL DESC, g.grabbed_at DESC, g.id
LIMIT $2 OFFSET $1
`
type ListGrabsParams struct {
Skip int32
MaxRows int32
}
type ListGrabsRow struct {
ID pgtype.UUID
WantID pgtype.UUID
Name string
Indexer string
Client string
Quality string
Origin string
Forced bool
GrabbedAt pgtype.Timestamptz
EndedAt pgtype.Timestamptz
EndReason string
EndDetail string
Progress float32
ImportState string
ImportReason string
ImportDetail []byte
ImportUpdatedAt pgtype.Timestamptz
ImportedAt pgtype.Timestamptz
ImportedFiles int32
Destination string
WantTitle *string
WantArtist *string
}
// The Wanted view's Activity tab: downloads waiting on a person first, then
// the rest still running, then the ended ones newest first.
func (q *Queries) ListGrabs(ctx context.Context, arg ListGrabsParams) ([]ListGrabsRow, error) {
rows, err := q.db.Query(ctx, listGrabs, arg.Skip, arg.MaxRows)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListGrabsRow
for rows.Next() {
var i ListGrabsRow
if err := rows.Scan(
&i.ID,
&i.WantID,
&i.Name,
&i.Indexer,
&i.Client,
&i.Quality,
&i.Origin,
&i.Forced,
&i.GrabbedAt,
&i.EndedAt,
&i.EndReason,
&i.EndDetail,
&i.Progress,
&i.ImportState,
&i.ImportReason,
&i.ImportDetail,
&i.ImportUpdatedAt,
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
&i.WantTitle,
&i.WantArtist,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLiveGrabs = `-- name: ListLiveGrabs :many
SELECT g.id, g.want_id, g.info_hash, g.name, g.key, g.indexer, g.source, g.client, g.save_path, g.quality, g.origin, g.forced, g.grabbed_by, g.grabbed_at, g.ended_at, g.end_reason, g.end_detail, g.progress, g.import_state, g.import_reason, g.import_detail, g.import_forced, g.import_attempts, g.import_updated_at, g.imported_at, g.imported_files, g.destination, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title AS want_title,
w.reason AS want_reason, w.album_id AS want_album_id
FROM grabs g
LEFT JOIN wants w ON w.id = g.want_id
WHERE g.ended_at IS NULL
ORDER BY g.grabbed_at, g.id
`
type ListLiveGrabsRow struct {
ID pgtype.UUID
WantID pgtype.UUID
InfoHash string
Name string
Key string
Indexer string
Source string
Client string
SavePath string
Quality string
Origin string
Forced bool
GrabbedBy pgtype.UUID
GrabbedAt pgtype.Timestamptz
EndedAt pgtype.Timestamptz
EndReason string
EndDetail string
Progress float32
ImportState string
ImportReason string
ImportDetail []byte
ImportForced bool
ImportAttempts int32
ImportUpdatedAt pgtype.Timestamptz
ImportedAt pgtype.Timestamptz
ImportedFiles int32
Destination string
ReleaseGroupMbid *string
ArtistMbid *string
ArtistName *string
WantTitle *string
WantReason *string
WantAlbumID pgtype.UUID
}
// Import with verification (M502 #5522).
// Every download still running or waiting on its import, oldest first.
func (q *Queries) ListLiveGrabs(ctx context.Context) ([]ListLiveGrabsRow, error) {
rows, err := q.db.Query(ctx, listLiveGrabs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLiveGrabsRow
for rows.Next() {
var i ListLiveGrabsRow
if err := rows.Scan(
&i.ID,
&i.WantID,
&i.InfoHash,
&i.Name,
&i.Key,
&i.Indexer,
&i.Source,
&i.Client,
&i.SavePath,
&i.Quality,
&i.Origin,
&i.Forced,
&i.GrabbedBy,
&i.GrabbedAt,
&i.EndedAt,
&i.EndReason,
&i.EndDetail,
&i.Progress,
&i.ImportState,
&i.ImportReason,
&i.ImportDetail,
&i.ImportForced,
&i.ImportAttempts,
&i.ImportUpdatedAt,
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.WantTitle,
&i.WantReason,
&i.WantAlbumID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listReleaseBlocksPage = `-- name: ListReleaseBlocksPage :many
SELECT b.info_hash, b.name, b.reason, b.detail, b.blocked_at, w.title AS want_title,
w.artist_name AS want_artist
FROM release_blocks b
LEFT JOIN wants w ON w.id = b.want_id
ORDER BY b.blocked_at DESC, b.info_hash
LIMIT $2 OFFSET $1
`
type ListReleaseBlocksPageParams struct {
Skip int32
MaxRows int32
}
type ListReleaseBlocksPageRow struct {
InfoHash string
Name string
Reason string
Detail string
BlockedAt pgtype.Timestamptz
WantTitle *string
WantArtist *string
}
func (q *Queries) ListReleaseBlocksPage(ctx context.Context, arg ListReleaseBlocksPageParams) ([]ListReleaseBlocksPageRow, error) {
rows, err := q.db.Query(ctx, listReleaseBlocksPage, arg.Skip, arg.MaxRows)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListReleaseBlocksPageRow
for rows.Next() {
var i ListReleaseBlocksPageRow
if err := rows.Scan(
&i.InfoHash,
&i.Name,
&i.Reason,
&i.Detail,
&i.BlockedAt,
&i.WantTitle,
&i.WantArtist,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markWantSearched = `-- name: MarkWantSearched :exec
UPDATE wants SET searched_at = now() WHERE id = $1
`
// Imported: nothing to search for until the wanted pass settles the want.
func (q *Queries) MarkWantSearched(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, markWantSearched, id)
return err
}
const openWantsForGroups = `-- name: OpenWantsForGroups :many
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.primary_type, w.first_release_date, w.reason, w.album_id, w.created_by, w.wanted_at, w.fulfilled_at, w.created_at, w.updated_at, w.searched_at, w.search_outcome, w.search_considered, w.best_release, w.best_verdict, w.search_error FROM wants w
WHERE w.fulfilled_at IS NULL AND w.release_group_mbid = ANY($1::text[])
AND NOT EXISTS (SELECT 1 FROM grabs g WHERE g.want_id = w.id AND g.ended_at IS NULL)
`
// Open wants for these release groups with no download running.
func (q *Queries) OpenWantsForGroups(ctx context.Context, groups []string) ([]Want, error) {
rows, err := q.db.Query(ctx, openWantsForGroups, groups)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Want
for rows.Next() {
var i Want
if err := rows.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.PrimaryType,
&i.FirstReleaseDate,
&i.Reason,
&i.AlbumID,
&i.CreatedBy,
&i.WantedAt,
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.SearchedAt,
&i.SearchOutcome,
&i.SearchConsidered,
&i.BestRelease,
&i.BestVerdict,
&i.SearchError,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const reclaimStaleImports = `-- name: ReclaimStaleImports :execrows
UPDATE grabs
SET import_state = 'waiting',
import_reason = 'The last import was cut short, so it is being tried again.'
WHERE ended_at IS NULL AND import_state = 'importing'
AND import_updated_at < now() - make_interval(mins => $1::int)
`
// An import cut short (a restart mid-copy) is tried again.
func (q *Queries) ReclaimStaleImports(ctx context.Context, minutes int32) (int64, error) {
result, err := q.db.Exec(ctx, reclaimStaleImports, minutes)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const recordImport = `-- name: RecordImport :one
UPDATE grabs
SET import_state = $1,
import_reason = $2,
import_detail = $3,
import_updated_at = now(),
import_forced = false,
imported_at = CASE WHEN $1::text = 'imported' THEN now() ELSE imported_at END,
imported_files = $4,
destination = $5,
ended_at = CASE WHEN $6::text <> '' THEN now() ELSE ended_at END,
end_reason = $6,
end_detail = CASE WHEN $6::text <> '' THEN $2::text ELSE end_detail END
WHERE id = $7
RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination
`
type RecordImportParams struct {
ImportState string
ImportReason string
ImportDetail []byte
ImportedFiles int32
Destination string
EndReason string
ID pgtype.UUID
}
// The import's outcome. An end reason ends the grab: imported, refused or
// held. Without one the grab stays live: review and failed wait for a person.
func (q *Queries) RecordImport(ctx context.Context, arg RecordImportParams) (Grab, error) {
row := q.db.QueryRow(ctx, recordImport,
arg.ImportState,
arg.ImportReason,
arg.ImportDetail,
arg.ImportedFiles,
arg.Destination,
arg.EndReason,
arg.ID,
)
var i Grab
err := row.Scan(
&i.ID,
&i.WantID,
&i.InfoHash,
&i.Name,
&i.Key,
&i.Indexer,
&i.Source,
&i.Client,
&i.SavePath,
&i.Quality,
&i.Origin,
&i.Forced,
&i.GrabbedBy,
&i.GrabbedAt,
&i.EndedAt,
&i.EndReason,
&i.EndDetail,
&i.Progress,
&i.ImportState,
&i.ImportReason,
&i.ImportDetail,
&i.ImportForced,
&i.ImportAttempts,
&i.ImportUpdatedAt,
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
)
return i, err
}
const rehomeGrab = `-- name: RehomeGrab :one
UPDATE grabs g
SET want_id = $1
WHERE g.id = $2 AND g.ended_at IS NULL
AND NOT EXISTS (SELECT 1 FROM grabs o WHERE o.want_id = $1 AND o.ended_at IS NULL)
RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination
`
type RehomeGrabParams struct {
WantID pgtype.UUID
ID pgtype.UUID
}
// A download that turned out to be another wanted album is that want's grab,
// when the want has none running.
func (q *Queries) RehomeGrab(ctx context.Context, arg RehomeGrabParams) (Grab, error) {
row := q.db.QueryRow(ctx, rehomeGrab, arg.WantID, arg.ID)
var i Grab
err := row.Scan(
&i.ID,
&i.WantID,
&i.InfoHash,
&i.Name,
&i.Key,
&i.Indexer,
&i.Source,
&i.Client,
&i.SavePath,
&i.Quality,
&i.Origin,
&i.Forced,
&i.GrabbedBy,
&i.GrabbedAt,
&i.EndedAt,
&i.EndReason,
&i.EndDetail,
&i.Progress,
&i.ImportState,
&i.ImportReason,
&i.ImportDetail,
&i.ImportForced,
&i.ImportAttempts,
&i.ImportUpdatedAt,
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
)
return i, err
}
const retryGrabImport = `-- name: RetryGrabImport :one
UPDATE grabs
SET import_state = '', import_reason = '', import_updated_at = now()
WHERE id = $1 AND ended_at IS NULL AND import_state = 'failed'
RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination
`
func (q *Queries) RetryGrabImport(ctx context.Context, id pgtype.UUID) (Grab, error) {
row := q.db.QueryRow(ctx, retryGrabImport, id)
var i Grab
err := row.Scan(
&i.ID,
&i.WantID,
&i.InfoHash,
&i.Name,
&i.Key,
&i.Indexer,
&i.Source,
&i.Client,
&i.SavePath,
&i.Quality,
&i.Origin,
&i.Forced,
&i.GrabbedBy,
&i.GrabbedAt,
&i.EndedAt,
&i.EndReason,
&i.EndDetail,
&i.Progress,
&i.ImportState,
&i.ImportReason,
&i.ImportDetail,
&i.ImportForced,
&i.ImportAttempts,
&i.ImportUpdatedAt,
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
)
return i, err
}
const searchWantAgain = `-- name: SearchWantAgain :exec
UPDATE wants SET searched_at = NULL, search_outcome = '' WHERE id = $1
`
// Refused: search again on the next pass.
func (q *Queries) SearchWantAgain(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, searchWantAgain, id)
return err
}
const setGrabProgress = `-- name: SetGrabProgress :exec
UPDATE grabs
SET progress = $1,
import_state = $2,
import_reason = $3,
import_updated_at = now()
WHERE id = $4 AND ended_at IS NULL
AND import_state IN ('', 'downloading', 'waiting')
`
type SetGrabProgressParams struct {
Progress float32
ImportState string
ImportReason string
ID pgtype.UUID
}
// What the download client says of a download not yet finished, or finished
// and waiting for what the import needs. Leaves a claimed, decided or failed
// import alone.
func (q *Queries) SetGrabProgress(ctx context.Context, arg SetGrabProgressParams) error {
_, err := q.db.Exec(ctx, setGrabProgress,
arg.Progress,
arg.ImportState,
arg.ImportReason,
arg.ID,
)
return err
}
+28 -17
View File
@@ -225,6 +225,7 @@ type AcquisitionSetting struct {
LastPassAt pgtype.Timestamptz
LastPass []byte
UpdatedAt pgtype.Timestamptz
ImportRoot string
}
type Album struct {
@@ -431,23 +432,33 @@ type GeneralLikesArtist struct {
}
type Grab struct {
ID pgtype.UUID
WantID pgtype.UUID
InfoHash string
Name string
Key string
Indexer string
Source string
Client string
SavePath string
Quality string
Origin string
Forced bool
GrabbedBy pgtype.UUID
GrabbedAt pgtype.Timestamptz
EndedAt pgtype.Timestamptz
EndReason string
EndDetail string
ID pgtype.UUID
WantID pgtype.UUID
InfoHash string
Name string
Key string
Indexer string
Source string
Client string
SavePath string
Quality string
Origin string
Forced bool
GrabbedBy pgtype.UUID
GrabbedAt pgtype.Timestamptz
EndedAt pgtype.Timestamptz
EndReason string
EndDetail string
Progress float32
ImportState string
ImportReason string
ImportDetail []byte
ImportForced bool
ImportAttempts int32
ImportUpdatedAt pgtype.Timestamptz
ImportedAt pgtype.Timestamptz
ImportedFiles int32
Destination string
}
type LibraryChange struct {
+37 -6
View File
@@ -89,7 +89,7 @@ func (q *Queries) EndGrab(ctx context.Context, arg EndGrabParams) error {
const getAcquisitionSettings = `-- name: GetAcquisitionSettings :one
SELECT id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at FROM acquisition_settings WHERE id = true
SELECT id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root FROM acquisition_settings WHERE id = true
`
// Searching for wants and grabbing releases (M502 #5521).
@@ -104,12 +104,13 @@ func (q *Queries) GetAcquisitionSettings(ctx context.Context) (AcquisitionSettin
&i.LastPassAt,
&i.LastPass,
&i.UpdatedAt,
&i.ImportRoot,
)
return i, err
}
const getLiveGrab = `-- name: GetLiveGrab :one
SELECT id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail FROM grabs WHERE want_id = $1 AND ended_at IS NULL
SELECT id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination FROM grabs WHERE want_id = $1 AND ended_at IS NULL
`
func (q *Queries) GetLiveGrab(ctx context.Context, wantID pgtype.UUID) (Grab, error) {
@@ -133,6 +134,16 @@ func (q *Queries) GetLiveGrab(ctx context.Context, wantID pgtype.UUID) (Grab, er
&i.EndedAt,
&i.EndReason,
&i.EndDetail,
&i.Progress,
&i.ImportState,
&i.ImportReason,
&i.ImportDetail,
&i.ImportForced,
&i.ImportAttempts,
&i.ImportUpdatedAt,
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
)
return i, err
}
@@ -173,7 +184,7 @@ func (q *Queries) GetWantCandidate(ctx context.Context, arg GetWantCandidatePara
}
const getWantForSearch = `-- name: GetWantForSearch :one
SELECT w.id, w.release_group_mbid, w.artist_name, w.title, w.reason, w.fulfilled_at,
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason, w.fulfilled_at,
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date
@@ -185,6 +196,7 @@ SELECT w.id, w.release_group_mbid, w.artist_name, w.title, w.reason, w.fulfilled
type GetWantForSearchRow struct {
ID pgtype.UUID
ReleaseGroupMbid string
ArtistMbid string
ArtistName string
Title string
Reason string
@@ -200,6 +212,7 @@ func (q *Queries) GetWantForSearch(ctx context.Context, id pgtype.UUID) (GetWant
err := row.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.Reason,
@@ -217,7 +230,7 @@ INSERT INTO grabs (want_id, info_hash, name, key, indexer, source, client, save_
VALUES ($1, $2, $3, $4, $5,
$6, $7, $8, $9,
$10, $11, $12)
RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail
RETURNING id, want_id, info_hash, name, key, indexer, source, client, save_path, quality, origin, forced, grabbed_by, grabbed_at, ended_at, end_reason, end_detail, progress, import_state, import_reason, import_detail, import_forced, import_attempts, import_updated_at, imported_at, imported_files, destination
`
type InsertGrabParams struct {
@@ -269,6 +282,16 @@ func (q *Queries) InsertGrab(ctx context.Context, arg InsertGrabParams) (Grab, e
&i.EndedAt,
&i.EndReason,
&i.EndDetail,
&i.Progress,
&i.ImportState,
&i.ImportReason,
&i.ImportDetail,
&i.ImportForced,
&i.ImportAttempts,
&i.ImportUpdatedAt,
&i.ImportedAt,
&i.ImportedFiles,
&i.Destination,
)
return i, err
}
@@ -558,19 +581,26 @@ UPDATE acquisition_settings
SET auto_grab = $1,
search_interval_hours = $2,
allowed_qualities = $3,
import_root = $4,
updated_at = now()
WHERE id = true
RETURNING id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at
RETURNING id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root
`
type UpdateAcquisitionSettingsParams struct {
AutoGrab bool
SearchIntervalHours int32
AllowedQualities []string
ImportRoot string
}
func (q *Queries) UpdateAcquisitionSettings(ctx context.Context, arg UpdateAcquisitionSettingsParams) (AcquisitionSetting, error) {
row := q.db.QueryRow(ctx, updateAcquisitionSettings, arg.AutoGrab, arg.SearchIntervalHours, arg.AllowedQualities)
row := q.db.QueryRow(ctx, updateAcquisitionSettings,
arg.AutoGrab,
arg.SearchIntervalHours,
arg.AllowedQualities,
arg.ImportRoot,
)
var i AcquisitionSetting
err := row.Scan(
&i.ID,
@@ -580,6 +610,7 @@ func (q *Queries) UpdateAcquisitionSettings(ctx context.Context, arg UpdateAcqui
&i.LastPassAt,
&i.LastPass,
&i.UpdatedAt,
&i.ImportRoot,
)
return i, err
}
+62 -27
View File
@@ -24,7 +24,13 @@ const countWants = `-- name: CountWants :one
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
(SELECT COUNT(*) FROM follows)::bigint AS following
(SELECT COUNT(*) FROM follows)::bigint AS following,
-- The Wanted view's other tabs (#5522): downloads running, those
-- waiting on a person, and releases set aside.
(SELECT COUNT(*) FROM grabs WHERE ended_at IS NULL)::bigint AS activity,
(SELECT COUNT(*) FROM grabs
WHERE ended_at IS NULL AND import_state IN ('review', 'failed'))::bigint AS attention,
(SELECT COUNT(*) FROM release_blocks)::bigint AS blocked
FROM wants
`
@@ -33,6 +39,9 @@ type CountWantsRow struct {
Missing int64
Held int64
Following int64
Activity int64
Attention int64
Blocked int64
}
func (q *Queries) CountWants(ctx context.Context) (CountWantsRow, error) {
@@ -43,6 +52,9 @@ func (q *Queries) CountWants(ctx context.Context) (CountWantsRow, error) {
&i.Missing,
&i.Held,
&i.Following,
&i.Activity,
&i.Attention,
&i.Blocked,
)
return i, err
}
@@ -520,11 +532,18 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id,
-- The search schedule's last look (#5521), and the download running.
w.searched_at, w.search_outcome, w.search_considered, w.best_release, w.best_verdict, w.search_error,
gr.name AS grab_name, gr.grabbed_at, gr.origin AS grab_origin, gr.quality AS grab_quality
-- Its latest download, running or ended, and what the import made of
-- it (#5522).
gr.id AS grab_id, gr.name AS grab_name, gr.grabbed_at, gr.origin AS grab_origin,
gr.quality AS grab_quality, gr.ended_at AS grab_ended_at, gr.end_reason AS grab_end_reason,
gr.progress AS grab_progress, gr.import_state AS grab_import_state,
gr.import_reason AS grab_import_reason, gr.imported_at AS grab_imported_at,
gr.imported_files AS grab_imported_files
FROM wants w
LEFT JOIN users u ON u.id = w.created_by
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
LEFT JOIN grabs gr ON gr.want_id = w.id AND gr.ended_at IS NULL
LEFT JOIN grabs gr ON gr.id = (SELECT x.id FROM grabs x WHERE x.want_id = w.id
ORDER BY x.ended_at IS NULL DESC, x.grabbed_at DESC LIMIT 1)
WHERE CASE $1::text
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
@@ -541,30 +560,38 @@ type ListWantsParams struct {
}
type ListWantsRow struct {
ID pgtype.UUID
ReleaseGroupMbid string
ArtistMbid string
ArtistName string
Title string
Reason string
AlbumID pgtype.UUID
WantedAt pgtype.Timestamptz
FulfilledAt pgtype.Timestamptz
PrimaryType string
SecondaryTypes []string
FirstReleaseDate string
RequestedBy *string
LibraryArtistID pgtype.UUID
SearchedAt pgtype.Timestamptz
SearchOutcome string
SearchConsidered int32
BestRelease string
BestVerdict string
SearchError string
GrabName *string
GrabbedAt pgtype.Timestamptz
GrabOrigin *string
GrabQuality *string
ID pgtype.UUID
ReleaseGroupMbid string
ArtistMbid string
ArtistName string
Title string
Reason string
AlbumID pgtype.UUID
WantedAt pgtype.Timestamptz
FulfilledAt pgtype.Timestamptz
PrimaryType string
SecondaryTypes []string
FirstReleaseDate string
RequestedBy *string
LibraryArtistID pgtype.UUID
SearchedAt pgtype.Timestamptz
SearchOutcome string
SearchConsidered int32
BestRelease string
BestVerdict string
SearchError string
GrabID pgtype.UUID
GrabName *string
GrabbedAt pgtype.Timestamptz
GrabOrigin *string
GrabQuality *string
GrabEndedAt pgtype.Timestamptz
GrabEndReason *string
GrabProgress *float32
GrabImportState *string
GrabImportReason *string
GrabImportedAt pgtype.Timestamptz
GrabImportedFiles *int32
}
// The Wanted page: open wants (all but missing ones, or only missing ones),
@@ -599,10 +626,18 @@ func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWan
&i.BestRelease,
&i.BestVerdict,
&i.SearchError,
&i.GrabID,
&i.GrabName,
&i.GrabbedAt,
&i.GrabOrigin,
&i.GrabQuality,
&i.GrabEndedAt,
&i.GrabEndReason,
&i.GrabProgress,
&i.GrabImportState,
&i.GrabImportReason,
&i.GrabImportedAt,
&i.GrabImportedFiles,
); err != nil {
return nil, err
}
@@ -0,0 +1,24 @@
ALTER TABLE acquisition_settings DROP COLUMN import_root;
DELETE FROM release_blocks WHERE reason = 'import';
ALTER TABLE release_blocks DROP CONSTRAINT release_blocks_reason_check;
ALTER TABLE release_blocks ADD CONSTRAINT release_blocks_reason_check
CHECK (reason IN ('operator', 'files'));
DROP INDEX grabs_live_idx;
UPDATE grabs SET end_reason = 'superseded' WHERE end_reason IN ('imported', 'refused', 'held');
ALTER TABLE grabs DROP CONSTRAINT grabs_end_reason_check;
ALTER TABLE grabs ADD CONSTRAINT grabs_end_reason_check CHECK (end_reason IN ('', 'superseded'));
ALTER TABLE grabs
DROP COLUMN progress,
DROP COLUMN import_state,
DROP COLUMN import_reason,
DROP COLUMN import_detail,
DROP COLUMN import_forced,
DROP COLUMN import_attempts,
DROP COLUMN import_updated_at,
DROP COLUMN imported_at,
DROP COLUMN imported_files,
DROP COLUMN destination;
+37
View File
@@ -0,0 +1,37 @@
-- Import with verification (M502 #5522): a finished download is checked
-- against the album it was grabbed for before anything reaches the library.
--
-- grabs (columns) how far the download has got, and what the import
-- made of it: the verdict, its reason and evidence,
-- where the files went.
-- grabs.end_reason a grab also ends by being imported, refused, or
-- found to be an album already held.
-- release_blocks.reason import: the files were not the album.
-- acquisition_settings which library folder new music is filed into
-- ('' = the first scan path).
ALTER TABLE grabs
ADD COLUMN progress real NOT NULL DEFAULT 0,
ADD COLUMN import_state text NOT NULL DEFAULT ''
CONSTRAINT grabs_import_state_check CHECK (import_state IN
('', 'downloading', 'waiting', 'importing', 'imported', 'refused', 'review', 'held', 'failed')),
ADD COLUMN import_reason text NOT NULL DEFAULT '',
ADD COLUMN import_detail jsonb NOT NULL DEFAULT '{}',
-- A person said "import anyway" to a download the check held for them.
ADD COLUMN import_forced boolean NOT NULL DEFAULT false,
ADD COLUMN import_attempts integer NOT NULL DEFAULT 0,
ADD COLUMN import_updated_at timestamptz,
ADD COLUMN imported_at timestamptz,
ADD COLUMN imported_files integer NOT NULL DEFAULT 0,
ADD COLUMN destination text NOT NULL DEFAULT '';
ALTER TABLE grabs DROP CONSTRAINT grabs_end_reason_check;
ALTER TABLE grabs ADD CONSTRAINT grabs_end_reason_check
CHECK (end_reason IN ('', 'superseded', 'imported', 'refused', 'held'));
CREATE INDEX grabs_live_idx ON grabs (grabbed_at) WHERE ended_at IS NULL;
ALTER TABLE release_blocks DROP CONSTRAINT release_blocks_reason_check;
ALTER TABLE release_blocks ADD CONSTRAINT release_blocks_reason_check
CHECK (reason IN ('operator', 'files', 'import'));
ALTER TABLE acquisition_settings ADD COLUMN import_root text NOT NULL DEFAULT '';
+130
View File
@@ -0,0 +1,130 @@
-- Import with verification (M502 #5522).
-- name: ListLiveGrabs :many
-- Every download still running or waiting on its import, oldest first.
SELECT g.*, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title AS want_title,
w.reason AS want_reason, w.album_id AS want_album_id
FROM grabs g
LEFT JOIN wants w ON w.id = g.want_id
WHERE g.ended_at IS NULL
ORDER BY g.grabbed_at, g.id;
-- name: GetGrab :one
SELECT * FROM grabs WHERE id = $1;
-- name: SetGrabProgress :exec
-- What the download client says of a download not yet finished, or finished
-- and waiting for what the import needs. Leaves a claimed, decided or failed
-- import alone.
UPDATE grabs
SET progress = sqlc.arg(progress),
import_state = sqlc.arg(import_state),
import_reason = sqlc.arg(import_reason),
import_updated_at = now()
WHERE id = sqlc.arg(id) AND ended_at IS NULL
AND import_state IN ('', 'downloading', 'waiting');
-- name: ClaimGrabImport :one
-- Takes a finished download for import. The update is the lock: a second
-- pass finds nothing to claim.
UPDATE grabs
SET import_state = 'importing', import_attempts = import_attempts + 1,
import_updated_at = now(), progress = 1
WHERE id = $1 AND ended_at IS NULL AND import_state IN ('', 'downloading', 'waiting')
RETURNING *;
-- name: ReclaimStaleImports :execrows
-- An import cut short (a restart mid-copy) is tried again.
UPDATE grabs
SET import_state = 'waiting',
import_reason = 'The last import was cut short, so it is being tried again.'
WHERE ended_at IS NULL AND import_state = 'importing'
AND import_updated_at < now() - make_interval(mins => sqlc.arg(minutes)::int);
-- name: RecordImport :one
-- The import's outcome. An end reason ends the grab: imported, refused or
-- held. Without one the grab stays live: review and failed wait for a person.
UPDATE grabs
SET import_state = sqlc.arg(import_state),
import_reason = sqlc.arg(import_reason),
import_detail = sqlc.arg(import_detail),
import_updated_at = now(),
import_forced = false,
imported_at = CASE WHEN sqlc.arg(import_state)::text = 'imported' THEN now() ELSE imported_at END,
imported_files = sqlc.arg(imported_files),
destination = sqlc.arg(destination),
ended_at = CASE WHEN sqlc.arg(end_reason)::text <> '' THEN now() ELSE ended_at END,
end_reason = sqlc.arg(end_reason),
end_detail = CASE WHEN sqlc.arg(end_reason)::text <> '' THEN sqlc.arg(import_reason)::text ELSE end_detail END
WHERE id = sqlc.arg(id)
RETURNING *;
-- name: ForceGrabImport :one
-- "Import anyway": the next pass imports the download whatever the check said.
UPDATE grabs
SET import_state = '', import_forced = true, import_reason = '', import_updated_at = now()
WHERE id = $1 AND ended_at IS NULL AND import_state IN ('review', 'failed')
RETURNING *;
-- name: RetryGrabImport :one
UPDATE grabs
SET import_state = '', import_reason = '', import_updated_at = now()
WHERE id = $1 AND ended_at IS NULL AND import_state = 'failed'
RETURNING *;
-- name: RehomeGrab :one
-- A download that turned out to be another wanted album is that want's grab,
-- when the want has none running.
UPDATE grabs g
SET want_id = sqlc.arg(want_id)
WHERE g.id = sqlc.arg(id) AND g.ended_at IS NULL
AND NOT EXISTS (SELECT 1 FROM grabs o WHERE o.want_id = sqlc.arg(want_id) AND o.ended_at IS NULL)
RETURNING *;
-- name: OpenWantsForGroups :many
-- Open wants for these release groups with no download running.
SELECT w.* FROM wants w
WHERE w.fulfilled_at IS NULL AND w.release_group_mbid = ANY(sqlc.arg(groups)::text[])
AND NOT EXISTS (SELECT 1 FROM grabs g WHERE g.want_id = w.id AND g.ended_at IS NULL);
-- name: MarkWantSearched :exec
-- Imported: nothing to search for until the wanted pass settles the want.
UPDATE wants SET searched_at = now() WHERE id = $1;
-- name: SearchWantAgain :exec
-- Refused: search again on the next pass.
UPDATE wants SET searched_at = NULL, search_outcome = '' WHERE id = $1;
-- name: HeldRecordings :many
-- Which of these recordings the library holds a file for.
SELECT DISTINCT mbid::text AS mbid FROM tracks
WHERE mbid = ANY(sqlc.arg(recordings)::text[]) AND missing_since IS NULL;
-- name: HeldPositions :many
-- The disc and track positions the library holds for an album of this group.
SELECT DISTINCT COALESCE(t.disc_number, 1)::int AS disc, t.track_number::int AS track
FROM tracks t
JOIN albums a ON a.id = t.album_id
WHERE a.release_group_mbid = sqlc.arg(release_group_mbid)
AND t.missing_since IS NULL AND t.track_number IS NOT NULL;
-- name: ListGrabs :many
-- The Wanted view's Activity tab: downloads waiting on a person first, then
-- the rest still running, then the ended ones newest first.
SELECT g.id, g.want_id, g.name, g.indexer, g.client, g.quality, g.origin, g.forced,
g.grabbed_at, g.ended_at, g.end_reason, g.end_detail, g.progress, g.import_state,
g.import_reason, g.import_detail, g.import_updated_at, g.imported_at, g.imported_files,
g.destination, w.title AS want_title, w.artist_name AS want_artist
FROM grabs g
LEFT JOIN wants w ON w.id = g.want_id
ORDER BY (g.ended_at IS NULL AND g.import_state IN ('review', 'failed')) DESC,
g.ended_at IS NULL DESC, g.grabbed_at DESC, g.id
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: ListReleaseBlocksPage :many
SELECT b.info_hash, b.name, b.reason, b.detail, b.blocked_at, w.title AS want_title,
w.artist_name AS want_artist
FROM release_blocks b
LEFT JOIN wants w ON w.id = b.want_id
ORDER BY b.blocked_at DESC, b.info_hash
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
+2 -1
View File
@@ -8,6 +8,7 @@ UPDATE acquisition_settings
SET auto_grab = sqlc.arg(auto_grab),
search_interval_hours = sqlc.arg(search_interval_hours),
allowed_qualities = sqlc.arg(allowed_qualities),
import_root = sqlc.arg(import_root),
updated_at = now()
WHERE id = true
RETURNING *;
@@ -34,7 +35,7 @@ SELECT w.id, w.release_group_mbid, w.artist_name, w.title, w.reason,
LIMIT sqlc.arg(max_rows);
-- name: GetWantForSearch :one
SELECT w.id, w.release_group_mbid, w.artist_name, w.title, w.reason, w.fulfilled_at,
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason, w.fulfilled_at,
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date
+16 -3
View File
@@ -189,11 +189,18 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id,
-- The search schedule's last look (#5521), and the download running.
w.searched_at, w.search_outcome, w.search_considered, w.best_release, w.best_verdict, w.search_error,
gr.name AS grab_name, gr.grabbed_at, gr.origin AS grab_origin, gr.quality AS grab_quality
-- Its latest download, running or ended, and what the import made of
-- it (#5522).
gr.id AS grab_id, gr.name AS grab_name, gr.grabbed_at, gr.origin AS grab_origin,
gr.quality AS grab_quality, gr.ended_at AS grab_ended_at, gr.end_reason AS grab_end_reason,
gr.progress AS grab_progress, gr.import_state AS grab_import_state,
gr.import_reason AS grab_import_reason, gr.imported_at AS grab_imported_at,
gr.imported_files AS grab_imported_files
FROM wants w
LEFT JOIN users u ON u.id = w.created_by
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
LEFT JOIN grabs gr ON gr.want_id = w.id AND gr.ended_at IS NULL
LEFT JOIN grabs gr ON gr.id = (SELECT x.id FROM grabs x WHERE x.want_id = w.id
ORDER BY x.ended_at IS NULL DESC, x.grabbed_at DESC LIMIT 1)
WHERE CASE sqlc.arg(state)::text
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
@@ -206,5 +213,11 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
(SELECT COUNT(*) FROM follows)::bigint AS following
(SELECT COUNT(*) FROM follows)::bigint AS following,
-- The Wanted view's other tabs (#5522): downloads running, those
-- waiting on a person, and releases set aside.
(SELECT COUNT(*) FROM grabs WHERE ended_at IS NULL)::bigint AS activity,
(SELECT COUNT(*) FROM grabs
WHERE ended_at IS NULL AND import_state IN ('review', 'failed'))::bigint AS attention,
(SELECT COUNT(*) FROM release_blocks)::bigint AS blocked
FROM wants;
+1 -1
View File
@@ -186,7 +186,7 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
// Search and grab settings (M502 #5521), a singleton reset the same way.
if _, err := pool.Exec(ctx, `
UPDATE acquisition_settings
SET auto_grab = DEFAULT, search_interval_hours = DEFAULT, allowed_qualities = DEFAULT,
SET auto_grab = DEFAULT, search_interval_hours = DEFAULT, allowed_qualities = DEFAULT, import_root = DEFAULT,
last_pass_at = NULL, last_pass = DEFAULT, updated_at = DEFAULT`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset acquisition settings: %v", err)
+608
View File
@@ -0,0 +1,608 @@
package importer
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"slices"
"strconv"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
)
// Import states, as grabs.import_state holds them.
const (
StateDownloading = "downloading"
StateWaiting = "waiting"
StateImporting = "importing"
StateImported = "imported"
StateRefused = "refused"
StateReview = "review"
StateHeld = "held"
StateFailed = "failed"
)
const (
// interval: a finished download is noticed within a minute. One
// Torrents call per download client, so the client barely notices.
interval = time.Minute
// passTimeout bounds a pass: fingerprinting a long album against
// AcoustID's pace takes a minute or two, so half an hour is a hang.
passTimeout = 30 * time.Minute
// staleMinutes: an import still "importing" after this was cut short
// (a restart mid-copy) and is tried again.
staleMinutes = 30
)
// Scanner reads new files into the library.
type Scanner interface {
ScanFiles(ctx context.Context, paths []string) ([]pgtype.UUID, error)
}
// Catalogue lists a release group's editions.
type Catalogue interface {
Releases(ctx context.Context, groupMBID string) (catalogue.Editions, error)
}
// Importer watches every live grab's download and imports it once finished.
type Importer struct {
pool *pgxpool.Pool
Scanner Scanner
Catalogue Catalogue
Printer Printer
// Roots are the library's folders (the scan paths). New music is filed
// under the one the settings name, or the first.
Roots []string
// ClientFor builds the download client a grab went to. Defaults to the
// acquisition_services row of that code.
ClientFor func(ctx context.Context, code string) (downloader.Driver, error)
logger *slog.Logger
wake chan struct{}
mu sync.Mutex // one pass at a time
}
// New returns an Importer. printer may be nil: the check then rests on
// lengths and tags.
func New(pool *pgxpool.Pool, scanner Scanner, cat Catalogue, printer Printer, roots []string, logger *slog.Logger) *Importer {
if logger == nil {
logger = slog.Default()
}
im := &Importer{pool: pool, Scanner: scanner, Catalogue: cat, Printer: printer, Roots: roots,
logger: logger, wake: make(chan struct{}, 1)}
im.ClientFor = im.clientFor
return im
}
func (im *Importer) clientFor(ctx context.Context, code string) (downloader.Driver, error) {
row, err := dbq.New(im.pool).GetAcquisitionService(ctx, code)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("the download client %q is no longer set up", code)
}
return nil, err
}
return acquire.DownloadClient(row)
}
// Wake asks for a pass now: after a grab, or a person's decision.
func (im *Importer) Wake() {
select {
case im.wake <- struct{}{}:
default:
}
}
// Run passes every minute, and when woken, until ctx ends.
func (im *Importer) Run(ctx context.Context) {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
case <-im.wake:
}
rep := im.Pass(ctx)
if rep.Imported+rep.Refused+rep.Review+rep.Held+rep.Failed > 0 || rep.Error != "" {
im.logger.Info("import: pass", "imported", rep.Imported, "refused", rep.Refused,
"review", rep.Review, "held", rep.Held, "failed", rep.Failed,
"downloading", rep.Downloading, "error", rep.Error)
}
}
}
// Report is one pass.
type Report struct {
Downloading, Imported, Refused, Review, Held, Failed, Waiting int
Error string
}
// Pass looks at every live grab once.
func (im *Importer) Pass(ctx context.Context) Report {
im.mu.Lock()
defer im.mu.Unlock()
ctx, cancel := context.WithTimeout(ctx, passTimeout)
defer cancel()
var rep Report
q := dbq.New(im.pool)
if _, err := q.ReclaimStaleImports(ctx, staleMinutes); err != nil {
rep.Error = err.Error()
return rep
}
grabs, err := q.ListLiveGrabs(ctx)
if err != nil {
rep.Error = err.Error()
return rep
}
byClient := map[string][]dbq.ListLiveGrabsRow{}
var order []string
for _, g := range grabs {
if _, ok := byClient[g.Client]; !ok {
order = append(order, g.Client)
}
byClient[g.Client] = append(byClient[g.Client], g)
}
for _, code := range order {
im.passClient(ctx, q, code, byClient[code], &rep)
}
return rep
}
func (im *Importer) passClient(ctx context.Context, q *dbq.Queries, code string, gs []dbq.ListLiveGrabsRow, rep *Report) {
wait := func(g dbq.ListLiveGrabsRow, why string) {
rep.Waiting++
_ = q.SetGrabProgress(ctx, dbq.SetGrabProgressParams{
ID: g.ID, Progress: g.Progress, ImportState: StateWaiting, ImportReason: why})
}
d, err := im.ClientFor(ctx, code)
if err != nil {
for _, g := range gs {
wait(g, fmt.Sprintf("Can't reach its download client: %v.", err))
}
return
}
hashes := make([]string, 0, len(gs))
for _, g := range gs {
hashes = append(hashes, g.InfoHash)
}
ts, err := d.Torrents(ctx, hashes...)
if err != nil {
for _, g := range gs {
wait(g, fmt.Sprintf("Can't reach its download client %s: %s", code, indexer.FirstLine(err.Error())))
}
return
}
byHash := map[string]downloader.Torrent{}
for _, t := range ts {
byHash[indexer.NormalizeHash(t.Hash)] = t
}
for _, g := range gs {
if ctx.Err() != nil {
return
}
switch g.ImportState {
case StateReview, StateFailed, StateImporting:
continue
}
t, ok := byHash[indexer.NormalizeHash(g.InfoHash)]
if !ok {
continue // gone from the client: the download lifecycle's (#5523)
}
if !g.WantID.Valid {
wait(g, "Its album is no longer wanted.")
continue
}
if !finished(ctx, d, t) {
rep.Downloading++
_ = q.SetGrabProgress(ctx, dbq.SetGrabProgressParams{
ID: g.ID, Progress: float32(min(t.Progress, 0.999)), ImportState: StateDownloading})
continue
}
claimed, err := q.ClaimGrabImport(ctx, g.ID)
if err != nil {
continue // claimed by a pass racing this one, or ended
}
switch out := im.importOne(ctx, q, claimed, d, t); out {
case StateImported:
rep.Imported++
case StateRefused:
rep.Refused++
case StateReview:
rep.Review++
case StateHeld:
rep.Held++
case StateFailed:
rep.Failed++
case StateWaiting:
rep.Waiting++
}
}
}
// upStates are qBittorrent's states for a torrent that has every byte and
// is seeding, paused or queued to seed. Exchange's states arrive in the same
// vocabulary.
var upStates = map[string]bool{
"uploading": true, "stalledUP": true, "pausedUP": true, "stoppedUP": true,
"queuedUP": true, "forcedUP": true, "checkingUP": true,
}
// finished: whole, in a seeding state, and no wanted file short of bytes.
func finished(ctx context.Context, d downloader.Driver, t downloader.Torrent) bool {
if !t.Done() || !upStates[t.State] {
return false
}
if fp, ok := d.(downloader.FileProgress); ok {
if states, err := fp.FileStates(ctx, t.Hash); err == nil {
for _, f := range states {
if f.Unfinished() {
return false
}
}
}
}
return true
}
// Detail is what grabs.import_detail keeps: the verdict and what it rested
// on, the edition chosen, and what happened to the files.
type Detail struct {
Verdict Verdict `json:"verdict"`
Edition string `json:"edition,omitempty"`
EditionMBID string `json:"edition_mbid,omitempty"`
Files []string `json:"files,omitempty"`
Placed int `json:"placed,omitempty"`
Skipped int `json:"skipped,omitempty"`
Untagged int `json:"untagged,omitempty"`
// RehomedFrom: the album it was grabbed for, when it turned out to be
// another wanted album.
RehomedFrom string `json:"rehomed_from,omitempty"`
}
// importOne checks and files one finished download and records the outcome.
func (im *Importer) importOne(ctx context.Context, q *dbq.Queries, g dbq.Grab, d downloader.Driver, t downloader.Torrent) string {
record := func(state, reason, end string, det Detail, files int, dest string) string {
body, _ := json.Marshal(det)
// The outcome is written even when the pass's context is ending:
// a claimed grab left "importing" waits half an hour to be retried.
wctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
if _, err := q.RecordImport(wctx, dbq.RecordImportParams{
ID: g.ID, ImportState: state, ImportReason: reason, ImportDetail: body,
ImportedFiles: int32(files), Destination: dest, EndReason: end,
}); err != nil {
im.logger.Warn("import: record outcome", "grab", g.Name, "state", state, "err", err)
}
im.logger.Info("import: "+state, "grab", g.Name, "reason", reason)
return state
}
w, err := im.want(ctx, q, g.WantID)
if err != nil {
return record(StateWaiting, "Its album could not be read: "+err.Error(), "", Detail{}, 0, "")
}
editions, err := im.editions(ctx, w.GroupMBID)
if err != nil {
return record(StateWaiting, "MusicBrainz could not be reached to check it against the album; trying again shortly.",
"", Detail{}, 0, "")
}
src := t.ContentPath
if src == "" {
src = filepath.Join(g.SavePath, t.Name)
}
files, printed, err := inspect(ctx, src, im.Printer)
if err != nil {
return record(StateFailed, fmt.Sprintf("Couldn't read the download at %s: %v", src, err), "", Detail{}, 0, "")
}
v := Verify(w, editions, files, printed, g.ImportForced)
det := Detail{Verdict: v}
for _, f := range files {
det.Files = append(det.Files, f.Rel)
}
if v.Edition >= 0 {
det.Edition, det.EditionMBID = describe(editions[v.Edition]), editions[v.Edition].MBID
}
switch v.Outcome {
case Review:
return record(StateReview, v.Reason, "", det, 0, "")
case Refuse:
if v.Code == CodeWrongAlbum {
if out, ok := im.rehome(ctx, q, g, d, w, files, printed, record); ok {
return out
}
}
im.refuse(ctx, q, g, d, w, v.Reason, pgtype.UUID{})
return record(StateRefused, v.Reason, StateRefused, det, 0, "")
}
return im.place(ctx, q, g, d, w, editions, files, v, det, record)
}
type recordFunc func(state, reason, end string, det Detail, files int, dest string) string
// place files an accepted download and records it imported, or held when
// the library already had every track.
func (im *Importer) place(ctx context.Context, q *dbq.Queries, g dbq.Grab, d downloader.Driver, w Want,
editions []Edition, files []File, v Verdict, det Detail, record recordFunc) string {
root, err := im.root(ctx, q)
if err != nil {
return record(StateFailed, "Not imported: "+err.Error()+".", "", det, 0, "")
}
var e *Edition
if v.Edition >= 0 {
e = &editions[v.Edition]
}
plan := Plan(root, w, e, files, v.Pairs)
heldRec, heldPos, err := im.held(ctx, q, w, plan)
if err != nil {
return record(StateWaiting, "Couldn't read what the library holds: "+err.Error(), "", det, 0, "")
}
var placed []string
for _, pl := range plan {
if (pl.Recording != "" && heldRec[pl.Recording]) || heldPos[[2]int{pl.Disc, pl.Track}] {
det.Skipped++
continue
}
tags := Tags{Title: pl.Title, Artist: w.Artist, Album: w.Title, AlbumArtist: w.Artist, Year: w.Year,
Disc: pl.Disc, Discs: pl.Discs, Track: pl.Track, Tracks: pl.Tracks,
GroupMBID: w.GroupMBID, RecordingMBID: pl.Recording, ArtistMBID: w.ArtistMBID}
if e != nil {
tags.ReleaseMBID = e.MBID
}
tagged, err := Place(ctx, files[pl.File].Path, pl.Dest, tags)
if errors.Is(err, ErrOccupied) {
det.Skipped++
continue
}
if err != nil {
for _, p := range placed {
_ = os.Remove(p)
}
return record(StateFailed, fmt.Sprintf("Copying into the library failed, and nothing was left behind: %v", err),
"", det, 0, "")
}
placed = append(placed, pl.Dest)
if !tagged {
det.Untagged++
}
}
det.Placed = len(placed)
if len(placed) == 0 {
im.removeFromClient(ctx, d, g)
return record(StateHeld, fmt.Sprintf("The library already holds every track of %s, so nothing was imported.", w.Title),
StateHeld, det, 0, "")
}
if _, err := im.Scanner.ScanFiles(ctx, placed); err != nil {
// The watcher and the next scan find the files anyway.
im.logger.Warn("import: scan the new files", "grab", g.Name, "err", err)
}
if err := q.MarkWantSearched(ctx, g.WantID); err != nil {
im.logger.Warn("import: mark the want searched", "grab", g.Name, "err", err)
}
dest := filepath.Dir(placed[0])
if e != nil && e.Media() > 1 {
dest = filepath.Dir(dest)
}
reason := fmt.Sprintf("Imported %s into %s. %s", tracks(len(placed)), dest, v.Reason)
if det.Skipped > 0 {
reason += fmt.Sprintf(" %s already in the library left as they were.", tracks(det.Skipped))
}
if det.Untagged > 0 {
reason += fmt.Sprintf(" %s copied with their own tags: ffmpeg could not rewrite them.", tracks(det.Untagged))
}
return record(StateImported, reason, StateImported, det, len(placed), dest)
}
// rehome imports a download that turned out to be another wanted album,
// named by its tags, for that album, when its own grab is not running.
func (im *Importer) rehome(ctx context.Context, q *dbq.Queries, g dbq.Grab, d downloader.Driver,
from Want, files []File, printed bool, record recordFunc) (string, bool) {
group := majority(files, func(f File) string { return f.GroupMBID })
if group == "" {
return "", false
}
others, err := q.OpenWantsForGroups(ctx, []string{group})
if err != nil || len(others) == 0 {
return "", false
}
other := others[0]
w, err := im.want(ctx, q, other.ID)
if err != nil {
return "", false
}
editions, err := im.editions(ctx, w.GroupMBID)
if err != nil {
return "", false
}
v := Verify(w, editions, files, printed, false)
if v.Outcome != Accept {
return "", false
}
moved, err := q.RehomeGrab(ctx, dbq.RehomeGrabParams{ID: g.ID, WantID: other.ID})
if err != nil {
return "", false
}
if err := q.SearchWantAgain(ctx, g.WantID); err != nil {
im.logger.Warn("import: search the first album again", "want", from.Title, "err", err)
}
det := Detail{Verdict: v, RehomedFrom: from.Title}
for _, f := range files {
det.Files = append(det.Files, f.Rel)
}
if v.Edition >= 0 {
det.Edition, det.EditionMBID = describe(editions[v.Edition]), editions[v.Edition].MBID
}
v.Reason = fmt.Sprintf("Grabbed for %s, it is %s, which is wanted too. %s", from.Title, w.Title, v.Reason)
det.Verdict = v
return im.place(ctx, q, moved, d, w, editions, files, v, det, record), true
}
// refuse blocks the release, asks for the album to be searched again and
// removes the download with its files: it is not the album.
func (im *Importer) refuse(ctx context.Context, q *dbq.Queries, g dbq.Grab, d downloader.Driver, w Want, why string, by pgtype.UUID) {
if err := acquire.NewMemory(im.pool).Block(ctx, dbq.BlockReleaseParams{
InfoHash: g.InfoHash, Name: g.Name, Reason: acquire.BlockImport, Detail: why, WantID: g.WantID, BlockedBy: by,
}); err != nil {
im.logger.Warn("import: block the release", "grab", g.Name, "err", err)
}
if err := q.SearchWantAgain(ctx, g.WantID); err != nil {
im.logger.Warn("import: search again", "want", w.Title, "err", err)
}
im.removeFromClient(ctx, d, g)
}
func (im *Importer) removeFromClient(ctx context.Context, d downloader.Driver, g dbq.Grab) {
if d == nil || g.InfoHash == "" {
return
}
if err := d.Delete(ctx, true, g.InfoHash); err != nil {
im.logger.Warn("import: remove the download", "grab", g.Name, "err", err)
}
}
// Refuse is a person's "refuse" on a download the check held for them, or
// one that failed: it is refused as the check would have.
func (im *Importer) Refuse(ctx context.Context, grabID, by pgtype.UUID) (dbq.Grab, error) {
q := dbq.New(im.pool)
g, err := q.GetGrab(ctx, grabID)
if err != nil {
return dbq.Grab{}, err
}
if g.EndedAt.Valid || (g.ImportState != StateReview && g.ImportState != StateFailed) {
return dbq.Grab{}, ErrNotDecidable
}
var w Want
if g.WantID.Valid {
w, _ = im.want(ctx, q, g.WantID)
}
d, err := im.ClientFor(ctx, g.Client)
if err != nil {
d = nil
}
why := "A person refused it: it is not the album."
im.refuse(ctx, q, g, d, w, why, by)
out, err := q.RecordImport(ctx, dbq.RecordImportParams{
ID: g.ID, ImportState: StateRefused, ImportReason: why, ImportDetail: g.ImportDetail,
EndReason: StateRefused,
})
im.Wake()
return out, err
}
// ErrNotDecidable: the grab is not waiting on a person.
var ErrNotDecidable = errors.New("importer: that download is not waiting on a decision")
func (im *Importer) want(ctx context.Context, q *dbq.Queries, id pgtype.UUID) (Want, error) {
row, err := q.GetWantForSearch(ctx, id)
if err != nil {
return Want{}, err
}
return Want{
GroupMBID: row.ReleaseGroupMbid, ArtistMBID: row.ArtistMbid, Artist: row.ArtistName, Title: row.Title,
Year: yearOf(row.FirstReleaseDate), PrimaryType: row.PrimaryType, SecondaryTypes: row.SecondaryTypes,
}, nil
}
func (im *Importer) editions(ctx context.Context, group string) ([]Edition, error) {
if im.Catalogue == nil {
return nil, errors.New("no catalogue")
}
eds, err := im.Catalogue.Releases(ctx, group)
if err != nil {
return nil, err
}
out := make([]Edition, 0, len(eds.Editions))
for _, e := range eds.Editions {
ed := Edition{MBID: e.Release.Mbid, Title: e.Release.Title, Status: e.Release.Status,
Date: e.Release.ReleaseDate, Country: e.Release.Country}
for _, t := range e.Tracks {
ed.Tracks = append(ed.Tracks, Track{Medium: int(t.MediumPosition), Format: t.MediumFormat,
Position: int(t.Position), Title: t.Title, RecordingMBID: t.RecordingMbid, LengthMs: int(t.LengthMs)})
}
slices.SortStableFunc(ed.Tracks, func(a, b Track) int {
if a.Medium != b.Medium {
return a.Medium - b.Medium
}
return a.Position - b.Position
})
out = append(out, ed)
}
return out, nil
}
// root is the library folder new music is filed into.
func (im *Importer) root(ctx context.Context, q *dbq.Queries) (string, error) {
if len(im.Roots) == 0 {
return "", errors.New("there is no library folder to file new music into (MINSTREL_LIBRARY_SCAN_PATHS)")
}
s, err := q.GetAcquisitionSettings(ctx)
if err == nil && s.ImportRoot != "" && slices.Contains(im.Roots, s.ImportRoot) {
return s.ImportRoot, nil
}
return im.Roots[0], nil
}
// held reads which of the planned tracks the library already has: by
// recording, and by disc and track of an album of the same group.
func (im *Importer) held(ctx context.Context, q *dbq.Queries, w Want, plan []Placement) (map[string]bool, map[[2]int]bool, error) {
var recs []string
for _, pl := range plan {
if pl.Recording != "" {
recs = append(recs, pl.Recording)
}
}
rec := map[string]bool{}
if len(recs) > 0 {
got, err := q.HeldRecordings(ctx, recs)
if err != nil {
return nil, nil, err
}
for _, r := range got {
rec[r] = true
}
}
pos := map[[2]int]bool{}
got, err := q.HeldPositions(ctx, &w.GroupMBID)
if err != nil {
return nil, nil, err
}
for _, p := range got {
pos[[2]int{int(p.Disc), int(p.Track)}] = true
}
return rec, pos, nil
}
func yearOf(date string) int {
if len(date) < 4 {
return 0
}
y, err := strconv.Atoi(date[:4])
if err != nil {
return 0
}
return y
}
func tracks(n int) string {
if n == 1 {
return "1 track"
}
return strconv.Itoa(n) + " tracks"
}
+268
View File
@@ -0,0 +1,268 @@
package importer
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/downloader"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
const hash = "0123456789abcdef0123456789abcdef01234567"
// fakeClient is a download client holding one finished torrent.
type fakeClient struct {
downloader.Driver
content string
deleted []string
}
func (c *fakeClient) Torrents(_ context.Context, _ ...string) ([]downloader.Torrent, error) {
return []downloader.Torrent{{Hash: hash, Name: "Daft Punk - RAM [FLAC]", Progress: 1, State: "uploading",
ContentPath: c.content}}, nil
}
func (c *fakeClient) Delete(_ context.Context, _ bool, hashes ...string) error {
c.deleted = append(c.deleted, hashes...)
return nil
}
// fakeCatalogue knows RAM as one three-track edition.
type fakeCatalogue struct{}
func (fakeCatalogue) Releases(_ context.Context, _ string) (catalogue.Editions, error) {
return catalogue.Editions{Editions: []catalogue.Edition{{
Release: dbq.MbRelease{Mbid: "rel-ram", Title: "Random Access Memories", Status: "Official", ReleaseDate: "2013-05-17"},
Tracks: []dbq.MbReleaseTrack{
{MediumPosition: 1, MediumFormat: "CD", Position: 1, Title: "Give Life Back to Music", RecordingMbid: "rec-01"},
{MediumPosition: 1, MediumFormat: "CD", Position: 2, Title: "The Game of Love", RecordingMbid: "rec-02"},
{MediumPosition: 1, MediumFormat: "CD", Position: 3, Title: "Giorgio by Moroder", RecordingMbid: "rec-03"},
},
}}}, nil
}
// fakePrinter names each file's recording by its file name: 01.flac is
// rec-01, unless wrong is set.
type fakePrinter struct{ wrong bool }
func (p fakePrinter) Recordings(_ context.Context, path string) ([]string, error) {
if p.wrong {
return []string{"rec-other"}, nil
}
return []string{"rec-" + strings.TrimSuffix(filepath.Base(path), ".flac")}, nil
}
type fakeScanner struct{ scanned []string }
func (s *fakeScanner) ScanFiles(_ context.Context, paths []string) ([]pgtype.UUID, error) {
s.scanned = append(s.scanned, paths...)
return nil, nil
}
type stack struct {
pool *pgxpool.Pool
q *dbq.Queries
im *Importer
client *fakeClient
scanner *fakeScanner
library string
want dbq.Want
grab dbq.Grab
}
// newStack wants RAM, has a finished download of it waiting in a fake
// client, and an importer filing into a temp library.
func newStack(t *testing.T, printer Printer) *stack {
t.Helper()
ctx := context.Background()
pool := newPool(t)
q := dbq.New(pool)
content := t.TempDir()
for _, n := range []string{"01", "02", "03"} {
if err := os.WriteFile(filepath.Join(content, n+".flac"), []byte("not really flac "+n), 0o644); err != nil {
t.Fatal(err)
}
}
w, err := wanted.WantWith(ctx, q, wanted.WantParams{GroupMBID: "rg-ram", ArtistMBID: "mb-dp", ArtistName: "Daft Punk",
Title: "Random Access Memories", PrimaryType: "Album", FirstReleaseDate: "2013-05-17", Reason: wanted.ReasonRequest})
if err != nil {
t.Fatal(err)
}
g, err := acquire.NewMemory(pool).Grabbed(ctx, dbq.InsertGrabParams{WantID: w.ID, InfoHash: hash,
Name: "Daft Punk - RAM [FLAC]", Key: hash, Client: "qbittorrent", SavePath: content, Origin: acquire.OriginSchedule},
pgtype.UUID{}, "")
if err != nil {
t.Fatal(err)
}
s := &stack{pool: pool, q: q, client: &fakeClient{content: content}, scanner: &fakeScanner{},
library: t.TempDir(), want: w, grab: g}
s.im = New(pool, s.scanner, fakeCatalogue{}, printer, []string{s.library}, slog.New(slog.NewTextHandler(io.Discard, nil)))
s.im.ClientFor = func(context.Context, string) (downloader.Driver, error) { return s.client, nil }
return s
}
func (s *stack) grabNow(t *testing.T) dbq.Grab {
t.Helper()
g, err := s.q.GetGrab(context.Background(), s.grab.ID)
if err != nil {
t.Fatal(err)
}
return g
}
func TestImport_AFinishedDownloadOfTheAlbumIsFiled_Integration(t *testing.T) {
s := newStack(t, fakePrinter{})
rep := s.im.Pass(context.Background())
if rep.Imported != 1 {
t.Fatalf("pass = %+v", rep)
}
g := s.grabNow(t)
if g.ImportState != StateImported || g.EndReason != StateImported || !g.EndedAt.Valid || g.ImportedFiles != 3 {
t.Fatalf("grab = %s %s ended=%v files=%d: %s", g.ImportState, g.EndReason, g.EndedAt.Valid, g.ImportedFiles, g.ImportReason)
}
dir := filepath.Join(s.library, "Daft Punk", "Random Access Memories (2013)")
if g.Destination != dir {
t.Errorf("destination %q", g.Destination)
}
got, err := os.ReadFile(filepath.Join(dir, "Daft Punk - Random Access Memories - 02 - The Game of Love.flac"))
if err != nil || string(got) != "not really flac 02" {
t.Errorf("filed copy: %q %v", got, err)
}
if _, err := os.Stat(filepath.Join(s.client.content, "02.flac")); err != nil {
t.Errorf("the download's own file went: %v", err)
}
if len(s.scanner.scanned) != 3 || len(s.client.deleted) != 0 {
t.Errorf("scanned %v, deleted %v", s.scanner.scanned, s.client.deleted)
}
w, _ := s.q.GetWantByReleaseGroup(context.Background(), "rg-ram")
if !w.SearchedAt.Valid {
t.Error("an imported want is due for search again")
}
if s.im.Pass(context.Background()).Imported != 0 {
t.Error("imported twice")
}
}
func TestImport_TheWrongAlbumIsRefusedBlockedAndSearchedAgain_Integration(t *testing.T) {
s := newStack(t, fakePrinter{wrong: true})
ctx := context.Background()
if err := s.q.MarkWantSearched(ctx, s.want.ID); err != nil {
t.Fatal(err)
}
if rep := s.im.Pass(ctx); rep.Refused != 1 {
t.Fatalf("pass = %+v", rep)
}
g := s.grabNow(t)
if g.ImportState != StateRefused || g.EndReason != StateRefused || !strings.Contains(g.ImportReason, "recordings") {
t.Fatalf("grab = %s %s: %s", g.ImportState, g.EndReason, g.ImportReason)
}
blocks, err := s.q.ListReleaseBlocks(ctx)
if err != nil || len(blocks) != 1 || blocks[0].InfoHash != hash || blocks[0].Reason != acquire.BlockImport {
t.Errorf("blocks = %+v %v", blocks, err)
}
if len(s.client.deleted) != 1 {
t.Errorf("the refused download was not removed: %v", s.client.deleted)
}
w, _ := s.q.GetWantByReleaseGroup(ctx, "rg-ram")
if w.SearchedAt.Valid {
t.Error("a refused want is not due for search")
}
entries, _ := os.ReadDir(s.library)
if len(entries) != 0 {
t.Errorf("the library got %d entries", len(entries))
}
}
func TestImport_ADoubtfulDownloadWaitsForAPersonThenImportsAnyway_Integration(t *testing.T) {
s := newStack(t, nil) // no AcoustID; untagged files: nothing but lengths
ctx := context.Background()
if rep := s.im.Pass(ctx); rep.Review != 1 {
t.Fatalf("pass = %+v", rep)
}
g := s.grabNow(t)
if g.ImportState != StateReview || g.EndedAt.Valid || !strings.Contains(g.ImportReason, "A person decides") {
t.Fatalf("grab = %s ended=%v: %s", g.ImportState, g.EndedAt.Valid, g.ImportReason)
}
if rep := s.im.Pass(ctx); rep.Review+rep.Imported != 0 {
t.Errorf("a held download was looked at again: %+v", rep)
}
if _, err := s.q.ForceGrabImport(ctx, g.ID); err != nil {
t.Fatal(err)
}
if rep := s.im.Pass(ctx); rep.Imported != 1 {
t.Fatalf("after import anyway: %+v", rep)
}
if g := s.grabNow(t); g.ImportState != StateImported || !strings.Contains(g.ImportReason, "anyway") {
t.Errorf("grab = %s: %s", g.ImportState, g.ImportReason)
}
}
func TestImport_APersonRefusesADoubtfulDownload_Integration(t *testing.T) {
s := newStack(t, nil)
ctx := context.Background()
s.im.Pass(ctx)
g, err := s.im.Refuse(ctx, s.grab.ID, pgtype.UUID{})
if err != nil {
t.Fatal(err)
}
if g.ImportState != StateRefused || !g.EndedAt.Valid || len(s.client.deleted) != 1 {
t.Errorf("grab = %s ended=%v deleted=%v", g.ImportState, g.EndedAt.Valid, s.client.deleted)
}
if _, err := s.im.Refuse(ctx, s.grab.ID, pgtype.UUID{}); err == nil {
t.Error("refused twice")
}
}
func TestImport_AnUnfinishedDownloadIsOnlyWatched_Integration(t *testing.T) {
s := newStack(t, fakePrinter{})
s.im.ClientFor = func(context.Context, string) (downloader.Driver, error) { return &halfClient{s.client}, nil }
if rep := s.im.Pass(context.Background()); rep.Downloading != 1 {
t.Fatalf("pass = %+v", rep)
}
g := s.grabNow(t)
if g.ImportState != StateDownloading || g.Progress < 0.49 || g.Progress > 0.51 {
t.Errorf("grab = %s %.2f", g.ImportState, g.Progress)
}
}
type halfClient struct{ *fakeClient }
func (c *halfClient) Torrents(ctx context.Context, h ...string) ([]downloader.Torrent, error) {
ts, _ := c.fakeClient.Torrents(ctx, h...)
ts[0].Progress, ts[0].State = 0.5, "downloading"
return ts, nil
}
+195
View File
@@ -0,0 +1,195 @@
package importer
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/dhowden/tag"
"github.com/dhowden/tag/mbz"
"git.fabledsword.com/bvandeusen/minstrel/internal/acoustid"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// Printer says which MusicBrainz recordings a file's audio is. ErrNoPrints:
// it cannot say for any file (AcoustID is not set up, or not answering), so
// the check rests on lengths and tags.
type Printer interface {
Recordings(ctx context.Context, path string) ([]string, error)
}
// ErrNoPrints: fingerprints are not available for this download.
var ErrNoPrints = errors.New("importer: fingerprints are not available")
// AcoustIDPrinter fingerprints with fpcalc and asks AcoustID, under the
// operator's settings (key, minimum score). Each call reads the settings, so
// a change takes effect on the next file.
type AcoustIDPrinter struct {
Settings interface {
Get() library.AcoustIDSettings
}
Client *acoustid.Client
}
// Recordings is every recording AcoustID gives at or above the minimum score:
// one audio can be several recordings (the album track and the single).
func (p *AcoustIDPrinter) Recordings(ctx context.Context, path string) ([]string, error) {
s := p.Settings.Get()
if !s.Ready() || p.Client == nil {
return nil, ErrNoPrints
}
fp, dur, err := library.LookupFingerprint(ctx, path)
if err != nil {
if library.IsInconclusive(err) {
return nil, ErrNoPrints
}
return nil, nil // fpcalc rejected the file: no evidence either way
}
cands, err := p.Client.Lookup(ctx, s.APIKey, fp, dur)
switch {
case errors.Is(err, acoustid.ErrInvalidKey), errors.Is(err, acoustid.ErrUnavailable):
return nil, ErrNoPrints
case err != nil:
return nil, nil
}
var out []string
for _, c := range cands {
if c.Score >= float64(s.MinScore) && c.ID != "" {
out = append(out, c.ID)
}
}
return out, nil
}
// formatRank picks which format's files are the album when a download holds
// more than one (a FLAC folder and an MP3 folder): the most files, then the
// better format.
var formatRank = map[string]int{".flac": 6, ".wav": 5, ".m4a": 4, ".opus": 3, ".ogg": 2, ".mp3": 1, ".aac": 0}
// audioFiles lists the audio files under root (a folder, or one file), in
// the one format that is the album. Dot-named files and folders are skipped:
// they are partial downloads, OS litter or Minstrel's own temp files.
func audioFiles(root string) ([]string, error) {
info, err := os.Stat(root)
if err != nil {
return nil, err
}
if !info.IsDir() {
if library.IsAudioFile(root) {
return []string{root}, nil
}
return nil, nil
}
byExt := map[string][]string{}
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if strings.HasPrefix(d.Name(), ".") && path != root {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if !d.IsDir() && library.IsAudioFile(path) {
ext := strings.ToLower(filepath.Ext(path))
byExt[ext] = append(byExt[ext], path)
}
return nil
})
if err != nil {
return nil, err
}
best := ""
for ext, paths := range byExt {
if best == "" || len(paths) > len(byExt[best]) ||
(len(paths) == len(byExt[best]) && formatRank[ext] > formatRank[best]) {
best = ext
}
}
out := byExt[best]
sort.Strings(out)
return out, nil
}
// readFile reads what one file's tags and length say. A file whose tags
// cannot be read still counts, by its length and name.
func readFile(ctx context.Context, root, path string) File {
f := File{Path: path, Rel: rel(root, path), Video: len(library.SourceMarkersFor(path)) > 0}
if ms, err := library.ProbeDurationMs(ctx, path); err == nil {
f.LengthMs = int(ms)
}
h, err := os.Open(path)
if err != nil {
return f
}
defer func() { _ = h.Close() }()
m, err := tag.ReadFrom(h)
if err != nil {
return f
}
f.Title, f.Artist, f.AlbumArtist, f.Album = m.Title(), m.Artist(), m.AlbumArtist(), m.Album()
f.Track, _ = m.Track()
f.Disc, _ = m.Disc()
info := mbz.Extract(m)
f.GroupMBID = clean(info.Get(mbz.ReleaseGroup))
f.ReleaseMBID = clean(info.Get(mbz.Album))
f.RecordingMBID = clean(info.Get(mbz.Recording))
if f.RecordingMBID == "" && m.Format() == tag.VORBIS {
// Picard's Vorbis key for the recording is MUSICBRAINZ_TRACKID, which
// mbz files under its release-track name.
f.RecordingMBID = clean(info.Get(mbz.Track))
}
return f
}
func rel(root, path string) string {
if r, err := filepath.Rel(root, path); err == nil && r != "." {
return r
}
return filepath.Base(path)
}
// clean takes the first of NUL-separated values: ID3v2.4 multi-values.
func clean(s string) string {
for _, part := range strings.Split(s, "\x00") {
if t := strings.TrimSpace(part); t != "" {
return t
}
}
return ""
}
// inspect reads every audio file under root and asks the printer about each.
// fingerprinted reports whether the printer answered at all.
func inspect(ctx context.Context, root string, p Printer) (files []File, fingerprinted bool, err error) {
paths, err := audioFiles(root)
if err != nil {
return nil, false, err
}
fingerprinted = p != nil
for _, path := range paths {
if ctx.Err() != nil {
return nil, false, ctx.Err()
}
f := readFile(ctx, root, path)
if fingerprinted && len(paths) <= maxFiles {
recs, err := p.Recordings(ctx, path)
if errors.Is(err, ErrNoPrints) {
fingerprinted = false
for i := range files {
files[i].Recordings = nil
}
} else {
f.Recordings = recs
}
}
files = append(files, f)
}
return files, fingerprinted, nil
}
+110
View File
@@ -0,0 +1,110 @@
package importer
import (
"fmt"
"path/filepath"
"strings"
"unicode/utf8"
)
// Placement is where one file goes and the names written into its tags.
type Placement struct {
File int // index into the files
Dest string // absolute
Title string
Disc int
Discs int
Track int
Tracks int // on its disc
// Recording is the MusicBrainz recording the file was matched to.
Recording string
}
// Plan names every file's destination under root the way Lidarr named the
// library Minstrel took over (note #5527), so new arrivals sit beside what
// is there:
//
// {Artist}/{Album} ({Year})/{Artist} - {Album} - {track:00} - {Title}.ext
//
// with a "{Medium Format} {medium:00}" folder between when the edition has
// more than one medium. A file the check paired to a track takes the track's
// name and place; one it did not (an "import anyway" with more files than
// tracks) keeps its own tags.
func Plan(root string, w Want, e *Edition, files []File, pairs []Pair) []Placement {
album := w.Title
if w.Year > 0 {
album = fmt.Sprintf("%s (%d)", w.Title, w.Year)
}
dir := filepath.Join(root, Segment(w.Artist), Segment(album))
media := 1
if e != nil {
media = max(e.Media(), 1)
}
perDisc := map[int]int{}
if e != nil {
for _, t := range e.Tracks {
perDisc[t.Medium]++
}
}
paired := map[int]Pair{}
for _, p := range pairs {
paired[p.File] = p
}
out := make([]Placement, 0, len(files))
for i, f := range files {
pl := Placement{File: i, Title: f.Title, Disc: max(f.Disc, 1), Track: f.Track, Discs: media}
format := ""
if p, ok := paired[i]; ok && e != nil {
t := e.Tracks[p.Track]
pl.Title, pl.Disc, pl.Track, pl.Recording, format = t.Title, max(t.Medium, 1), t.Position, t.RecordingMBID, t.Format
pl.Tracks = perDisc[t.Medium]
}
if pl.Title == "" {
pl.Title = strings.TrimSuffix(filepath.Base(f.Path), filepath.Ext(f.Path))
}
d := dir
if media > 1 {
if format == "" {
format = "Disc"
}
d = filepath.Join(dir, Segment(fmt.Sprintf("%s %02d", format, pl.Disc)))
}
name := fmt.Sprintf("%s - %s - %02d - %s", w.Artist, w.Title, pl.Track, pl.Title)
pl.Dest = filepath.Join(d, Segment(name)+strings.ToLower(filepath.Ext(f.Path)))
out = append(out, pl)
}
return out
}
// segmentMax keeps a folder or file name inside the 255 bytes most
// filesystems allow, with room for an extension and a temp prefix.
const segmentMax = 200
var illegal = strings.NewReplacer(
`\`, "+", "/", "+", "<", "", ">", "", "?", "!", "*", "-", "|", "", `"`, "",
// Lidarr's default "smart" colon: "Title: Subtitle" reads "Title - Subtitle".
": ", " - ", ":", "-",
)
// Segment makes a name safe as one path segment, the way Lidarr and Sonarr
// clean file names: their illegal-character mapping, no leading or trailing
// spaces or dots, at most segmentMax bytes, never "" or "..".
func Segment(s string) string {
s = illegal.Replace(s)
s = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, s)
s = strings.Trim(s, " .")
for len(s) > segmentMax {
_, size := utf8.DecodeLastRuneInString(s)
s = strings.TrimRight(s[:len(s)-size], " .")
}
if s == "" {
return "_"
}
return s
}
+182
View File
@@ -0,0 +1,182 @@
package importer
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// ErrOccupied: the destination already has a file. Nothing is overwritten.
var ErrOccupied = errors.New("importer: destination already holds a file")
// tagTimeout bounds one ffmpeg run. A remux copies the audio untouched, so a
// whole album track takes well under a second on local disk; two minutes is
// for a slow network mount, not for ffmpeg.
const tagTimeout = 2 * time.Minute
// Tags are the names written into an imported file. Empty values are left
// out; the file's own tags stay for everything not named here.
type Tags struct {
Title, Artist, Album, AlbumArtist string
Year int
Disc, Discs, Track, Tracks int
// MusicBrainz ids, written the way Picard writes them.
ReleaseMBID, GroupMBID, RecordingMBID, ArtistMBID string
}
// args turns the tags into ffmpeg -metadata pairs for a file of this
// extension. Vorbis comments (FLAC, Ogg, Opus) take Picard's key names;
// ID3 takes Picard's TXXX descriptions, which ffmpeg writes for any key it
// has no frame for. MP4's freeform atoms ffmpeg cannot write, so an M4A
// gets the plain names only.
func (t Tags) args(ext string) []string {
kv := [][2]string{
{"title", t.Title}, {"artist", t.Artist}, {"album", t.Album}, {"album_artist", t.AlbumArtist},
{"track", ofN(t.Track, t.Tracks)}, {"disc", ofN(t.Disc, t.Discs)},
}
if t.Year > 0 {
kv = append(kv, [2]string{"date", strconv.Itoa(t.Year)})
}
switch ext {
case ".flac", ".ogg", ".opus":
kv = append(kv,
[2]string{"MUSICBRAINZ_ALBUMID", t.ReleaseMBID},
[2]string{"MUSICBRAINZ_RELEASEGROUPID", t.GroupMBID},
[2]string{"MUSICBRAINZ_TRACKID", t.RecordingMBID},
[2]string{"MUSICBRAINZ_ARTISTID", t.ArtistMBID},
[2]string{"MUSICBRAINZ_ALBUMARTISTID", t.ArtistMBID})
case ".mp3":
kv = append(kv,
[2]string{"MusicBrainz Album Id", t.ReleaseMBID},
[2]string{"MusicBrainz Release Group Id", t.GroupMBID},
[2]string{"MusicBrainz Track Id", t.RecordingMBID},
[2]string{"MusicBrainz Artist Id", t.ArtistMBID},
[2]string{"MusicBrainz Album Artist Id", t.ArtistMBID})
}
var out []string
for _, p := range kv {
if p[1] != "" {
out = append(out, "-metadata", p[0]+"="+p[1])
}
}
return out
}
func ofN(n, of int) string {
switch {
case n <= 0:
return ""
case of > 0:
return fmt.Sprintf("%d/%d", n, of)
}
return strconv.Itoa(n)
}
// taggable: the formats ffmpeg can rewrite tags in by remuxing.
var taggable = map[string]bool{".flac": true, ".ogg": true, ".opus": true, ".mp3": true, ".m4a": true}
// Place copies src to dst with tags written, leaving src as it was (the
// download keeps seeding). The copy is written to a dot-named temp file
// beside dst, which the scanner skips, and renamed into place, so the
// library never sees half a file. Tags are written by an ffmpeg remux that
// copies the audio untouched; when ffmpeg cannot (it is missing, or the
// container will not take the file's streams) the file is copied as it is
// and tagged reports false. An existing dst is never overwritten.
func Place(ctx context.Context, src, dst string, t Tags) (tagged bool, err error) {
if _, err := os.Lstat(dst); err == nil {
return false, fmt.Errorf("%w: %s", ErrOccupied, dst)
}
dir := filepath.Dir(dst)
if err := os.MkdirAll(dir, 0o755); err != nil {
return false, fmt.Errorf("make %s: %w", dir, err)
}
ext := strings.ToLower(filepath.Ext(dst))
tmp := filepath.Join(dir, ".minstrel-import-"+randomHex()+ext)
defer func() { _ = os.Remove(tmp) }() // gone already once renamed
if taggable[ext] {
tagged = remux(ctx, src, tmp, t.args(ext)) == nil
}
if !tagged {
if err := copyFile(src, tmp); err != nil {
return false, err
}
}
if err := os.Chmod(tmp, 0o644); err != nil {
return false, fmt.Errorf("chmod %s: %w", tmp, err)
}
if _, err := os.Lstat(dst); err == nil {
return false, fmt.Errorf("%w: %s", ErrOccupied, dst)
}
if err := os.Rename(tmp, dst); err != nil {
return false, fmt.Errorf("move into place %s: %w", dst, err)
}
return tagged, nil
}
// remux writes src to dst through ffmpeg with every stream copied as it is
// (the cover art too) and the tags set. Its output is checked against the
// source's size: a remux shifts container bytes, never the audio, so a file
// much smaller than its source lost something on the way.
func remux(ctx context.Context, src, dst string, meta []string) error {
ctx, cancel := context.WithTimeout(ctx, tagTimeout)
defer cancel()
args := append([]string{"-nostdin", "-v", "error", "-y", "-i", src,
"-map", "0", "-c", "copy", "-map_metadata", "0"}, meta...)
args = append(args, dst)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
if out, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(dst)
return fmt.Errorf("ffmpeg: %w: %s", err, strings.TrimSpace(string(out)))
}
si, err := os.Stat(src)
if err != nil {
return err
}
di, err := os.Stat(dst)
if err != nil {
return err
}
if di.Size() < si.Size()/2 {
_ = os.Remove(dst)
return fmt.Errorf("ffmpeg wrote %d bytes from %d", di.Size(), si.Size())
}
return nil
}
// copyFile copies src to dst byte for byte and syncs it.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer func() { _ = in.Close() }()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return fmt.Errorf("copy %s: %w", src, err)
}
if err := out.Sync(); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
func randomHex() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
+466
View File
@@ -0,0 +1,466 @@
// Package importer checks a finished download against the album it was
// grabbed for and files it into the library (M502 #5522). Nothing reaches
// the library before the check says the files are the album: a wrong album,
// another version of it, a video rip or an incomplete copy is refused, its
// release is blocked and the album is searched for again.
//
// Ported from Fabled Librarian's importer and relocate packages, with the
// check rewritten for music: the files are matched to one of the album's
// MusicBrainz editions by AcoustID fingerprints, track lengths, titles and
// the tags they carry.
package importer
import (
"fmt"
"slices"
"sort"
"strings"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// Outcome is what the check decided.
type Outcome string
const (
Accept Outcome = "accept"
// Review: the files may be the album and the evidence cannot say. A
// person decides.
Review Outcome = "review"
Refuse Outcome = "refuse"
)
// Codes say why, beside the sentence in Verdict.Reason.
const (
CodeMatched = "matched"
CodeForced = "forced"
CodeNoAudio = "no_audio"
CodeVideo = "video"
CodeWrongAlbum = "wrong_album"
CodeOtherVersion = release.VetoVersion
CodeWrongTitle = release.VetoTitle
CodeIncomplete = "incomplete"
CodeNoEdition = "no_edition"
CodeWeak = "weak"
CodeTooMany = "too_many"
)
// maxFiles is the most audio files one album is checked for. More is a box
// set or a discography, which is a person's call, not a fingerprint queue.
const maxFiles = 60
// File is what one audio file in a download says about itself.
type File struct {
Path string // absolute, as Minstrel sees it
Rel string // under the download, for the person reading the verdict
// Disc and Track are the tags' positions; 0 when untagged.
Disc, Track int
Title string
Artist string
AlbumArtist string
Album string
// MusicBrainz ids from the tags, when a tagger wrote them.
GroupMBID string
ReleaseMBID string
RecordingMBID string
LengthMs int
// Recordings are the MusicBrainz recordings AcoustID says the audio is,
// at or above the operator's minimum score.
Recordings []string
// Video: the file name says it is a music video, a lyric video or the
// like (library.SourceMarkersFor).
Video bool
}
// Track is one track of an edition.
type Track struct {
Medium int
Format string // the medium's: CD, Digital Media, Vinyl
Position int
Title string
RecordingMBID string
LengthMs int
}
// Edition is one MusicBrainz release of the wanted release group, its
// tracks in medium and position order.
type Edition struct {
MBID string
Title string
Status string
Date string
Country string
Tracks []Track
}
// Media is how many media the edition has.
func (e Edition) Media() int {
n := 0
for _, t := range e.Tracks {
n = max(n, t.Medium)
}
return n
}
// Want is the album the download was grabbed for.
type Want struct {
GroupMBID string
ArtistMBID string
Artist string
Title string
Year int
PrimaryType string
SecondaryTypes []string
}
// Pair is a file matched to an edition's track, and what agreed.
type Pair struct {
File int `json:"file"`
Track int `json:"track"`
Recording bool `json:"recording"`
Length bool `json:"length"`
Title bool `json:"title"`
}
// Evidence is what the check counted, kept with the verdict so a person can
// see what it rested on.
type Evidence struct {
Files int `json:"files"`
Tracks int `json:"tracks"`
// Identified: files with a recording from AcoustID or their tags.
Identified int `json:"identified"`
Recordings int `json:"recordings"`
Lengths int `json:"lengths"`
Titles int `json:"titles"`
// GroupTag: "same" when the tags name this release group, "other" when
// another, "" when they name none.
GroupTag string `json:"group_tag,omitempty"`
AlbumTag string `json:"album_tag,omitempty"`
// Fingerprinted: AcoustID was asked. False when it is not set up or
// could not be reached; the check then rests on lengths and tags.
Fingerprinted bool `json:"fingerprinted"`
}
// Verdict is the check's answer.
type Verdict struct {
Outcome Outcome `json:"outcome"`
Code string `json:"code"`
Reason string `json:"reason"`
// Edition indexes the editions passed in; -1 when none was chosen.
Edition int `json:"-"`
Pairs []Pair `json:"pairs,omitempty"`
Evidence Evidence `json:"evidence"`
}
// Verify decides whether files are the wanted album and, if so, which
// edition and which track each file is. force is a person's "import anyway":
// the best edition is taken whatever the evidence, but a download with no
// audio is still refused.
func Verify(w Want, editions []Edition, files []File, fingerprinted, force bool) Verdict {
v := Verdict{Edition: -1, Evidence: Evidence{Files: len(files), Fingerprinted: fingerprinted}}
if len(files) == 0 {
return v.set(Refuse, CodeNoAudio, "The download holds no audio files.")
}
if !force && len(files) > maxFiles {
return v.set(Review, CodeTooMany, fmt.Sprintf(
"The download holds %d audio files, more than one album; a person decides.", len(files)))
}
if video := countIf(files, func(f File) bool { return f.Video }); !force && video*2 > len(files) {
return v.set(Refuse, CodeVideo, fmt.Sprintf(
"%d of its %d audio files are named as music or lyric videos: the sound of a video, not the album.", video, len(files)))
}
group := majority(files, func(f File) string { return f.GroupMBID })
switch {
case group == "":
case strings.EqualFold(group, w.GroupMBID):
v.Evidence.GroupTag = "same"
default:
v.Evidence.GroupTag = "other"
}
album := majority(files, func(f File) string { return f.Album })
v.Evidence.AlbumTag = album
var tagVeto release.Match
albumAgrees := false
if album != "" {
// The want's own artist goes in front: the tags' artist spelling is
// not what this asks. Only the album title and its version words.
m := release.Judge(release.Want{Artist: w.Artist, Title: w.Title, PrimaryType: w.PrimaryType,
SecondaryTypes: w.SecondaryTypes}, release.Parse(w.Artist+" - "+album))
switch m.Veto {
case release.VetoVersion, release.VetoTitle:
tagVeto = m
case "":
albumAgrees = true
}
}
if !force && v.Evidence.GroupTag == "other" {
return v.set(Refuse, CodeWrongAlbum, fmt.Sprintf(
"Its tags name a different MusicBrainz release group (%s), not %s.", group, w.Title))
}
if len(editions) == 0 {
if force {
v.Code, v.Outcome, v.Reason = CodeForced, Accept, "Imported anyway at a person's word; MusicBrainz lists no edition to match it to."
return v
}
return v.set(Review, CodeNoEdition, fmt.Sprintf(
"MusicBrainz lists no edition of %s to check the files against; a person decides.", w.Title))
}
best, bestScore := -1, -1
shortest, longest := len(editions[0].Tracks), 0
for i, e := range editions {
shortest, longest = min(shortest, len(e.Tracks)), max(longest, len(e.Tracks))
if len(e.Tracks) != len(files) && !force {
continue
}
pairs := pairUp(files, e.Tracks)
if s := score(pairs); s > bestScore || (s == bestScore && better(e, editions[best])) {
best, bestScore = i, s
}
}
if best < 0 {
if len(files) < shortest {
return v.set(Refuse, CodeIncomplete, fmt.Sprintf(
"It holds %d audio files; the shortest edition of %s has %d tracks.", len(files), w.Title, shortest))
}
return v.set(Review, CodeNoEdition, fmt.Sprintf(
"It holds %d audio files; no edition of %s has %d tracks (they have %s). A person decides.",
len(files), w.Title, len(files), span(shortest, longest)))
}
e := editions[best]
v.Edition = best
v.Pairs = pairUp(files, e.Tracks)
n := len(e.Tracks)
ev := &v.Evidence
ev.Tracks = n
for _, p := range v.Pairs {
f := files[p.File]
if f.RecordingMBID != "" || len(f.Recordings) > 0 {
ev.Identified++
}
ev.Recordings += b2i(p.Recording)
ev.Lengths += b2i(p.Length)
ev.Titles += b2i(p.Title)
}
if force {
v.Outcome, v.Code = Accept, CodeForced
v.Reason = fmt.Sprintf("Imported anyway at a person's word, as %s. %s", describe(e), ev.sentence())
return v
}
half := (n + 1) / 2
strong := ev.GroupTag == "same" || (ev.Identified >= half && ev.Recordings*10 >= ev.Identified*7)
switch {
case ev.Identified >= half && ev.Recordings*10 < ev.Identified*3 && ev.GroupTag != "same":
return v.set(Refuse, CodeWrongAlbum, fmt.Sprintf(
"Only %d of the %d tracks AcoustID knows are recordings on %s.", ev.Recordings, ev.Identified, w.Title))
case tagVeto.Veto != "" && !strong:
return v.set(Refuse, tagVeto.Veto, fmt.Sprintf("Its tags call it %q. %s", album, tagVeto.Reason))
case !strong && ev.Identified == 0 && ev.Lengths*2 < n:
return v.set(Refuse, CodeWrongAlbum, fmt.Sprintf(
"Only %d of its %d tracks are the length of %s's, and nothing else says it is the album.", ev.Lengths, n, w.Title))
case strong && ev.Lengths*10 >= n*6,
!strong && ev.Lengths*10 >= n*8 && (ev.Titles*10 >= n*6 || albumAgrees):
v.Outcome, v.Code = Accept, CodeMatched
v.Reason = fmt.Sprintf("It is %s. %s", describe(e), ev.sentence())
return v
}
return v.set(Review, CodeWeak, fmt.Sprintf(
"It may be %s, but the evidence is thin: %s A person decides.", describe(e), ev.sentence()))
}
func (v Verdict) set(o Outcome, code, reason string) Verdict {
v.Outcome, v.Code, v.Reason = o, code, reason
return v
}
// sentence says what agreed, for the verdict's reason.
func (ev Evidence) sentence() string {
var parts []string
if ev.GroupTag == "same" {
parts = append(parts, "its tags name this album")
}
if ev.Identified > 0 {
parts = append(parts, fmt.Sprintf("%d of %d identified tracks are its recordings", ev.Recordings, ev.Identified))
}
parts = append(parts, fmt.Sprintf("%d of %d lengths fit", ev.Lengths, ev.Tracks),
fmt.Sprintf("%d of %d titles agree", ev.Titles, ev.Tracks))
s := strings.Join(parts, ", ")
s = strings.ToUpper(s[:1]) + s[1:] + "."
if !ev.Fingerprinted {
s += " AcoustID was not asked (not set up or unreachable), so lengths and tags decided."
}
return s
}
// describe names an edition for a person: "the 2013 CD release (13 tracks)".
func describe(e Edition) string {
var b strings.Builder
b.WriteString("the ")
if len(e.Date) >= 4 {
b.WriteString(e.Date[:4] + " ")
}
if f := e.format(); f != "" {
b.WriteString(f + " ")
}
fmt.Fprintf(&b, "edition (%d tracks)", len(e.Tracks))
return b.String()
}
func (e Edition) format() string {
if len(e.Tracks) == 0 {
return ""
}
f := e.Tracks[0].Format
for _, t := range e.Tracks {
if t.Format != f {
return ""
}
}
return f
}
// pairUp matches files to tracks in order: by the tags' disc and track
// numbers when every file has them and no two share a place, by file path
// otherwise. With a person's "import anyway" the counts may differ; pairs
// run to the shorter.
func pairUp(files []File, tracks []Track) []Pair {
order := make([]int, len(files))
for i := range order {
order[i] = i
}
if numbered(files) {
sort.SliceStable(order, func(a, b int) bool {
fa, fb := files[order[a]], files[order[b]]
if da, db := max(fa.Disc, 1), max(fb.Disc, 1); da != db {
return da < db
}
return fa.Track < fb.Track
})
} else {
sort.SliceStable(order, func(a, b int) bool { return files[order[a]].Rel < files[order[b]].Rel })
}
n := min(len(files), len(tracks))
pairs := make([]Pair, n)
for i := range n {
f, t := files[order[i]], tracks[i]
pairs[i] = Pair{
File: order[i],
Track: i,
Recording: t.RecordingMBID != "" && (f.RecordingMBID == t.RecordingMBID || slices.Contains(f.Recordings, t.RecordingMBID)),
Length: lengthFits(f.LengthMs, t.LengthMs),
Title: titleAgrees(f.Title, t.Title),
}
}
return pairs
}
// numbered: every file carries a track number and no two share a disc and
// track.
func numbered(files []File) bool {
seen := map[[2]int]bool{}
for _, f := range files {
k := [2]int{max(f.Disc, 1), f.Track}
if f.Track <= 0 || seen[k] {
return false
}
seen[k] = true
}
return true
}
// lengthFits allows five seconds or 3%, whichever is more: remasters and
// rips differ by a second or two, a different recording by far more. A
// track MusicBrainz has no length for says nothing against the file.
func lengthFits(fileMs, trackMs int) bool {
if trackMs <= 0 {
return true
}
if fileMs <= 0 {
return false
}
tol := max(5000, trackMs*3/100)
d := fileMs - trackMs
return d <= tol && -d <= tol
}
// titleAgrees: the same words, or one title holding all of the other's
// ("Get Lucky (Radio Edit)" holds "Get Lucky").
func titleAgrees(a, b string) bool {
ta, tb := release.Tokens(a), release.Tokens(b)
if len(ta) == 0 || len(tb) == 0 {
return false
}
return subset(ta, tb) || subset(tb, ta)
}
func subset(small, big []string) bool {
for _, w := range small {
if !slices.Contains(big, w) {
return false
}
}
return true
}
// score weighs recordings above lengths above titles: a fingerprint is the
// audio itself, a length is the audio's shape, a title is only a name.
func score(pairs []Pair) int {
s := 0
for _, p := range pairs {
s += 3*b2i(p.Recording) + 2*b2i(p.Length) + b2i(p.Title)
}
return s
}
// better breaks a tie between equally good editions: an official one, then
// the earlier.
func better(a, b Edition) bool {
if (a.Status == "Official") != (b.Status == "Official") {
return a.Status == "Official"
}
return a.Date != "" && (b.Date == "" || a.Date < b.Date)
}
func majority(files []File, get func(File) string) string {
counts := map[string]int{}
best, n := "", 0
for _, f := range files {
v := strings.TrimSpace(get(f))
if v == "" {
continue
}
counts[strings.ToLower(v)]++
if c := counts[strings.ToLower(v)]; c > n {
best, n = v, c
}
}
return best
}
func countIf(files []File, f func(File) bool) int {
n := 0
for _, x := range files {
if f(x) {
n++
}
}
return n
}
func span(a, b int) string {
if a == b {
return fmt.Sprint(a)
}
return fmt.Sprintf("%d to %d", a, b)
}
func b2i(b bool) int {
if b {
return 1
}
return 0
}
+243
View File
@@ -0,0 +1,243 @@
package importer
import (
"fmt"
"path/filepath"
"strings"
"testing"
)
var ram = Want{GroupMBID: "rg-ram", ArtistMBID: "mb-dp", Artist: "Daft Punk", Title: "Random Access Memories",
Year: 2013, PrimaryType: "Album"}
var ramTitles = []string{"Give Life Back to Music", "The Game of Love", "Giorgio by Moroder", "Within",
"Instant Crush", "Lose Yourself to Dance", "Touch", "Get Lucky", "Beyond", "Motherboard",
"Fragments of Time", "Doin' It Right", "Contact"}
// edition is RAM's 13-track CD, recordings rec-01.., lengths 4:00 + n seconds.
func edition(mbid string, n int) Edition {
e := Edition{MBID: mbid, Title: "Random Access Memories", Status: "Official", Date: "2013-05-17"}
for i := range n {
e.Tracks = append(e.Tracks, Track{Medium: 1, Format: "CD", Position: i + 1, Title: ramTitles[i%len(ramTitles)],
RecordingMBID: fmt.Sprintf("rec-%02d", i+1), LengthMs: 240000 + i*1000})
}
return e
}
// download is n files lined up with the edition: tagged positions, lengths
// within a second, AcoustID naming each file's recording.
func download(n int) []File {
var fs []File
for i := range n {
fs = append(fs, File{Path: fmt.Sprintf("/dl/%02d.flac", i+1), Rel: fmt.Sprintf("%02d.flac", i+1),
Disc: 1, Track: i + 1, Title: ramTitles[i%len(ramTitles)], Album: "Random Access Memories",
LengthMs: 240000 + i*1000 + 400, Recordings: []string{fmt.Sprintf("rec-%02d", i+1)}})
}
return fs
}
func TestVerify_TheAlbumAndWhatIsNot(t *testing.T) {
eds := []Edition{edition("rel-cd", 13), edition("rel-deluxe", 15)}
cases := []struct {
name string
files func() []File
printed bool
out Outcome
code string
edition string
}{
{"the album, fingerprinted", func() []File { return download(13) }, true, Accept, CodeMatched, "rel-cd"},
{"the deluxe by count", func() []File { return download(15) }, true, Accept, CodeMatched, "rel-deluxe"},
{"no audio", func() []File { return nil }, true, Refuse, CodeNoAudio, ""},
{"music videos", func() []File {
fs := download(13)
for i := range fs {
fs[i].Video = i < 10
}
return fs
}, true, Refuse, CodeVideo, ""},
{"tags name another group", func() []File {
fs := download(13)
for i := range fs {
fs[i].GroupMBID = "rg-homework"
}
return fs
}, true, Refuse, CodeWrongAlbum, ""},
{"fingerprints say other recordings", func() []File {
fs := download(13)
for i := range fs {
fs[i].Recordings = []string{"rec-other"}
}
return fs
}, true, Refuse, CodeWrongAlbum, "rel-cd"},
{"the drumless edition: right lengths, album tag says otherwise", func() []File {
fs := download(13)
for i := range fs {
fs[i].Recordings, fs[i].Album = nil, "Random Access Memories (Drumless Edition)"
}
return fs
}, false, Refuse, CodeOtherVersion, "rel-cd"},
{"fewer files than any edition", func() []File { return download(9) }, true, Refuse, CodeIncomplete, ""},
{"no edition this long", func() []File { return download(14) }, true, Review, CodeNoEdition, ""},
{"no fingerprints, lengths and titles agree", func() []File {
fs := download(13)
for i := range fs {
fs[i].Recordings = nil
}
return fs
}, false, Accept, CodeMatched, "rel-cd"},
{"no fingerprints, no titles, nothing to go on but lengths", func() []File {
fs := download(13)
for i := range fs {
fs[i].Recordings, fs[i].Title, fs[i].Album = nil, "", ""
}
return fs
}, false, Review, CodeWeak, "rel-cd"},
{"no fingerprints, lengths all wrong", func() []File {
fs := download(13)
for i := range fs {
fs[i].Recordings, fs[i].LengthMs = nil, 600000
}
return fs
}, false, Refuse, CodeWrongAlbum, "rel-cd"},
{"tags name the group: strong even unprinted", func() []File {
fs := download(13)
for i := range fs {
fs[i].Recordings, fs[i].Title, fs[i].GroupMBID = nil, "", "rg-ram"
}
return fs
}, false, Accept, CodeMatched, "rel-cd"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
v := Verify(ram, eds, c.files(), c.printed, false)
if v.Outcome != c.out || v.Code != c.code {
t.Fatalf("got %s/%s (%s), want %s/%s", v.Outcome, v.Code, v.Reason, c.out, c.code)
}
got := ""
if v.Edition >= 0 {
got = eds[v.Edition].MBID
}
if got != c.edition {
t.Errorf("edition %q, want %q", got, c.edition)
}
if v.Reason == "" {
t.Error("no reason given")
}
})
}
}
func TestVerify_UnnumberedFilesPairByPath(t *testing.T) {
fs := download(13)
for i := range fs {
fs[i].Track, fs[i].Disc = 0, 0
}
fs[0], fs[5] = fs[5], fs[0] // listed out of order; their names sort right
v := Verify(ram, []Edition{edition("rel-cd", 13)}, fs, true, false)
if v.Outcome != Accept || v.Evidence.Recordings != 13 {
t.Fatalf("got %+v", v)
}
}
func TestVerify_ImportAnywayTakesTheBestEdition(t *testing.T) {
v := Verify(ram, []Edition{edition("rel-cd", 13)}, download(14), true, true)
if v.Outcome != Accept || v.Code != CodeForced || v.Edition != 0 || len(v.Pairs) != 13 {
t.Fatalf("got %+v", v)
}
if v := Verify(ram, nil, nil, true, true); v.Outcome != Refuse {
t.Errorf("forced with no audio: %+v", v)
}
}
func TestVerify_WithoutAcoustIDTheReasonSaysSo(t *testing.T) {
fs := download(13)
for i := range fs {
fs[i].Recordings = nil
}
v := Verify(ram, []Edition{edition("rel-cd", 13)}, fs, false, false)
if !strings.Contains(v.Reason, "AcoustID was not asked") {
t.Errorf("reason %q", v.Reason)
}
}
func TestLengthFits(t *testing.T) {
for _, c := range []struct {
file, track int
ok bool
}{
{240000, 244000, true}, {240000, 246000, false}, {600000, 615000, true}, {600000, 620000, false},
{0, 240000, false}, {240000, 0, true},
} {
if got := lengthFits(c.file, c.track); got != c.ok {
t.Errorf("lengthFits(%d, %d) = %v", c.file, c.track, got)
}
}
}
func TestPlan_LidarrsNaming(t *testing.T) {
e := edition("rel-cd", 2)
e.Tracks[1].Title = "Within: Part 2/3?"
pl := Plan("/music", ram, &e, download(2), Verify(ram, []Edition{e}, download(2), true, false).Pairs)
want := []string{
"/music/Daft Punk/Random Access Memories (2013)/Daft Punk - Random Access Memories - 01 - Give Life Back to Music.flac",
"/music/Daft Punk/Random Access Memories (2013)/Daft Punk - Random Access Memories - 02 - Within - Part 2+3!.flac",
}
for i, p := range pl {
if p.Dest != want[i] {
t.Errorf("dest %d = %q, want %q", i, p.Dest, want[i])
}
}
if pl[0].Recording != "rec-01" || pl[0].Tracks != 2 {
t.Errorf("placement %+v", pl[0])
}
}
func TestPlan_MultiDiscGetsAMediumFolder(t *testing.T) {
e := edition("rel-2cd", 4)
e.Tracks[2].Medium, e.Tracks[2].Position = 2, 1
e.Tracks[3].Medium, e.Tracks[3].Position = 2, 2
fs := download(4)
fs[2].Disc, fs[2].Track, fs[3].Disc, fs[3].Track = 2, 1, 2, 2
pl := Plan("/music", ram, &e, fs, Verify(ram, []Edition{e}, fs, true, false).Pairs)
if got := filepath.Dir(pl[3].Dest); got != "/music/Daft Punk/Random Access Memories (2013)/CD 02" {
t.Errorf("disc folder %q", got)
}
if !strings.HasSuffix(pl[3].Dest, " - 02 - Get Lucky.flac") && !strings.Contains(pl[3].Dest, " - 02 - ") {
t.Errorf("dest %q", pl[3].Dest)
}
}
func TestSegment(t *testing.T) {
for in, want := range map[string]string{
"AC/DC": "AC+DC", `What?`: "What!", "Title: Sub": "Title - Sub", "a:b": "a-b", " ..x.. ": "x",
"": "_", "..": "_", `"Quoted" <x> *y* |z|`: "Quoted x -y- z",
} {
if got := Segment(in); got != want {
t.Errorf("Segment(%q) = %q, want %q", in, got, want)
}
}
if long := Segment(strings.Repeat("é", 200)); len(long) > segmentMax {
t.Errorf("long segment is %d bytes", len(long))
}
}
func TestTagsArgs(t *testing.T) {
tg := Tags{Title: "Get Lucky", Artist: "Daft Punk", Track: 8, Tracks: 13, Disc: 1, Discs: 1, Year: 2013,
RecordingMBID: "rec-08", GroupMBID: "rg-ram"}
flac := strings.Join(tg.args(".flac"), " ")
for _, s := range []string{"title=Get Lucky", "track=8/13", "date=2013", "MUSICBRAINZ_TRACKID=rec-08", "MUSICBRAINZ_RELEASEGROUPID=rg-ram"} {
if !strings.Contains(flac, s) {
t.Errorf("flac args lack %q: %s", s, flac)
}
}
if strings.Contains(flac, "MUSICBRAINZ_ALBUMID") {
t.Errorf("an empty release id was written: %s", flac)
}
if mp3 := strings.Join(tg.args(".mp3"), " "); !strings.Contains(mp3, "MusicBrainz Track Id=rec-08") {
t.Errorf("mp3 args %s", mp3)
}
if m4a := strings.Join(tg.args(".m4a"), " "); strings.Contains(m4a, "MusicBrainz") {
t.Errorf("m4a args %s", m4a)
}
}
+13
View File
@@ -422,3 +422,16 @@ func notURLBase64(r rune) bool {
}
return true
}
// LookupFingerprint is the compressed fingerprint AcoustID's lookup takes and
// the file's whole length in seconds, for a file not in the library yet: the
// import checks a download with it (M502 #5522). IsInconclusive tells a
// missing or stalled fpcalc from a file it rejected.
func LookupFingerprint(ctx context.Context, path string) (fingerprint string, durationSec int, err error) {
fp, err := computeLookupFingerprint(ctx, path)
return fp.fingerprint, fp.durationSec, err
}
// IsInconclusive reports that a fingerprint tool could not give an answer
// (missing, timed out), as opposed to rejecting the file.
func IsInconclusive(err error) bool { return isInconclusive(err) }
+13
View File
@@ -674,3 +674,16 @@ func probeDurationMs(ctx context.Context, path string) (int32, error) {
}
return int32(ms), nil
}
// ProbeDurationMs is a file's length in milliseconds, read by ffprobe.
func ProbeDurationMs(ctx context.Context, path string) (int32, error) {
return probeDurationMs(ctx, path)
}
// IsAudioFile reports whether the scanner reads a file with this name.
func IsAudioFile(path string) bool {
return audioExtensions[strings.ToLower(filepath.Ext(path))]
}
// Roots are the folders the scanner reads: the library.
func (s *Scanner) Roots() []string { return append([]string(nil), s.paths...) }
+1
View File
@@ -180,6 +180,7 @@ func (s *Searcher) pass(ctx context.Context) Report {
rep.Nothing++
default:
rep.Failed++
s.logger.Warn("wanted: search failed", "want", want.Artist+" - "+want.Title, "err", out.Error)
}
}
return rep