mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Merge branch 'master' into error-handling-and-response-bugs/5896
This commit is contained in:
commit
5f766827f9
5
.github/actions/prepare-docker/action.yml
vendored
5
.github/actions/prepare-docker/action.yml
vendored
@ -68,6 +68,11 @@ runs:
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
# Runner IPs are shared, so anonymous base image pulls get rate-limited.
|
||||
buildkitd-config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["mirror.gcr.io"]
|
||||
|
||||
- name: Extract metadata for Docker image
|
||||
id: meta
|
||||
|
||||
8
.github/workflows/pipeline.yml
vendored
8
.github/workflows/pipeline.yml
vendored
@ -68,10 +68,16 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# Keep CI on the same version `make lint` installs, so a clean local run
|
||||
# cannot turn red in CI just because a new golangci-lint was released.
|
||||
- name: Resolve golangci-lint version
|
||||
id: golangci-version
|
||||
run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
version: ${{ steps.golangci-version.outputs.version }}
|
||||
problem-matchers: true
|
||||
args: --timeout 2m
|
||||
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
|
||||
|
||||
########################################################################################################################
|
||||
### Build xx (original image: tonistiigi/xx)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
|
||||
FROM --platform=$BUILDPLATFORM alpine:3.20 AS xx-build
|
||||
|
||||
# v1.9.0
|
||||
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
||||
@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome UI
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
|
||||
FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui
|
||||
WORKDIR /app
|
||||
|
||||
# Install node dependencies
|
||||
@ -43,7 +43,7 @@ COPY --from=ui /build /build
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build-alpine
|
||||
COPY --from=xx / /
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
@ -85,7 +85,7 @@ EOT
|
||||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-trixie AS base
|
||||
RUN apt-get update && apt-get install -y clang lld
|
||||
COPY --from=xx / /
|
||||
WORKDIR /workspace
|
||||
@ -154,7 +154,7 @@ COPY --from=build /out /
|
||||
|
||||
########################################################################################################################
|
||||
### Build Final Image
|
||||
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
|
||||
FROM alpine:3.20 AS final
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||
|
||||
|
||||
@ -214,5 +214,14 @@ var _ = Describe("auth_router", func() {
|
||||
_, err = verifyLinkToken(nonExpiringToken)
|
||||
Expect(err).To(MatchError("link token missing expiration"))
|
||||
})
|
||||
|
||||
It("rejects a Jellyfin access token", func() {
|
||||
usr := &model.User{ID: "u1", UserName: "johndoe"}
|
||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = verifyLinkToken(tokenStr)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
341
cmd/artwork.go
341
cmd/artwork.go
@ -26,29 +26,41 @@ import (
|
||||
|
||||
var explainLive bool
|
||||
|
||||
// Only one subcommand runs per invocation, so reprocess and cancel bind the same flag targets.
|
||||
var (
|
||||
reprocessKinds []string
|
||||
reprocessSources []string
|
||||
reprocessAll bool
|
||||
reprocessDryRun bool
|
||||
reprocessYes bool
|
||||
artworkKinds []string
|
||||
artworkSources []string
|
||||
artworkPriorities []string
|
||||
artworkAll bool
|
||||
artworkDryRun bool
|
||||
artworkYes bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
artworkExplainCmd.Flags().BoolVar(&explainLive, "live", false,
|
||||
"perform real external lookups instead of reporting what would be tried; "+
|
||||
"also initializes plugin agents, which may open external connections")
|
||||
artworkReprocessCmd.Flags().StringSliceVar(&reprocessKinds, "kind", nil,
|
||||
"walk the chain again now, performing real external lookups, instead of reporting the "+
|
||||
"stored trace of the last resolution; also initializes plugin agents, which may open "+
|
||||
"external connections")
|
||||
artworkReprocessCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil,
|
||||
"kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable")
|
||||
artworkReprocessCmd.Flags().StringSliceVar(&reprocessSources, "source", nil,
|
||||
artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "source", nil,
|
||||
"only items currently resolved from these sources (e.g. folder, external:deezer, absent)")
|
||||
artworkReprocessCmd.Flags().BoolVar(&reprocessAll, "all", false, "reprocess every kind")
|
||||
artworkReprocessCmd.Flags().BoolVar(&reprocessDryRun, "dry-run", false,
|
||||
artworkReprocessCmd.Flags().BoolVar(&artworkAll, "all", false, "reprocess every kind")
|
||||
artworkReprocessCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false,
|
||||
"report what would be queued and exit without queueing")
|
||||
artworkReprocessCmd.Flags().BoolVarP(&reprocessYes, "yes", "y", false, "skip the confirmation prompt")
|
||||
artworkReprocessCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt")
|
||||
artworkCancelCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil,
|
||||
"kinds to cancel ("+kindPrefixes(artwork.RefreshableKinds)+"); repeatable")
|
||||
artworkCancelCmd.Flags().StringSliceVar(&artworkPriorities, "priority", nil,
|
||||
"only rows queued at these priorities ("+priorityNames()+"); repeatable")
|
||||
artworkCancelCmd.Flags().BoolVar(&artworkAll, "all", false, "cancel every kind at every priority")
|
||||
artworkCancelCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false,
|
||||
"report what would be cancelled and exit without cancelling")
|
||||
artworkCancelCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt")
|
||||
artworkCmd.AddCommand(artworkExplainCmd)
|
||||
artworkCmd.AddCommand(artworkRefreshCmd)
|
||||
artworkCmd.AddCommand(artworkReprocessCmd)
|
||||
artworkCmd.AddCommand(artworkCancelCmd)
|
||||
artworkCmd.AddCommand(artworkStatusCmd)
|
||||
rootCmd.AddCommand(artworkCmd)
|
||||
}
|
||||
@ -92,6 +104,22 @@ var artworkReprocessCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var artworkCancelCmd = &cobra.Command{
|
||||
Use: "cancel",
|
||||
Short: "Cancel pending artwork work in bulk, by kind and/or queue priority",
|
||||
Long: "Cancel pending artwork work in bulk, by kind and/or queue priority.\n\n" +
|
||||
"Only the queue is touched: resolved artwork and the state behind `artwork explain` are\n" +
|
||||
"left alone, and the trace of why a cancelled item last failed goes with its queue row.\n\n" +
|
||||
"Work already picked up is not interrupted, and an item with no artwork yet can be\n" +
|
||||
"queued again by the hourly re-check. The selection is applied again when you confirm,\n" +
|
||||
"so anything queued after the preview is cancelled too. Use it to call off a bulk\n" +
|
||||
"backfill, not to stop the worker.",
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runCancel(cmd.Context())
|
||||
},
|
||||
}
|
||||
|
||||
var artworkStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Report the artwork queue, where artwork resolves from, and the backfill state",
|
||||
@ -132,9 +160,11 @@ type statusReport struct {
|
||||
current string
|
||||
}
|
||||
|
||||
func (r statusReport) queueTotal() int64 {
|
||||
func (r statusReport) queueTotal() int64 { return queueTotal(r.queue) }
|
||||
|
||||
func queueTotal(stats []model.ArtworkQueueStat) int64 {
|
||||
var n int64
|
||||
for _, s := range r.queue {
|
||||
for _, s := range stats {
|
||||
n += s.Count
|
||||
}
|
||||
return n
|
||||
@ -154,7 +184,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error
|
||||
q := ds.ArtworkQueue(ctx)
|
||||
var rep statusReport
|
||||
var err error
|
||||
if rep.queue, err = q.CountByKindAndPriority(); err != nil {
|
||||
if rep.queue, err = q.CountQueued(nil, nil); err != nil {
|
||||
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
|
||||
}
|
||||
|
||||
@ -194,11 +224,7 @@ func formatStatus(rep statusReport) string {
|
||||
if len(rep.queue) == 0 {
|
||||
fmt.Fprintln(w, " (empty)")
|
||||
} else {
|
||||
fmt.Fprintln(w, " KIND\tPRIORITY\tITEMS")
|
||||
for _, s := range rep.queue {
|
||||
fmt.Fprintf(w, " %s\t%s\t%d\n", kindName(s.ItemKind), priorityName(s.Priority), s.Count)
|
||||
}
|
||||
fmt.Fprintf(w, " TOTAL\t\t%d\n", rep.queueTotal())
|
||||
printQueueStats(w, rep.queue, rep.queueTotal(), "ITEMS", " ")
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "\nSources")
|
||||
@ -212,7 +238,8 @@ func formatStatus(rep statusReport) string {
|
||||
for _, a := range rep.absent {
|
||||
fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.Total, a.Stale)
|
||||
}
|
||||
fmt.Fprintf(w, " (rechecked once the last attempt is older than %gh)\n", artwork.StaleAbsentAge.Hours())
|
||||
fmt.Fprintf(w, " (eligible once the last attempt is older than %gh; re-queued %d per kind per hour, oldest first)\n",
|
||||
artwork.StaleAbsentAge.Hours(), artwork.StaleAbsentRecheckBatch)
|
||||
|
||||
fmt.Fprintln(w, "\nBackfill")
|
||||
fmt.Fprintf(w, " State:\t%s\n", backfillState(rep))
|
||||
@ -245,6 +272,15 @@ func backfillState(rep statusReport) string {
|
||||
return "up to date"
|
||||
}
|
||||
|
||||
// printQueueStats writes the shared queue breakdown; the caller owns the tab writer and flushes it.
|
||||
func printQueueStats(w io.Writer, stats []model.ArtworkQueueStat, total int64, countHeader, indent string) {
|
||||
fmt.Fprintf(w, "%sKIND\tPRIORITY\t%s\n", indent, countHeader)
|
||||
for _, s := range stats {
|
||||
fmt.Fprintf(w, "%s%s\t%s\t%d\n", indent, kindName(s.ItemKind), priorityName(s.Priority), s.Count)
|
||||
}
|
||||
fmt.Fprintf(w, "%sTOTAL\t\t%d\n", indent, total)
|
||||
}
|
||||
|
||||
func kindName(prefix string) string {
|
||||
if k, ok := model.ParseKind(prefix); ok {
|
||||
return k.String()
|
||||
@ -252,22 +288,44 @@ func kindName(prefix string) string {
|
||||
return prefix
|
||||
}
|
||||
|
||||
type artworkPriority struct {
|
||||
name string
|
||||
value int
|
||||
}
|
||||
|
||||
// knownPriorities is the one listing behind both the name and the parse, so they cannot drift.
|
||||
var knownPriorities = []artworkPriority{
|
||||
{"bump", model.ArtworkPriorityBump},
|
||||
{"scan", model.ArtworkPriorityScan},
|
||||
{"backfill", model.ArtworkPriorityBackfill},
|
||||
{"recheck", model.ArtworkPriorityRecheck},
|
||||
}
|
||||
|
||||
// priorityName falls back to the number: a row written by a newer version still has to print.
|
||||
func priorityName(p int) string {
|
||||
switch p {
|
||||
case model.ArtworkPriorityRecheck:
|
||||
return "recheck"
|
||||
case model.ArtworkPriorityBackfill:
|
||||
return "backfill"
|
||||
case model.ArtworkPriorityScan:
|
||||
return "scan"
|
||||
case model.ArtworkPriorityBump:
|
||||
return "bump"
|
||||
for _, ap := range knownPriorities {
|
||||
if ap.value == p {
|
||||
return ap.name
|
||||
}
|
||||
}
|
||||
return strconv.Itoa(p)
|
||||
}
|
||||
|
||||
func priorityNames() string {
|
||||
return strings.Join(slice.Map(knownPriorities, func(ap artworkPriority) string { return ap.name }), ", ")
|
||||
}
|
||||
|
||||
func parseArtworkPriority(s string) (int, error) {
|
||||
for _, ap := range knownPriorities {
|
||||
if ap.name == s {
|
||||
return ap.value, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("invalid priority %q, expected one of: %s", s, priorityNames())
|
||||
}
|
||||
|
||||
func runReprocess(ctx context.Context) {
|
||||
kinds, err := selectedKinds(reprocessKinds, reprocessSources, reprocessAll)
|
||||
kinds, err := selectedKinds(artworkKinds, artworkSources, artworkAll)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, err)
|
||||
}
|
||||
@ -281,11 +339,11 @@ func runReprocess(ctx context.Context) {
|
||||
if needsImageAgents(kinds) {
|
||||
mgr := loadPluginAgents(ctx, false)
|
||||
defer func() { _ = mgr.Stop() }()
|
||||
imageAgents = imageAgentCount(ds, mgr)
|
||||
imageAgents = artwork.NewImageAgentCount(agents.GetAgents(ds, mgr))
|
||||
}
|
||||
|
||||
if err := reprocessArtwork(ctx, ds, kinds, repositorySources(reprocessSources), imageAgents,
|
||||
reprocessDryRun, reprocessConfirm(reprocessYes, os.Stdin), os.Stdout); err != nil {
|
||||
if err := reprocessArtwork(ctx, ds, kinds, repositorySources(artworkSources), imageAgents,
|
||||
artworkDryRun, confirmUnlessYes(artworkYes, os.Stdin, "re-resolve"), os.Stdout); err != nil {
|
||||
log.Fatal(ctx, err)
|
||||
}
|
||||
}
|
||||
@ -298,16 +356,9 @@ func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) {
|
||||
if len(kinds) == 0 {
|
||||
return nil, fmt.Errorf("no selector given: pass --kind, --source or --all")
|
||||
}
|
||||
out := make([]model.Kind, 0, len(kinds))
|
||||
for _, k := range kinds {
|
||||
kind, err := parseArtworkKind(k, artwork.RecheckKinds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, kind)
|
||||
}
|
||||
// A repeated kind would be counted twice, overstating the cost the operator confirms.
|
||||
return slice.Unique(out), nil
|
||||
return parseAll(kinds, func(s string) (model.Kind, error) {
|
||||
return parseArtworkKind(s, artwork.RecheckKinds)
|
||||
})
|
||||
}
|
||||
|
||||
// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI.
|
||||
@ -326,11 +377,11 @@ func displaySource(s string) string { return cmp.Or(s, absentSource) }
|
||||
|
||||
type confirmFunc func(out io.Writer, total, external int64) bool
|
||||
|
||||
func reprocessConfirm(yes bool, in io.Reader) confirmFunc {
|
||||
func confirmUnlessYes(yes bool, in io.Reader, verb string) confirmFunc {
|
||||
if yes {
|
||||
return func(io.Writer, int64, int64) bool { return true }
|
||||
}
|
||||
return promptConfirm(in)
|
||||
return promptConfirm(in, verb)
|
||||
}
|
||||
|
||||
// externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and the
|
||||
@ -346,11 +397,6 @@ func externalLookupLine(n int64) string {
|
||||
return fmt.Sprintf("External lookups: %s.", externalEstimate(n))
|
||||
}
|
||||
|
||||
func imageAgentCount(ds model.DataStore, mgr *plugins.Manager) artwork.ImageAgentCount {
|
||||
ag := agents.GetAgents(ds, mgr)
|
||||
return artwork.ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())}
|
||||
}
|
||||
|
||||
// loadPluginAgents loads the plugins named in Agents, so the CLI resolves through the same agents a
|
||||
// running server would. A load failure is reported, not fatal: the built-in agents still answer.
|
||||
func loadPluginAgents(ctx context.Context, runInit bool) *plugins.Manager {
|
||||
@ -378,13 +424,13 @@ func configuredAgents() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func promptConfirm(in io.Reader) confirmFunc {
|
||||
func promptConfirm(in io.Reader, verb string) confirmFunc {
|
||||
return func(out io.Writer, total, external int64) bool {
|
||||
var cost string
|
||||
if external > 0 {
|
||||
cost = fmt.Sprintf(" %s", externalLookupLine(external))
|
||||
}
|
||||
fmt.Fprintf(out, "\nThis will re-resolve %d items.%s Continue? [y/N] ", total, cost)
|
||||
fmt.Fprintf(out, "\nThis will %s %d items.%s Continue? [y/N] ", verb, total, cost)
|
||||
var answer string
|
||||
if _, err := fmt.Fscanln(in, &answer); err != nil {
|
||||
return false
|
||||
@ -476,6 +522,90 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCancel(ctx context.Context) {
|
||||
kinds, priorities, err := cancelSelection(artworkKinds, artworkPriorities, artworkAll)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, err)
|
||||
}
|
||||
|
||||
defer db.Init(ctx)()
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
if err := cancelArtwork(ctx, ds, kinds, priorities, artworkDryRun,
|
||||
confirmUnlessYes(artworkYes, os.Stdin, "cancel"), os.Stdout); err != nil {
|
||||
log.Fatal(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelSelection leaves --all as the empty filter the repository reads as "every one", so a row
|
||||
// whose kind this build does not know still gets cancelled.
|
||||
func cancelSelection(kinds, priorities []string, all bool) ([]model.Kind, []int, error) {
|
||||
if all {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if len(kinds) == 0 && len(priorities) == 0 {
|
||||
return nil, nil, fmt.Errorf("no selector given: pass --kind, --priority or --all")
|
||||
}
|
||||
// RefreshableKinds, not RecheckKinds: media files are queued, so --kind must reach them.
|
||||
outKinds, err := parseAll(kinds, func(s string) (model.Kind, error) {
|
||||
return parseArtworkKind(s, artwork.RefreshableKinds)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
outPriorities, err := parseAll(priorities, parseArtworkPriority)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return outKinds, outPriorities, nil
|
||||
}
|
||||
|
||||
// parseAll drops repeats: a doubled selector would overstate the total the operator confirms.
|
||||
func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T, error) {
|
||||
out := make([]T, 0, len(values))
|
||||
for _, v := range values {
|
||||
parsed, err := parse(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, parsed)
|
||||
}
|
||||
return slice.Unique(out), nil
|
||||
}
|
||||
|
||||
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
|
||||
dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||
q := ds.ArtworkQueue(ctx)
|
||||
matched, err := q.CountQueued(kinds, priorities)
|
||||
if err != nil {
|
||||
return fmt.Errorf("counting queued artwork: %w", err)
|
||||
}
|
||||
total := queueTotal(matched)
|
||||
w := newTabWriter(out)
|
||||
printQueueStats(w, matched, total, "MATCHED", "")
|
||||
w.Flush()
|
||||
|
||||
switch {
|
||||
case total == 0:
|
||||
fmt.Fprintln(out, "\nNothing matches this selection.")
|
||||
return nil
|
||||
case dryRun:
|
||||
fmt.Fprintln(out, "\nDry run: nothing was cancelled.")
|
||||
return nil
|
||||
case !confirm(out, total, 0):
|
||||
fmt.Fprintln(out, "Aborted: nothing was cancelled.")
|
||||
return nil
|
||||
}
|
||||
|
||||
cancelled, err := q.PurgeQueued(kinds, priorities)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cancelling queued artwork: %w", err)
|
||||
}
|
||||
// Count and delete are separate statements, so a drain in between makes these two differ.
|
||||
fmt.Fprintf(out, "Cancelled %d of %d matched items.\n", cancelled, total)
|
||||
return nil
|
||||
}
|
||||
|
||||
// printReprocessPreview also states the external estimate, which --dry-run must show because it
|
||||
// skips the prompt that would otherwise carry it.
|
||||
func printReprocessPreview(out io.Writer, kinds []model.Kind, matched []int64, total, external int64, sources []string) {
|
||||
@ -541,7 +671,7 @@ var explainKinds = []model.Kind{
|
||||
}
|
||||
|
||||
func kindPrefixes(kinds []model.Kind) string {
|
||||
return strings.Join(slice.Map(kinds, func(k model.Kind) string { return k.Prefix() }), ", ")
|
||||
return strings.Join(model.KindPrefixes(kinds), ", ")
|
||||
}
|
||||
|
||||
func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) {
|
||||
@ -640,10 +770,6 @@ func explainResult(source string, steps []artwork.TraceStep) string {
|
||||
if s.Outcome == artwork.OutcomeHit {
|
||||
break
|
||||
}
|
||||
if s.Outcome == artwork.OutcomeWouldTry {
|
||||
return "resolved from " + source +
|
||||
" (offline: a higher-priority external candidate was not tried; re-run with --live)"
|
||||
}
|
||||
// An external winner discards the earlier error, so the resolver settles it with no retry.
|
||||
if s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix) &&
|
||||
!strings.HasPrefix(source, artwork.ExternalPrefix) {
|
||||
@ -655,14 +781,12 @@ func explainResult(source string, steps []artwork.TraceStep) string {
|
||||
}
|
||||
for _, s := range steps {
|
||||
switch {
|
||||
case s.Outcome == artwork.OutcomeWouldTry:
|
||||
return "indeterminate (external agents not called; re-run with --live)"
|
||||
case s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix):
|
||||
return "indeterminate (an external lookup failed; the item may resolve on a later attempt)"
|
||||
// The worker treats an unreadable local candidate exactly as it treats a failed external one:
|
||||
// it retries instead of settling absent, so the verdict must not read as a clean miss.
|
||||
case s.Outcome == artwork.OutcomeUnreadable:
|
||||
return "indeterminate (a candidate exists but could not be read; the worker retries rather than settling absent)"
|
||||
// A stage error or an unreadable candidate means a source was found but not processed; the
|
||||
// worker retries rather than settling absent, so neither reads as a clean miss.
|
||||
case s.Outcome == artwork.OutcomeError, s.Outcome == artwork.OutcomeUnreadable:
|
||||
return "indeterminate (a candidate was found but could not be processed; the worker retries rather than settling absent)"
|
||||
}
|
||||
}
|
||||
return "not resolved"
|
||||
@ -684,22 +808,55 @@ func explainConfig(kind model.Kind) (name, value string) {
|
||||
}
|
||||
|
||||
type explainReport struct {
|
||||
kind model.Kind
|
||||
id string
|
||||
name string
|
||||
stored *model.ItemArtwork
|
||||
queued *model.ArtworkQueueItem
|
||||
agents string
|
||||
kind model.Kind
|
||||
id string
|
||||
name string
|
||||
stored *model.ItemArtwork
|
||||
queued *model.ArtworkQueueItem
|
||||
agents string
|
||||
// steps is the chain walk: recorded when the item was resolved, or performed just now when walked.
|
||||
steps []artwork.TraceStep
|
||||
source string
|
||||
walked bool
|
||||
resolveErr error
|
||||
}
|
||||
|
||||
// explainChainOrigin says whether the operator is reading history or a walk performed just now,
|
||||
// since the two can disagree after a config change.
|
||||
func explainChainOrigin(rep explainReport) string {
|
||||
if rep.walked {
|
||||
return "walked now"
|
||||
}
|
||||
if rep.stored != nil {
|
||||
return "recorded " + formatTime(rep.stored.AttemptedAt)
|
||||
}
|
||||
return "not recorded"
|
||||
}
|
||||
|
||||
// writeSteps prints the trace rows. An empty last cell would end tabwriter's column block and
|
||||
// break the alignment, so a missing detail is rendered as a dash.
|
||||
func writeSteps(w io.Writer, indent string, steps []artwork.TraceStep) {
|
||||
for _, s := range steps {
|
||||
fmt.Fprintf(w, "%s%s\t%s\t%s\n", indent, s.Candidate, s.Outcome, cmp.Or(s.Detail, "-"))
|
||||
}
|
||||
}
|
||||
|
||||
// writeStepTable prints a secondary trace, and nothing at all when there is none to show.
|
||||
func writeStepTable(w io.Writer, title string, steps []artwork.TraceStep) {
|
||||
if len(steps) == 0 {
|
||||
return
|
||||
}
|
||||
// No tab on the title: it closes the preceding column block, so these rows align among themselves.
|
||||
fmt.Fprintf(w, " %s:\n", title)
|
||||
writeSteps(w, " ", steps)
|
||||
}
|
||||
|
||||
func formatExplain(rep explainReport) string {
|
||||
var sb strings.Builder
|
||||
w := newTabWriter(&sb)
|
||||
explainable := artwork.Explainable(rep.kind)
|
||||
stateful := artwork.KeepsState(rep.kind)
|
||||
unrecorded := !rep.walked && rep.stored == nil
|
||||
|
||||
fmt.Fprintln(w, "Item")
|
||||
fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.kind, rep.kind.Prefix())
|
||||
@ -732,6 +889,12 @@ func formatExplain(rep explainReport) string {
|
||||
fmt.Fprintf(w, " Attempts:\t%d\n", rep.queued.Attempts)
|
||||
fmt.Fprintf(w, " Retry at:\t%s\n", formatTime(rep.queued.RetryAt))
|
||||
}
|
||||
if rep.queued != nil {
|
||||
writeStepTable(w, "Last attempt failed", artwork.DecodeTrace(rep.queued.Trace, ""))
|
||||
}
|
||||
if rep.stored != nil {
|
||||
writeStepTable(w, "Gave up after", artwork.DecodeTrace(rep.stored.LastFailure, ""))
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "\nConfig")
|
||||
if setting, value := explainConfig(rep.kind); setting == "" {
|
||||
@ -743,15 +906,22 @@ func formatExplain(rep explainReport) string {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "\nChain")
|
||||
if !explainable {
|
||||
fmt.Fprintf(w, "\nChain (%s)\n", explainChainOrigin(rep))
|
||||
switch {
|
||||
case !explainable:
|
||||
fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.kind)
|
||||
} else {
|
||||
case unrecorded:
|
||||
fmt.Fprintln(w, " (no resolution recorded yet; re-run with --live to walk the chain now)")
|
||||
case !rep.walked && len(rep.steps) == 0 && rep.stored.Hash != "":
|
||||
// A stored image with no chain can only predate trace recording: a recorded resolution that
|
||||
// found an image always records its winning candidate.
|
||||
fmt.Fprintln(w, " (this item was resolved before traces were recorded; re-run with --live)")
|
||||
case !rep.walked && len(rep.steps) == 0:
|
||||
// Absent with no chain: an empty priority list walked nothing, or a pre-tracing absent row.
|
||||
fmt.Fprintln(w, " (no candidates were recorded; re-run with --live to walk the chain now)")
|
||||
default:
|
||||
fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL")
|
||||
for _, s := range rep.steps {
|
||||
// A row with an empty last cell would end tabwriter's column block, breaking alignment.
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\n", s.Candidate, s.Outcome, cmp.Or(s.Detail, "-"))
|
||||
}
|
||||
writeSteps(w, " ", rep.steps)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "\nResult")
|
||||
@ -760,6 +930,8 @@ func formatExplain(rep explainReport) string {
|
||||
fmt.Fprintf(w, " resolution failed: %s\n", rep.resolveErr)
|
||||
case !explainable:
|
||||
fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)")
|
||||
case unrecorded:
|
||||
fmt.Fprintln(w, " not evaluated (nothing recorded; re-run with --live to walk the chain now)")
|
||||
default:
|
||||
fmt.Fprintf(w, " %s\n", explainResult(rep.source, rep.steps))
|
||||
}
|
||||
@ -807,6 +979,8 @@ func runExplain(ctx context.Context, args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now.
|
||||
rep.walked = explainLive || !artwork.KeepsState(kind)
|
||||
if artwork.Explainable(kind) {
|
||||
// Only artist and album reach an agent, and the load must precede the resolver, which reads
|
||||
// the same manager.
|
||||
@ -815,11 +989,16 @@ func runExplain(ctx context.Context, args []string) {
|
||||
defer func() { _ = mgr.Stop() }()
|
||||
rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, mgr, kind))
|
||||
}
|
||||
trace := &artwork.ChainTrace{}
|
||||
rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id)
|
||||
rep.steps = trace.Steps()
|
||||
switch {
|
||||
case rep.walked:
|
||||
trace := &artwork.ChainTrace{}
|
||||
rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id)
|
||||
rep.steps = trace.Steps()
|
||||
case rep.stored != nil:
|
||||
rep.steps = artwork.DecodeTrace(rep.stored.Trace, rep.stored.SourcePath)
|
||||
rep.source = rep.stored.Source
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Print(formatExplain(rep))
|
||||
// The steps taken before a failed walk are the diagnosis, so report them before exiting.
|
||||
if rep.resolveErr != nil {
|
||||
|
||||
@ -3,6 +3,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
@ -145,6 +146,15 @@ var _ = Describe("explainResult", func() {
|
||||
"the worker retries an unreadable candidate instead of settling absent, so this is not a clean miss")
|
||||
})
|
||||
|
||||
It("reports indeterminate when a processing stage errored after a candidate was found", func() {
|
||||
steps := []artwork.TraceStep{
|
||||
{Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"},
|
||||
{Candidate: "store", Outcome: "error", Detail: "disk full"},
|
||||
}
|
||||
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
|
||||
"a stage error is a processing failure the worker retries, not a definitive miss")
|
||||
})
|
||||
|
||||
It("does not qualify a hit that an earlier unreadable candidate preceded", func() {
|
||||
// chainState.try stamps only the external error onto a hit and drops the local one, so the
|
||||
// worker settles this as found; warning about it would be a false alarm.
|
||||
@ -164,34 +174,6 @@ var _ = Describe("explainResult", func() {
|
||||
"a failed network call is not evidence that the item has no artwork")
|
||||
})
|
||||
|
||||
It("qualifies a win a skipped higher-priority external candidate could have taken", func() {
|
||||
steps := []artwork.TraceStep{
|
||||
{Candidate: "external:deezer", Outcome: "would-try"},
|
||||
{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"},
|
||||
}
|
||||
res := explainResult("artist.*", steps)
|
||||
Expect(res).To(ContainSubstring("resolved from artist.*"))
|
||||
Expect(res).To(ContainSubstring("--live"),
|
||||
"offline, the winner is only the winner because the external tier was skipped")
|
||||
})
|
||||
|
||||
It("does not qualify a win that no skipped candidate outranked", func() {
|
||||
steps := []artwork.TraceStep{
|
||||
{Candidate: "artist.*", Outcome: "hit"},
|
||||
{Candidate: "external:deezer", Outcome: "would-try"},
|
||||
}
|
||||
Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*"))
|
||||
})
|
||||
|
||||
It("reports indeterminate when external agents were never called", func() {
|
||||
steps := []artwork.TraceStep{
|
||||
{Candidate: "artist.*", Outcome: "miss"},
|
||||
{Candidate: "external:deezer", Outcome: "would-try"},
|
||||
}
|
||||
Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"),
|
||||
"an offline run must not claim an item is unresolvable when external agents were skipped")
|
||||
})
|
||||
|
||||
It("qualifies a win a failed higher-priority external lookup could have taken", func() {
|
||||
steps := []artwork.TraceStep{
|
||||
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
|
||||
@ -252,9 +234,10 @@ var _ = Describe("formatExplain", func() {
|
||||
id: "ar-1",
|
||||
name: "Radiohead",
|
||||
agents: "lastfm,spotify",
|
||||
walked: true,
|
||||
steps: []artwork.TraceStep{
|
||||
{Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"},
|
||||
{Candidate: "external:deezer", Outcome: "would-try"},
|
||||
{Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"},
|
||||
},
|
||||
source: "",
|
||||
}
|
||||
@ -267,7 +250,6 @@ var _ = Describe("formatExplain", func() {
|
||||
Expect(out).To(ContainSubstring("ArtistArtPriority"))
|
||||
Expect(out).To(ContainSubstring("lastfm,spotify"))
|
||||
Expect(out).To(ContainSubstring("external:deezer"))
|
||||
Expect(out).To(ContainSubstring("would-try"))
|
||||
Expect(out).To(ContainSubstring("indeterminate"))
|
||||
})
|
||||
|
||||
@ -304,7 +286,7 @@ var _ = Describe("formatExplain", func() {
|
||||
out := formatExplain(rep)
|
||||
Expect(out).To(ContainSubstring("resolution failed: no such directory"))
|
||||
Expect(out).ToNot(ContainSubstring("indeterminate"))
|
||||
Expect(out).To(ContainSubstring("would-try"), "the steps taken before the failure still print")
|
||||
Expect(out).To(ContainSubstring("external:deezer"), "the steps taken before the failure still print")
|
||||
})
|
||||
|
||||
It("says a kind that does not walk a chain has no chain, without an empty table", func() {
|
||||
@ -329,6 +311,7 @@ var _ = Describe("formatExplain", func() {
|
||||
kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)",
|
||||
steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}},
|
||||
source: "folder",
|
||||
walked: true,
|
||||
}
|
||||
|
||||
out := formatExplain(rep)
|
||||
@ -341,10 +324,74 @@ var _ = Describe("formatExplain", func() {
|
||||
Expect(out).To(ContainSubstring("resolved from folder"))
|
||||
})
|
||||
|
||||
Context("stored traces", func() {
|
||||
BeforeEach(func() {
|
||||
rep.walked = false
|
||||
rep.steps = nil
|
||||
})
|
||||
|
||||
It("labels a recorded chain with when it was recorded, not as a walk done now", func() {
|
||||
attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
|
||||
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted}
|
||||
rep.steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}}
|
||||
rep.source = "folder"
|
||||
|
||||
out := formatExplain(rep)
|
||||
Expect(out).To(ContainSubstring("Chain (recorded 2026-08-13T10:00:00Z)"))
|
||||
Expect(out).To(ContainSubstring("/music/artist.jpg"))
|
||||
Expect(out).To(ContainSubstring("resolved from folder"))
|
||||
})
|
||||
|
||||
It("says so when the item has never been resolved", func() {
|
||||
out := formatExplain(rep)
|
||||
Expect(out).To(ContainSubstring("no resolution recorded yet"))
|
||||
Expect(out).To(ContainSubstring("--live"))
|
||||
Expect(out).ToNot(ContainSubstring("not resolved"),
|
||||
"nothing was recorded, which is not the same as resolving to nothing")
|
||||
})
|
||||
|
||||
It("distinguishes a row written before traces existed from one with an empty chain", func() {
|
||||
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now()}
|
||||
|
||||
Expect(formatExplain(rep)).To(ContainSubstring("resolved before traces were recorded"))
|
||||
})
|
||||
|
||||
It("does not call an absent row with an empty recorded chain a pre-tracing row", func() {
|
||||
// An empty priority list records a real but empty chain and resolves absent; that is not a
|
||||
// legacy row, so it must not be reported as resolved before tracing existed.
|
||||
rep.stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()}
|
||||
|
||||
out := formatExplain(rep)
|
||||
Expect(out).ToNot(ContainSubstring("resolved before traces were recorded"))
|
||||
Expect(out).To(ContainSubstring("no candidates were recorded"))
|
||||
Expect(out).To(ContainSubstring("not resolved"), "the Result still reports the absence plainly")
|
||||
})
|
||||
|
||||
It("prints why the last attempt failed and why it gave up", func() {
|
||||
rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 3,
|
||||
Trace: `[{"c":"decode","o":"error","d":"bad header"}]`}
|
||||
rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now(),
|
||||
LastFailure: `[{"c":"read","o":"error","d":"i/o timeout"}]`}
|
||||
|
||||
out := formatExplain(rep)
|
||||
Expect(out).To(ContainSubstring("Last attempt failed"))
|
||||
Expect(out).To(ContainSubstring("bad header"))
|
||||
Expect(out).To(ContainSubstring("Gave up after"))
|
||||
Expect(out).To(ContainSubstring("i/o timeout"))
|
||||
})
|
||||
|
||||
It("omits the failure tables when there is no failure to report", func() {
|
||||
out := formatExplain(rep)
|
||||
Expect(out).ToNot(ContainSubstring("Last attempt failed"))
|
||||
Expect(out).ToNot(ContainSubstring("Gave up after"))
|
||||
})
|
||||
})
|
||||
|
||||
It("reports the setting that governs media file artwork", func() {
|
||||
conf.Server.EnableMediaFileCoverArt = false
|
||||
rep = explainReport{
|
||||
kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag",
|
||||
walked: true,
|
||||
steps: []artwork.TraceStep{
|
||||
{Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"},
|
||||
},
|
||||
@ -503,36 +550,36 @@ var _ = Describe("promptConfirm", func() {
|
||||
BeforeEach(func() { out.Reset() })
|
||||
|
||||
It("states the external cost and accepts an explicit yes", func() {
|
||||
Expect(promptConfirm(strings.NewReader("y\n"))(&out, 42, 7)).To(BeTrue())
|
||||
Expect(promptConfirm(strings.NewReader("y\n"), "re-resolve")(&out, 42, 7)).To(BeTrue())
|
||||
Expect(out.String()).To(ContainSubstring("re-resolve 42 items"))
|
||||
Expect(out.String()).To(ContainSubstring("External lookups: ~7 estimated"))
|
||||
})
|
||||
|
||||
It("defaults to no on anything else", func() {
|
||||
Expect(promptConfirm(strings.NewReader("\n"))(&out, 1, 1)).To(BeFalse())
|
||||
Expect(promptConfirm(strings.NewReader("nope\n"))(&out, 1, 1)).To(BeFalse())
|
||||
Expect(promptConfirm(strings.NewReader(""))(&out, 1, 1)).To(BeFalse())
|
||||
Expect(promptConfirm(strings.NewReader("\n"), "re-resolve")(&out, 1, 1)).To(BeFalse())
|
||||
Expect(promptConfirm(strings.NewReader("nope\n"), "re-resolve")(&out, 1, 1)).To(BeFalse())
|
||||
Expect(promptConfirm(strings.NewReader(""), "re-resolve")(&out, 1, 1)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("drops the external clause when no lookup will be made", func() {
|
||||
Expect(promptConfirm(strings.NewReader("y\n"))(&out, 3, 0)).To(BeTrue())
|
||||
Expect(out.String()).To(ContainSubstring("re-resolve 3 items."))
|
||||
Expect(promptConfirm(strings.NewReader("y\n"), "cancel")(&out, 3, 0)).To(BeTrue())
|
||||
Expect(out.String()).To(ContainSubstring("cancel 3 items."))
|
||||
Expect(out.String()).ToNot(ContainSubstring("External lookups"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("reprocessConfirm", func() {
|
||||
var _ = Describe("confirmUnlessYes", func() {
|
||||
var out strings.Builder
|
||||
|
||||
BeforeEach(func() { out.Reset() })
|
||||
|
||||
It("prompts when --yes was not given", func() {
|
||||
Expect(reprocessConfirm(false, strings.NewReader("n\n"))(&out, 5, 5)).To(BeFalse())
|
||||
Expect(confirmUnlessYes(false, strings.NewReader("n\n"), "re-resolve")(&out, 5, 5)).To(BeFalse())
|
||||
Expect(out.String()).To(ContainSubstring("Continue?"))
|
||||
})
|
||||
|
||||
It("bypasses the prompt only for --yes", func() {
|
||||
Expect(reprocessConfirm(true, strings.NewReader(""))(&out, 5, 5)).To(BeTrue())
|
||||
Expect(confirmUnlessYes(true, strings.NewReader(""), "re-resolve")(&out, 5, 5)).To(BeTrue())
|
||||
Expect(out.String()).To(BeEmpty(), "--yes must not print a prompt it never reads")
|
||||
})
|
||||
})
|
||||
@ -772,7 +819,7 @@ var _ = Describe("collectStatus", func() {
|
||||
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
|
||||
}
|
||||
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
|
||||
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-48*time.Hour))
|
||||
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-artwork.StaleAbsentAge-time.Hour))
|
||||
put(model.KindArtistArtwork, "ar-3", "", "", time.Now())
|
||||
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
|
||||
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
|
||||
@ -863,8 +910,9 @@ var _ = Describe("formatStatus", func() {
|
||||
Expect(absent).To(MatchRegexp(`artist\s+2\s+1`))
|
||||
})
|
||||
|
||||
It("states the recheck window the absent counts are bucketed against", func() {
|
||||
Expect(formatStatus(rep)).To(ContainSubstring("24h"))
|
||||
It("states the recheck window and the drip rate the absent counts are bucketed against", func() {
|
||||
Expect(formatStatus(rep)).To(ContainSubstring(fmt.Sprintf("%gh", artwork.StaleAbsentAge.Hours())))
|
||||
Expect(formatStatus(rep)).To(ContainSubstring("100 per kind per hour"))
|
||||
})
|
||||
|
||||
It("leads with the queued backlog, which is the finding, not with the fingerprint verdict", func() {
|
||||
@ -1013,3 +1061,153 @@ var _ = Describe("configuredAgents", func() {
|
||||
Expect(configuredAgents()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("parseArtworkPriority", func() {
|
||||
It("accepts every name status prints", func() {
|
||||
for _, p := range []int{model.ArtworkPriorityRecheck, model.ArtworkPriorityBackfill,
|
||||
model.ArtworkPriorityScan, model.ArtworkPriorityBump} {
|
||||
Expect(parseArtworkPriority(priorityName(p))).To(Equal(p))
|
||||
}
|
||||
})
|
||||
|
||||
It("rejects an unknown name and lists the valid ones", func() {
|
||||
_, err := parseArtworkPriority("urgent")
|
||||
Expect(err).To(MatchError(ContainSubstring(`invalid priority "urgent"`)))
|
||||
Expect(err).To(MatchError(ContainSubstring("backfill")))
|
||||
})
|
||||
|
||||
// Accepting the raw numbers would make the help text a lie and let a typo like 11 select nothing.
|
||||
It("rejects the numeric form", func() {
|
||||
_, err := parseArtworkPriority("10")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("artwork cancel selection", func() {
|
||||
It("errors when no selector is given", func() {
|
||||
_, _, err := cancelSelection(nil, nil, false)
|
||||
Expect(err).To(MatchError(ContainSubstring("no selector given")))
|
||||
})
|
||||
|
||||
// Empty, not an enumeration of the known kinds: --all must also take a queue row whose kind
|
||||
// this build does not recognise.
|
||||
It("selects with no filter at all for --all", func() {
|
||||
kinds, priorities, err := cancelSelection(nil, nil, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(kinds).To(BeEmpty())
|
||||
Expect(priorities).To(BeEmpty())
|
||||
})
|
||||
|
||||
// The queue holds media file rows, so --all must reach them.
|
||||
It("accepts media file artwork, which reprocess does not", func() {
|
||||
kinds, _, err := cancelSelection([]string{"mf"}, nil, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(kinds).To(Equal([]model.Kind{model.KindMediaFileArtwork}))
|
||||
})
|
||||
|
||||
It("treats a priority filter on its own as a complete selection", func() {
|
||||
kinds, priorities, err := cancelSelection(nil, []string{"backfill"}, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(kinds).To(BeEmpty(), "no kind filter means every kind")
|
||||
Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill}))
|
||||
})
|
||||
|
||||
It("returns only the named kinds and priorities", func() {
|
||||
kinds, priorities, err := cancelSelection([]string{"ar", "al"}, []string{"backfill", "scan"}, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(kinds).To(Equal([]model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork}))
|
||||
Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan}))
|
||||
})
|
||||
|
||||
It("counts a repeated kind and a repeated priority once", func() {
|
||||
kinds, priorities, err := cancelSelection([]string{"ar", "ar"}, []string{"bump", "bump"}, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(kinds).To(HaveLen(1))
|
||||
Expect(priorities).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("rejects an unknown kind", func() {
|
||||
_, _, err := cancelSelection([]string{"zz"}, nil, false)
|
||||
Expect(err).To(MatchError(ContainSubstring(`invalid kind "zz"`)))
|
||||
})
|
||||
|
||||
It("rejects a kind that is never queued", func() {
|
||||
_, _, err := cancelSelection([]string{"dc"}, nil, false)
|
||||
Expect(err).To(MatchError(ContainSubstring("invalid kind")))
|
||||
})
|
||||
|
||||
It("rejects an unknown priority", func() {
|
||||
_, _, err := cancelSelection(nil, []string{"urgent"}, false)
|
||||
Expect(err).To(MatchError(ContainSubstring("invalid priority")))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("cancelArtwork", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var queue *tests.MockArtworkQueueRepo
|
||||
var out strings.Builder
|
||||
ctx := context.Background()
|
||||
accept := func(io.Writer, int64, int64) bool { return true }
|
||||
decline := func(io.Writer, int64, int64) bool { return false }
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||
out.Reset()
|
||||
Expect(queue.Enqueue(
|
||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBackfill},
|
||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBump},
|
||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-1", ImageType: model.ImageTypePrimary,
|
||||
Priority: model.ArtworkPriorityBackfill},
|
||||
)).To(Succeed())
|
||||
})
|
||||
|
||||
It("previews the per-kind breakdown and cancels nothing on a dry run", func() {
|
||||
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, nil, true, accept, &out)).To(Succeed())
|
||||
|
||||
Expect(out.String()).To(ContainSubstring("artist"))
|
||||
Expect(out.String()).To(ContainSubstring("backfill"))
|
||||
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
||||
Expect(out.String()).To(ContainSubstring("Dry run"))
|
||||
Expect(queue.Count()).To(BeNumerically("==", 3))
|
||||
})
|
||||
|
||||
It("cancels nothing when the operator declines", func() {
|
||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
|
||||
|
||||
Expect(out.String()).To(ContainSubstring("Aborted"))
|
||||
Expect(queue.Count()).To(BeNumerically("==", 3))
|
||||
})
|
||||
|
||||
It("deletes the selected rows and leaves the rest queued", func() {
|
||||
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
|
||||
|
||||
Expect(queue.Count()).To(BeNumerically("==", 1))
|
||||
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
|
||||
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
|
||||
})
|
||||
|
||||
It("cancels every kind and priority when neither filter is given", func() {
|
||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
|
||||
Expect(queue.Count()).To(BeZero())
|
||||
})
|
||||
|
||||
It("stops at a selection that matches nothing instead of prompting", func() {
|
||||
refuse := func(io.Writer, int64, int64) bool {
|
||||
Fail("must not prompt when nothing matches")
|
||||
return false
|
||||
}
|
||||
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
|
||||
|
||||
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
|
||||
Expect(queue.Count()).To(BeNumerically("==", 3))
|
||||
})
|
||||
|
||||
It("reports a queue read failure instead of reporting nothing to cancel", func() {
|
||||
queue.Err = errors.New("read failed")
|
||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(MatchError(ContainSubstring("read failed")))
|
||||
})
|
||||
})
|
||||
|
||||
17
cmd/pls.go
17
cmd/pls.go
@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@ -141,14 +142,16 @@ func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *mod
|
||||
func runExporter(ctx context.Context) {
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
playlist := findPlaylist(ctx, ds, playlistID)
|
||||
pls := playlist.ToM3U8()
|
||||
if outputFile == "-" || outputFile == "" {
|
||||
println(pls)
|
||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
||||
}
|
||||
|
||||
func writePlaylist(m3u string, out io.Writer, file string) {
|
||||
if file == "" || file == "-" {
|
||||
fmt.Fprint(out, m3u)
|
||||
return
|
||||
}
|
||||
err := os.WriteFile(outputFile, []byte(pls), 0600)
|
||||
if err != nil {
|
||||
log.Fatal("Error writing to the output file", "file", outputFile, err)
|
||||
if err := os.WriteFile(file, []byte(m3u), 0600); err != nil {
|
||||
log.Fatal("Error writing to the output file", "file", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,7 +160,7 @@ func runExport(ctx context.Context) {
|
||||
|
||||
if playlistID != "" && outputFile == "" {
|
||||
playlist := findPlaylist(ctx, ds, playlistID)
|
||||
println(playlist.ToM3U8())
|
||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
35
cmd/pls_test.go
Normal file
35
cmd/pls_test.go
Normal file
@ -0,0 +1,35 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("writePlaylist", func() {
|
||||
const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n"
|
||||
plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid()))
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(func() { _ = os.Remove(plsFile) })
|
||||
})
|
||||
|
||||
DescribeTable("writes the playlist to exactly one destination",
|
||||
func(file, wantStream, wantFile string) {
|
||||
var out strings.Builder
|
||||
|
||||
writePlaylist(m3u, &out, file)
|
||||
|
||||
written, _ := os.ReadFile(plsFile)
|
||||
Expect(out.String()).To(Equal(wantStream))
|
||||
Expect(string(written)).To(Equal(wantFile))
|
||||
},
|
||||
Entry("no file name writes to the stream", "", m3u, ""),
|
||||
Entry("a dash writes to the stream", "-", m3u, ""),
|
||||
Entry("a path writes to the file", plsFile, "", m3u),
|
||||
)
|
||||
})
|
||||
@ -73,6 +73,7 @@ type configOptions struct {
|
||||
Matcher matcherOptions `json:",omitzero"`
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
EnableNaturalSorting bool
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
FFmpegPath string
|
||||
@ -973,6 +974,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("matcher.fuzzythreshold", 85)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("prefersorttags", false)
|
||||
viper.SetDefault("enablenaturalsorting", false)
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
|
||||
@ -58,7 +58,7 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
|
||||
return nil, "", false
|
||||
}
|
||||
for _, a := range imageAgents {
|
||||
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
@ -69,6 +69,7 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar
|
||||
}
|
||||
return fromURL(ctx, u)
|
||||
})
|
||||
recordAgent(ctx, a.Name, reader, path, err)
|
||||
if reader != nil {
|
||||
return reader, a.Name, false
|
||||
}
|
||||
@ -90,7 +91,7 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m
|
||||
return nil, "", false
|
||||
}
|
||||
for _, a := range imageAgents {
|
||||
reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) {
|
||||
imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
@ -101,6 +102,7 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m
|
||||
}
|
||||
return fromURL(ctx, u)
|
||||
})
|
||||
recordAgent(ctx, a.Name, reader, path, err)
|
||||
if reader != nil {
|
||||
return reader, a.Name, false
|
||||
}
|
||||
|
||||
@ -393,16 +393,15 @@ type TracingResolver struct {
|
||||
trace *ChainTrace
|
||||
}
|
||||
|
||||
// NewTracingResolver builds a TracingResolver that records its priority-chain walk. With live
|
||||
// false the external tier is reported but never called.
|
||||
// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live
|
||||
// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider;
|
||||
// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed.
|
||||
func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver {
|
||||
gate := offlineGate(t)
|
||||
inner := newLocalResolver(ds, ffm)
|
||||
if live {
|
||||
// A diagnostic must show the provider's real answer, and one item is at most one call
|
||||
// per agent, so --live deliberately bypasses the rate limiter and circuit breaker.
|
||||
gate = tracingGate(t, passthroughGate)
|
||||
inner = newResolver(ds, ag, ffm, passthroughGate)
|
||||
}
|
||||
return &TracingResolver{inner: newResolver(ds, ag, ffm, gate), trace: t}
|
||||
return &TracingResolver{inner: inner, trace: t}
|
||||
}
|
||||
|
||||
// Resolve walks kind's sources for id, recording the walk, and reports the winning source
|
||||
|
||||
@ -18,7 +18,11 @@ import (
|
||||
)
|
||||
|
||||
// StaleAbsentAge is how long an absent state is trusted before a recheck retries it.
|
||||
const StaleAbsentAge = 24 * time.Hour
|
||||
const StaleAbsentAge = 30 * 24 * time.Hour
|
||||
|
||||
// StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind,
|
||||
// oldest first, so external agents see a flat drip instead of a daily burst.
|
||||
const StaleAbsentRecheckBatch = 100
|
||||
|
||||
// RecheckKinds omits media files: they resolve embedded-only, at scan or on view.
|
||||
var RecheckKinds = []model.Kind{
|
||||
@ -68,18 +72,27 @@ func ConfigFingerprint() string {
|
||||
return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw)))
|
||||
}
|
||||
|
||||
// backfillSummary is what a backfill enqueued. MaxExternalLookups is an upper estimate for one
|
||||
// attempt per item, not a bound: a local hit ends the walk, and a retry asks the agents again.
|
||||
type backfillSummary struct {
|
||||
Ran bool
|
||||
PerKind map[string]int64
|
||||
Items int64
|
||||
MaxExternalLookups int64
|
||||
}
|
||||
|
||||
// backfill enqueues artwork resolution for every entity when the config fingerprint changed.
|
||||
func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
|
||||
func backfill(ctx context.Context, ds model.DataStore, agentCount func() ImageAgentCount) (backfillSummary, error) {
|
||||
start := time.Now()
|
||||
ctx = auth.WithAdminUser(ctx, ds)
|
||||
current := ConfigFingerprint()
|
||||
props := ds.Property(ctx)
|
||||
stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
return backfillSummary{}, err
|
||||
}
|
||||
if stored == current {
|
||||
return false, nil
|
||||
return backfillSummary{}, nil
|
||||
}
|
||||
|
||||
// Artists first: few entities, most external-dependent, so they get a queue headstart.
|
||||
@ -92,21 +105,31 @@ func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
|
||||
{model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }},
|
||||
{model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }},
|
||||
}
|
||||
// Counted here, not by the caller: building the agent list constructs every enabled agent, and
|
||||
// an unchanged fingerprint returns above without ever needing the number.
|
||||
agents := agentCount()
|
||||
summary := backfillSummary{Ran: true, PerKind: map[string]int64{}}
|
||||
for _, k := range kinds {
|
||||
ids, err := k.fetch()
|
||||
if err != nil {
|
||||
return false, err
|
||||
return backfillSummary{}, err
|
||||
}
|
||||
if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil {
|
||||
return false, err
|
||||
return backfillSummary{}, err
|
||||
}
|
||||
n := int64(len(ids))
|
||||
summary.PerKind[k.kind.Prefix()] = n
|
||||
summary.Items += n
|
||||
summary.MaxExternalLookups += n * ExternalLookupsPerItem(k.kind, agents)
|
||||
}
|
||||
|
||||
if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil {
|
||||
return false, err
|
||||
return backfillSummary{}, err
|
||||
}
|
||||
log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "elapsed", time.Since(start))
|
||||
return true, nil
|
||||
log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "items", summary.Items,
|
||||
"byKind", summary.PerKind, "maxExternalLookups", summary.MaxExternalLookups,
|
||||
"elapsed", time.Since(start))
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error {
|
||||
@ -125,7 +148,7 @@ func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error {
|
||||
cutoff := time.Now().Add(-StaleAbsentAge)
|
||||
queue := ds.ArtworkQueue(ctx)
|
||||
for _, kind := range RecheckKinds {
|
||||
if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil {
|
||||
if _, err := queue.EnqueueStaleAbsent(kind, cutoff, StaleAbsentRecheckBatch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
@ -38,6 +39,8 @@ func adminUserRepo() *tests.MockedUserRepo {
|
||||
return repo
|
||||
}
|
||||
|
||||
func noAgents() ImageAgentCount { return ImageAgentCount{} }
|
||||
|
||||
// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can
|
||||
// assert phase ordering (artists-first) that same-priority timestamps can't guarantee.
|
||||
type orderTrackingQueueRepo struct {
|
||||
@ -163,9 +166,14 @@ var _ = Describe("Housekeeping", func() {
|
||||
seedEntities()
|
||||
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed())
|
||||
|
||||
did, err := backfill(ctx, ds)
|
||||
counted := false
|
||||
s, err := backfill(ctx, ds, func() ImageAgentCount {
|
||||
counted = true
|
||||
return ImageAgentCount{Artist: 3, Album: 2}
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeFalse())
|
||||
Expect(s).To(Equal(backfillSummary{}))
|
||||
Expect(counted).To(BeFalse(), "building the agent list constructs every agent; an unchanged fingerprint must not pay for it")
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -175,9 +183,9 @@ var _ = Describe("Housekeeping", func() {
|
||||
It("runs the backfill when no fingerprint was ever stored", func() {
|
||||
seedEntities()
|
||||
|
||||
did, err := backfill(ctx, ds)
|
||||
s, err := backfill(ctx, ds, noAgents)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeTrue())
|
||||
Expect(s.Ran).To(BeTrue())
|
||||
|
||||
count, err := queueRepo.Count()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -196,9 +204,9 @@ var _ = Describe("Housekeeping", func() {
|
||||
tracks: &tests.MockPlaylistTrackRepo{},
|
||||
}
|
||||
|
||||
did, err := backfill(ctx, vds)
|
||||
s, err := backfill(ctx, vds, noAgents)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeTrue())
|
||||
Expect(s.Ran).To(BeTrue())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil())
|
||||
})
|
||||
|
||||
@ -206,9 +214,9 @@ var _ = Describe("Housekeeping", func() {
|
||||
seedEntities()
|
||||
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||
|
||||
did, err := backfill(ctx, ds)
|
||||
s, err := backfill(ctx, ds, noAgents)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(did).To(BeTrue())
|
||||
Expect(s.Ran).To(BeTrue())
|
||||
|
||||
Expect(queueRepo.callKinds).ToNot(BeEmpty())
|
||||
firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" })
|
||||
@ -223,6 +231,22 @@ var _ = Describe("Housekeeping", func() {
|
||||
Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra"))
|
||||
}
|
||||
})
|
||||
|
||||
It("reports what it enqueued, per kind and as an external-lookup ceiling", func() {
|
||||
conf.Server.ArtistArtPriority = "artist.*, external"
|
||||
conf.Server.CoverArtPriority = "cover.*, external"
|
||||
conf.Server.EnableM3UExternalAlbumArt = false
|
||||
seedEntities()
|
||||
|
||||
s, err := backfill(ctx, ds, func() ImageAgentCount { return ImageAgentCount{Artist: 3, Album: 2} })
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Ran).To(BeTrue())
|
||||
|
||||
Expect(s.PerKind).To(Equal(map[string]int64{"ar": 2, "al": 1, "pl": 1, "ra": 1}))
|
||||
Expect(s.Items).To(Equal(int64(5)))
|
||||
// 2 artists x 3 agents, 1 album x 2, 1 playlist grid x 2, and radios never fetch.
|
||||
Expect(s.MaxExternalLookups).To(Equal(int64(6 + 2 + PlaylistGridSamples*2)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnqueueStaleAbsentAll", func() {
|
||||
@ -235,8 +259,8 @@ var _ = Describe("Housekeeping", func() {
|
||||
})
|
||||
|
||||
It("enqueues only absent entries older than the recheck window, across all kinds", func() {
|
||||
old := time.Now().Add(-48 * time.Hour)
|
||||
recent := time.Now().Add(-time.Hour)
|
||||
old := time.Now().Add(-StaleAbsentAge - time.Hour)
|
||||
recent := time.Now().Add(-StaleAbsentAge + time.Hour)
|
||||
|
||||
artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}
|
||||
@ -259,6 +283,20 @@ var _ = Describe("Housekeeping", func() {
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil())
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil())
|
||||
})
|
||||
|
||||
It("caps each tick at the recheck batch, oldest attempts first", func() {
|
||||
for i := range StaleAbsentRecheckBatch + 1 {
|
||||
id := fmt.Sprintf("ar%d", i)
|
||||
artRepo.ItemData[id] = model.ItemArtwork{ItemKind: "ar", ItemID: id, ImageType: model.ImageTypePrimary,
|
||||
Hash: "", AttemptedAt: time.Now().Add(-StaleAbsentAge - time.Duration(i+1)*time.Minute)}
|
||||
}
|
||||
|
||||
Expect(enqueueStaleAbsentAll(ctx, ds)).To(Succeed())
|
||||
|
||||
Expect(queueRepo.Data).To(HaveLen(StaleAbsentRecheckBatch))
|
||||
// ar0 has the newest attempted_at of the cohort, so it is the one left out.
|
||||
Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar0")).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnqueueMissingAll", func() {
|
||||
|
||||
@ -2,6 +2,7 @@ package artwork
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
@ -89,12 +90,21 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
||||
|
||||
res, err := p.resolver.resolve(ctx, item)
|
||||
if err != nil {
|
||||
traceStage(ctx, "resolve", err)
|
||||
log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
if res.reader == nil {
|
||||
if res.extError || res.localError {
|
||||
// A fault is not a definitive "no image": never settle absent, keep serving old state.
|
||||
// A chainless resolver (playlist/radio) records no step, so leave a fallback or explain is blank.
|
||||
if t := traceFrom(ctx); len(t.Steps()) == 0 {
|
||||
outcome := OutcomeError
|
||||
if res.localError {
|
||||
outcome = OutcomeUnreadable
|
||||
}
|
||||
t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome})
|
||||
}
|
||||
log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state",
|
||||
"kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError)
|
||||
return outcomeFailed, nil
|
||||
@ -106,6 +116,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
||||
readStart := time.Now()
|
||||
data, err := readCapped(res.reader)
|
||||
if err != nil {
|
||||
traceStage(ctx, "read", err)
|
||||
log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
@ -115,6 +126,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
||||
hashStart := time.Now()
|
||||
hash, err := hashImage(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
traceStage(ctx, "hash", err)
|
||||
log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
@ -138,19 +150,22 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
||||
art, err = undecodedArtwork(hash), nil
|
||||
}
|
||||
if err != nil {
|
||||
traceStage(ctx, "decode", err)
|
||||
log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash,
|
||||
"width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart))
|
||||
default:
|
||||
traceStage(ctx, "lookup", err)
|
||||
log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
art.SizeBytes = int64(len(data))
|
||||
|
||||
ia, err := p.persist(repo, item, art, res, data)
|
||||
ia, err := p.persist(ctx, repo, item, art, res, data)
|
||||
if err != nil {
|
||||
traceStage(ctx, "store", err)
|
||||
log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
return outcomeFailed, nil
|
||||
}
|
||||
@ -165,7 +180,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
||||
|
||||
// persist places the bytes and commits the rows referencing them, excluding Prune for that
|
||||
// window only so a slow resolution can never hold it off.
|
||||
func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueueItem,
|
||||
func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem,
|
||||
art *model.Artwork, res resolution, data []byte,
|
||||
) (*model.ItemArtwork, error) {
|
||||
if p.pruneLock != nil {
|
||||
@ -188,6 +203,7 @@ func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueu
|
||||
SourcePath: sourcePath,
|
||||
RefMtime: refMtime,
|
||||
AttemptedAt: time.Now(),
|
||||
Trace: traceFrom(ctx).encode(sourcePath),
|
||||
}
|
||||
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
|
||||
if err := repo.PutItemArtwork(ia); err != nil {
|
||||
@ -203,6 +219,7 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A
|
||||
ItemID: item.ItemID,
|
||||
ImageType: item.ImageType,
|
||||
AttemptedAt: time.Now(),
|
||||
Trace: traceFrom(ctx).encode(""),
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
|
||||
@ -229,6 +229,35 @@ var _ = Describe("processor.acquire", func() {
|
||||
Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent")
|
||||
})
|
||||
|
||||
// Playlist/radio resolvers walk no chain, so a fault records no step; without a fallback,
|
||||
// explain would show a give-up with an empty "Gave up after" table.
|
||||
It("chainless fault: records a fallback trace step naming the faulted source", func() {
|
||||
if runtime.GOOS == "windows" {
|
||||
// os.Open under a non-directory maps to a not-exist error on Windows, so no localError.
|
||||
Skip("cannot provoke an open fault via a non-directory parent on Windows")
|
||||
}
|
||||
radioRepo := tests.CreateMockedRadioRepo()
|
||||
radioRepo.Data = map[string]*model.Radio{}
|
||||
ds.MockedRadio = radioRepo
|
||||
dir := GinkgoT().TempDir()
|
||||
conf.Server.DataFolder = conf.NewDir(dir)
|
||||
upload := model.UploadedImagePath(consts.EntityRadio, "ra-tr.jpg")
|
||||
// A plain file where the upload's parent should be makes os.Open fault with ENOTDIR,
|
||||
// deterministically and regardless of the test user's privileges.
|
||||
Expect(os.MkdirAll(filepath.Dir(filepath.Dir(upload)), 0o755)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Dir(upload), []byte("x"), 0o600)).To(Succeed())
|
||||
radioRepo.Data["ra-tr"] = &model.Radio{ID: "ra-tr", Name: "Station", UploadedImage: "ra-tr.jpg"}
|
||||
|
||||
trace := &ChainTrace{}
|
||||
out, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"})
|
||||
Expect(out).To(Equal(outcomeFailed))
|
||||
|
||||
steps := trace.Steps()
|
||||
Expect(steps).To(HaveLen(1), "a radio fault must leave one step so explain is not blank")
|
||||
Expect(steps[0].Candidate).To(Equal("upload"))
|
||||
Expect(steps[0].Outcome).To(Equal(OutcomeUnreadable))
|
||||
})
|
||||
|
||||
It("failed-on-extError: leaves the item's state untouched", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||
|
||||
@ -37,7 +37,7 @@ type resolution struct {
|
||||
// transient external failure still retries; localErr is dropped, as the scanner re-lists changes.
|
||||
type chainState struct {
|
||||
extErr, localErr bool
|
||||
trace *ChainTrace // nil unless the CLI asked for a trace
|
||||
trace *ChainTrace // nil only where no caller attached one
|
||||
}
|
||||
|
||||
// try stamps the accumulated external failure onto a hit, and records the miss otherwise.
|
||||
@ -137,6 +137,15 @@ func MayFetchExternal(kind model.Kind) bool {
|
||||
// ImageAgentCount is how many enabled agents provide artist and album images.
|
||||
type ImageAgentCount struct{ Artist, Album int }
|
||||
|
||||
// NewImageAgentCount counts what an external step would consult, so an estimate and the gate that
|
||||
// guards it cannot disagree about which agents exist.
|
||||
func NewImageAgentCount(ag *agents.Agents) ImageAgentCount {
|
||||
if ag == nil {
|
||||
return ImageAgentCount{}
|
||||
}
|
||||
return ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())}
|
||||
}
|
||||
|
||||
// ExternalLookupsPerItem reports what resolving one item of this kind can cost: every image agent is
|
||||
// tried, and a zero count still bills one, so agents the caller cannot see never read as free.
|
||||
func ExternalLookupsPerItem(kind model.Kind, agents ImageAgentCount) int64 {
|
||||
@ -354,10 +363,13 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
||||
}
|
||||
if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt {
|
||||
sf := func() (io.ReadCloser, string, error) { return fromURL(ctx, remoteImg) }
|
||||
if res, ok, isErr := resolveExternalStep(r.ext.gate, "m3u", sf); ok {
|
||||
if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok {
|
||||
return res, nil
|
||||
} else if isErr {
|
||||
} else if err != nil {
|
||||
extErr = true
|
||||
// Record it here with its detail: once album sampling adds its own steps, the processor's
|
||||
// empty-trace fallback no longer fires, and the error that forced the retry would be lost.
|
||||
traceFrom(ctx).add(TraceStep{Candidate: ExternalPrefix + "m3u", Outcome: OutcomeError, Detail: err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
@ -461,14 +473,17 @@ func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, erro
|
||||
return dr.selectImage(ctx, r.ffmpeg, conf.Server.DiscArtPriority, &chain)
|
||||
}
|
||||
|
||||
// resolveExternalStep runs a single external sourceFunc through the named gate. extErr excludes
|
||||
// a not-found, which is a definitive "no" rather than a failure.
|
||||
func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) {
|
||||
// resolveExternalStep runs a single external sourceFunc through the named gate. A not-found is a
|
||||
// definitive "no", returned as (_, false, nil); any other error is a failure the caller records.
|
||||
func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (resolution, bool, error) {
|
||||
r, path, err := gate(name, sf)
|
||||
if r != nil {
|
||||
return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, false
|
||||
return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, nil
|
||||
}
|
||||
return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return resolution{}, false, nil
|
||||
}
|
||||
return resolution{}, false, err
|
||||
}
|
||||
|
||||
// classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path or a
|
||||
@ -561,7 +576,9 @@ func resolveLocalFile(path, source string) (resolution, bool) {
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return resolution{localError: !errors.Is(err, fs.ErrNotExist)}, false
|
||||
// Carry the source label even on a fault, so a resolver with no chain (playlist/radio) can
|
||||
// still name what faulted in the trace.
|
||||
return resolution{source: source, localError: !errors.Is(err, fs.ErrNotExist)}, false
|
||||
}
|
||||
return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true
|
||||
}
|
||||
|
||||
@ -520,6 +520,37 @@ var _ = Describe("resolveItem", func() {
|
||||
Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"")
|
||||
})
|
||||
|
||||
It("records the m3u failure in the trace even when album sampling adds its own steps", func() {
|
||||
conf.Server.EnableM3UExternalAlbumArt = true
|
||||
folderRepo.result = nil // the sampled album yields no tile, so the m3u failure is what forced the retry
|
||||
|
||||
plRepo := tests.CreateMockPlaylistRepo()
|
||||
plRepo.SetData(model.Playlists{{ID: "plm3u", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}})
|
||||
plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}}
|
||||
ds.MockedPlaylist = plRepo
|
||||
|
||||
gate := func(string, func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
return nil, "", errors.New("network down")
|
||||
}
|
||||
|
||||
trace := &ChainTrace{}
|
||||
res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace),
|
||||
model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.extError).To(BeTrue())
|
||||
|
||||
steps := trace.Steps()
|
||||
var m3u *TraceStep
|
||||
for i := range steps {
|
||||
if steps[i].Candidate == ExternalPrefix+"m3u" && steps[i].Outcome == OutcomeError {
|
||||
m3u = &steps[i]
|
||||
}
|
||||
}
|
||||
Expect(m3u).ToNot(BeNil(), "the m3u fetch error must be traced at its source, not left to the empty-trace fallback")
|
||||
Expect(m3u.Detail).To(Equal("network down"),
|
||||
"the trace must carry the underlying error so explain can tell a timeout from an HTTP error")
|
||||
})
|
||||
|
||||
It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() {
|
||||
folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces
|
||||
|
||||
|
||||
@ -2,10 +2,12 @@ package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
)
|
||||
|
||||
// Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these.
|
||||
@ -16,7 +18,6 @@ const (
|
||||
OutcomeMiss Outcome = "miss"
|
||||
OutcomeUnreadable Outcome = "unreadable"
|
||||
OutcomeSkipped Outcome = "skipped"
|
||||
OutcomeWouldTry Outcome = "would-try"
|
||||
OutcomeError Outcome = "error"
|
||||
)
|
||||
|
||||
@ -34,8 +35,8 @@ type TraceStep struct {
|
||||
Detail string
|
||||
}
|
||||
|
||||
// ChainTrace collects the walk of a single resolution. The artwork worker never attaches
|
||||
// one; only the CLI does, so resolution stays allocation-free in the hot path.
|
||||
// ChainTrace collects the walk of a single resolution: the worker attaches one per queue
|
||||
// item so it can be stored, and the CLI attaches one per explain.
|
||||
type ChainTrace struct {
|
||||
mu sync.Mutex
|
||||
steps []TraceStep
|
||||
@ -59,6 +60,65 @@ func (t *ChainTrace) Steps() []TraceStep {
|
||||
return slices.Clone(t.steps)
|
||||
}
|
||||
|
||||
// maxTraceDetail bounds a stored Detail, which on the failure paths is an error string of
|
||||
// unknown length. Past ~1kB a row spills to an overflow page, slowing every scan of the table.
|
||||
const maxTraceDetail = 200
|
||||
|
||||
// storedStep is the persisted shape of a TraceStep. The keys are single letters because a trace
|
||||
// is written for every item, and the encoded length is repeated across the whole library.
|
||||
type storedStep struct {
|
||||
C string `json:"c"`
|
||||
O Outcome `json:"o"`
|
||||
D string `json:"d,omitempty"`
|
||||
}
|
||||
|
||||
// encode serializes the trace for storage, without the copy Steps would make for a caller
|
||||
// that only wants to write it.
|
||||
func (t *ChainTrace) encode(sourcePath string) string {
|
||||
if t == nil {
|
||||
return encodeSteps(nil, sourcePath)
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return encodeSteps(t.steps, sourcePath)
|
||||
}
|
||||
|
||||
// encodeSteps writes the stored form. A hit's Detail is the winning source's path, which the
|
||||
// same row already stores as source_path, so it is dropped and DecodeTrace puts it back.
|
||||
func encodeSteps(steps []TraceStep, sourcePath string) string {
|
||||
out := make([]storedStep, 0, len(steps))
|
||||
for _, s := range steps {
|
||||
d := s.Detail
|
||||
if s.Outcome == OutcomeHit && d == sourcePath {
|
||||
d = ""
|
||||
}
|
||||
out = append(out, storedStep{C: s.Candidate, O: s.Outcome, D: str.TruncateRunes(d, maxTraceDetail, "...")})
|
||||
}
|
||||
b, _ := json.Marshal(out) // []storedStep is all strings, so this cannot fail
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// DecodeTrace reverses the stored form. A trace that will not parse is reported as no trace at all,
|
||||
// since a diagnostic command must not fail on a bad row.
|
||||
func DecodeTrace(encoded, sourcePath string) []TraceStep {
|
||||
if encoded == "" {
|
||||
return nil
|
||||
}
|
||||
var stored []storedStep
|
||||
if err := json.Unmarshal([]byte(encoded), &stored); err != nil {
|
||||
return nil
|
||||
}
|
||||
steps := make([]TraceStep, 0, len(stored))
|
||||
for _, s := range stored {
|
||||
d := s.D
|
||||
if d == "" && s.O == OutcomeHit {
|
||||
d = sourcePath
|
||||
}
|
||||
steps = append(steps, TraceStep{Candidate: s.C, Outcome: s.O, Detail: d})
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
type traceCtxKey struct{}
|
||||
|
||||
func withTrace(ctx context.Context, t *ChainTrace) context.Context {
|
||||
@ -70,30 +130,23 @@ func traceFrom(ctx context.Context) *ChainTrace {
|
||||
return t
|
||||
}
|
||||
|
||||
var errOfflineSkipped = errors.New("artwork: external lookup skipped (offline)")
|
||||
|
||||
// tracingGate records each external agent's outcome without changing what the gate returns.
|
||||
func tracingGate(t *ChainTrace, inner gateFunc) gateFunc {
|
||||
return func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
r, path, err := inner(name, f)
|
||||
candidate := ExternalPrefix + name
|
||||
switch {
|
||||
case r != nil:
|
||||
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path})
|
||||
case isTransientExternal(err):
|
||||
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()})
|
||||
default:
|
||||
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss})
|
||||
}
|
||||
return r, path, err
|
||||
// recordAgent files what one external agent answered. The agent loops call this rather than a
|
||||
// gate wrapper, because only they hold the context that carries the trace.
|
||||
func recordAgent(ctx context.Context, name string, r io.ReadCloser, path string, err error) {
|
||||
t := traceFrom(ctx)
|
||||
candidate := ExternalPrefix + name
|
||||
switch {
|
||||
case r != nil:
|
||||
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path})
|
||||
case isTransientExternal(err):
|
||||
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()})
|
||||
default:
|
||||
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss})
|
||||
}
|
||||
}
|
||||
|
||||
// offlineGate reports which agents would be asked without asking them, so a diagnostic
|
||||
// command cannot add load to a provider that is already rate-limiting us.
|
||||
func offlineGate(t *ChainTrace) gateFunc {
|
||||
return func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
|
||||
t.add(TraceStep{Candidate: ExternalPrefix + name, Outcome: OutcomeWouldTry})
|
||||
return nil, "", errOfflineSkipped
|
||||
}
|
||||
// traceStage records a failure from the stages that run after the priority chain has already
|
||||
// picked a winner: most ways an item can fail are here, not in the chain walk.
|
||||
func traceStage(ctx context.Context, stage string, err error) {
|
||||
traceFrom(ctx).add(TraceStep{Candidate: stage, Outcome: OutcomeError, Detail: err.Error()})
|
||||
}
|
||||
|
||||
@ -24,13 +24,63 @@ var _ = Describe("trace vocabulary", func() {
|
||||
// what `artwork explain` tells an operator, so it must be made deliberately.
|
||||
It("pins the wire values the CLI reads", func() {
|
||||
Expect([]Outcome{
|
||||
OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeWouldTry, OutcomeError,
|
||||
}).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "would-try", "error"}))
|
||||
OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeError,
|
||||
}).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "error"}))
|
||||
Expect(externalCandidate).To(Equal("external"))
|
||||
Expect(ExternalPrefix).To(Equal("external:"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("encodeSteps/DecodeTrace", func() {
|
||||
It("round-trips a trace", func() {
|
||||
steps := []TraceStep{
|
||||
{Candidate: "cover.png", Outcome: OutcomeMiss},
|
||||
{Candidate: "cover.*", Outcome: OutcomeHit, Detail: "/music/a/cover.jpg"},
|
||||
}
|
||||
Expect(DecodeTrace(encodeSteps(steps, ""), "")).To(Equal(steps))
|
||||
})
|
||||
|
||||
It("encodes an empty trace as an empty JSON array", func() {
|
||||
Expect(encodeSteps(nil, "")).To(Equal("[]"))
|
||||
Expect(DecodeTrace("[]", "")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("tolerates a row written before the column existed", func() {
|
||||
Expect(DecodeTrace("", "")).To(BeEmpty())
|
||||
})
|
||||
|
||||
// The hit detail repeats source_path byte for byte, and that column is on the same row.
|
||||
It("drops a hit detail that repeats sourcePath, and restores it on read", func() {
|
||||
path := "/music/artist/album/cover.jpg"
|
||||
steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeHit, Detail: path}}
|
||||
encoded := encodeSteps(steps, path)
|
||||
Expect(encoded).NotTo(ContainSubstring(path))
|
||||
Expect(DecodeTrace(encoded, path)).To(Equal(steps))
|
||||
})
|
||||
|
||||
It("keeps a detail that differs from sourcePath", func() {
|
||||
steps := []TraceStep{{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "https://cdn/x.jpg"}}
|
||||
Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps))
|
||||
})
|
||||
|
||||
// A row past ~1kB spills to an overflow page on these WITHOUT ROWID tables, which would
|
||||
// slow every scan; Detail is an error string on the failure paths, so it needs a bound.
|
||||
It("bounds a detail so one long error cannot inflate the row", func() {
|
||||
steps := []TraceStep{{Candidate: "decode", Outcome: OutcomeError, Detail: strings.Repeat("x", 5000)}}
|
||||
|
||||
got := DecodeTrace(encodeSteps(steps, ""), "")
|
||||
|
||||
Expect(len(got[0].Detail)).To(BeNumerically("<=", 210))
|
||||
Expect(got[0].Detail).To(HaveSuffix("..."))
|
||||
Expect(got[0].Candidate).To(Equal("decode"), "truncating the detail must not disturb the step")
|
||||
})
|
||||
|
||||
It("only restores sourcePath onto a detail-less hit", func() {
|
||||
steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}}
|
||||
Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("chainTrace", func() {
|
||||
It("returns nil when no trace is attached", func() {
|
||||
Expect(traceFrom(context.Background())).To(BeNil())
|
||||
@ -113,63 +163,41 @@ var _ = Describe("chainState tracing", func() {
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("external gate tracing", func() {
|
||||
hit := func() (io.ReadCloser, string, error) {
|
||||
return io.NopCloser(strings.NewReader("x")), "http://img", nil
|
||||
}
|
||||
miss := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound }
|
||||
boom := func() (io.ReadCloser, string, error) { return nil, "", errors.New("returned status 429") }
|
||||
var _ = Describe("external agent tracing", func() {
|
||||
var (
|
||||
t *ChainTrace
|
||||
ctx context.Context
|
||||
body io.ReadCloser
|
||||
)
|
||||
BeforeEach(func() {
|
||||
t = &ChainTrace{}
|
||||
ctx = withTrace(context.Background(), t)
|
||||
body = io.NopCloser(strings.NewReader("x"))
|
||||
})
|
||||
|
||||
It("records a hit with the image path", func() {
|
||||
t := &ChainTrace{}
|
||||
g := tracingGate(t, passthroughGate)
|
||||
|
||||
r, _, err := g("deezer", hit)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(r).ToNot(BeNil())
|
||||
recordAgent(ctx, "deezer", body, "http://img", nil)
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{
|
||||
{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "http://img"},
|
||||
}))
|
||||
})
|
||||
|
||||
It("records a miss for a not-found", func() {
|
||||
t := &ChainTrace{}
|
||||
_, _, _ = tracingGate(t, passthroughGate)("deezer", miss)
|
||||
recordAgent(ctx, "deezer", nil, "", agents.ErrNotFound)
|
||||
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss))
|
||||
})
|
||||
|
||||
It("records a miss for a model not-found", func() {
|
||||
t := &ChainTrace{}
|
||||
notFound := func() (io.ReadCloser, string, error) { return nil, "", model.ErrNotFound }
|
||||
_, _, _ = tracingGate(t, passthroughGate)("deezer", notFound)
|
||||
recordAgent(ctx, "deezer", nil, "", model.ErrNotFound)
|
||||
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss),
|
||||
"both not-found flavours are definitive answers, not faults")
|
||||
})
|
||||
|
||||
It("records an error with its reason", func() {
|
||||
t := &ChainTrace{}
|
||||
_, _, _ = tracingGate(t, passthroughGate)("apple-music", boom)
|
||||
recordAgent(ctx, "apple-music", nil, "", errors.New("returned status 429"))
|
||||
Expect(t.Steps()[0].Outcome).To(Equal(OutcomeError))
|
||||
Expect(t.Steps()[0].Detail).To(ContainSubstring("429"))
|
||||
})
|
||||
|
||||
It("never calls the agent in offline mode", func() {
|
||||
t := &ChainTrace{}
|
||||
called := false
|
||||
counting := func() (io.ReadCloser, string, error) {
|
||||
called = true
|
||||
return hit()
|
||||
}
|
||||
|
||||
_, _, err := offlineGate(t)("deezer", counting)
|
||||
|
||||
Expect(called).To(BeFalse(), "offline mode must not perform external requests")
|
||||
Expect(err).To(MatchError(errOfflineSkipped))
|
||||
Expect(t.Steps()).To(Equal([]TraceStep{
|
||||
{Candidate: "external:deezer", Outcome: OutcomeWouldTry},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("resolveAlbum tracing", func() {
|
||||
@ -409,51 +437,51 @@ var _ = Describe("NewTracingResolver", func() {
|
||||
t = &ChainTrace{}
|
||||
})
|
||||
|
||||
Context("offline", func() {
|
||||
Context("resolving", func() {
|
||||
var fake *fakeImageAgent
|
||||
|
||||
BeforeEach(func() {
|
||||
fake = &fakeImageAgent{name: "offline-probe"}
|
||||
// Misses, so the chain falls through to the local tier and both are traced.
|
||||
fake = &fakeImageAgent{name: "probe", err: agents.ErrNotFound}
|
||||
albumRepo.SetData(model.Albums{{
|
||||
ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"},
|
||||
}})
|
||||
artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
|
||||
})
|
||||
|
||||
It("reports the external tier without asking any agent", func() {
|
||||
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
|
||||
It("asks the agents and records what each answered", func() {
|
||||
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(source).To(Equal("embedded"))
|
||||
Expect(fake.albumCalls).To(BeZero(), "offline mode must not add load to an external provider")
|
||||
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry}))
|
||||
Expect(fake.albumCalls).To(Equal(1))
|
||||
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:probe", Outcome: OutcomeMiss}))
|
||||
})
|
||||
|
||||
It("records the local chain steps too", func() {
|
||||
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
|
||||
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
last := t.Steps()[len(t.Steps())-1]
|
||||
Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external gate")
|
||||
Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external tier")
|
||||
Expect(last.Outcome).To(Equal(OutcomeHit))
|
||||
})
|
||||
|
||||
It("never persists artwork state", func() {
|
||||
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
|
||||
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(artworkRepo.ItemData).To(BeEmpty(),
|
||||
"an offline resolution carries extError, which must never be recorded as a real provider failure")
|
||||
"explain is read-only; a diagnostic walk must never become the stored answer")
|
||||
Expect(queueRepo.Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("resolves an artist without persisting anything", func() {
|
||||
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindArtistArtwork, "ar1")
|
||||
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindArtistArtwork, "ar1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(source).To(BeEmpty())
|
||||
Expect(fake.artistCalls).To(BeZero())
|
||||
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry}))
|
||||
Expect(fake.artistCalls).To(Equal(1))
|
||||
Expect(artworkRepo.ItemData).To(BeEmpty())
|
||||
Expect(queueRepo.Data).To(BeEmpty())
|
||||
})
|
||||
@ -465,31 +493,39 @@ var _ = Describe("NewTracingResolver", func() {
|
||||
ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"},
|
||||
}})
|
||||
|
||||
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al2")
|
||||
source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al2")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(source).To(Equal("embedded"))
|
||||
Expect(ffm.IsClosed()).To(BeTrue(), "nothing downstream closes it, so a leak is one file handle per invocation")
|
||||
})
|
||||
|
||||
// Serving falls back disc -> album and track -> disc -> album. The resolver does not, but
|
||||
// if it ever did, an explain without --live would start calling providers uninvited.
|
||||
It("cannot reach a provider without live, whatever the chain does", func() {
|
||||
conf.Server.DiscArtPriority = "external, cover.*"
|
||||
conf.Server.CoverArtPriority = "external, cover.*"
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
mfRepo := tests.CreateMockMediaFileRepo()
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "mf1", LibraryID: 0, HasCoverArt: true,
|
||||
Path: "tests/fixtures/artist/an-album/test.mp3"}})
|
||||
ds.MockedMediaFile = mfRepo
|
||||
offline := NewTracingResolver(ds, imageAgents(fake), ffm, t, false)
|
||||
|
||||
_, err := offline.Resolve(context.Background(), model.KindDiscArtwork, "al1:1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = offline.Resolve(context.Background(), model.KindMediaFileArtwork, "mf1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(fake.albumCalls).To(BeZero())
|
||||
Expect(fake.artistCalls).To(BeZero())
|
||||
})
|
||||
|
||||
It("propagates a lookup error", func() {
|
||||
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "nope")
|
||||
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "nope")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
It("asks the agents when live is true", func() {
|
||||
fake := &fakeImageAgent{name: "live-probe", err: agents.ErrNotFound}
|
||||
albumRepo.SetData(model.Albums{{
|
||||
ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"},
|
||||
}})
|
||||
|
||||
_, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fake.albumCalls).To(Equal(1))
|
||||
Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:live-probe", Outcome: OutcomeMiss}))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("resolveDisc tracing", func() {
|
||||
|
||||
@ -40,6 +40,7 @@ type drainPool struct {
|
||||
// independently, and pruneMu serializes prune against the store-write window.
|
||||
type Worker struct {
|
||||
proc *processor
|
||||
agents *agents.Agents
|
||||
cache cache.FileCache
|
||||
ffmpeg ffmpeg.FFmpeg
|
||||
broker events.Broker
|
||||
@ -54,6 +55,7 @@ type Worker struct {
|
||||
func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker {
|
||||
w := &Worker{
|
||||
proc: &processor{ds: ds, store: store},
|
||||
agents: ag,
|
||||
cache: imgCache,
|
||||
ffmpeg: ffmpeg,
|
||||
broker: broker,
|
||||
@ -132,12 +134,14 @@ func (w *Worker) RunPrune(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed,
|
||||
// artists first. It reports whether anything was enqueued.
|
||||
// artists first. It reports whether the backfill ran.
|
||||
func (w *Worker) Backfill(ctx context.Context) (bool, error) {
|
||||
return backfill(ctx, w.proc.ds)
|
||||
s, err := backfill(ctx, w.proc.ds, func() ImageAgentCount { return NewImageAgentCount(w.agents) })
|
||||
return s.Ran, err
|
||||
}
|
||||
|
||||
// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge.
|
||||
// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge, at most
|
||||
// StaleAbsentRecheckBatch per kind, oldest first.
|
||||
func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error {
|
||||
return enqueueStaleAbsentAll(ctx, w.proc.ds)
|
||||
}
|
||||
@ -235,6 +239,8 @@ func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueu
|
||||
|
||||
func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) {
|
||||
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
|
||||
trace := &ChainTrace{}
|
||||
ctx = withTrace(ctx, trace)
|
||||
out, got := w.proc.acquire(ctx, item)
|
||||
|
||||
queue := w.proc.ds.ArtworkQueue(ctx)
|
||||
@ -247,10 +253,11 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||
}
|
||||
case outcomeFoundStale, outcomeFailed:
|
||||
retryAt := time.Now().Add(backoff(item.Attempts))
|
||||
encoded := trace.encode("")
|
||||
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
|
||||
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
|
||||
// fresh, immediate eligibility.
|
||||
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil {
|
||||
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
|
||||
@ -265,6 +272,9 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||
writeAbsent(ctx, w.proc.ds.Artwork(ctx), item)
|
||||
settled = "recorded absent"
|
||||
}
|
||||
// The queue row is about to go, taking the only record of the failure with it. This write is
|
||||
// unconditional (not CAS-guarded) — safe only because the drain resolves each item serially.
|
||||
w.recordGiveUp(ctx, item, encoded)
|
||||
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
|
||||
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
|
||||
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||
@ -274,6 +284,18 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||
return out, got
|
||||
}
|
||||
|
||||
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
|
||||
// that never resolved has no row to update, and creating one would settle it absent.
|
||||
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {
|
||||
kind, ok := model.ParseKind(item.ItemKind)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := w.proc.ds.Artwork(ctx).PutLastFailure(kind, item.ItemID, item.ImageType, trace); err != nil {
|
||||
log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool {
|
||||
kind, ok := model.ParseKind(item.ItemKind)
|
||||
if !ok {
|
||||
|
||||
@ -95,6 +95,17 @@ func (f *fakeEventBroker) getEvents() []events.Event {
|
||||
|
||||
var _ events.Broker = (*fakeEventBroker)(nil)
|
||||
|
||||
// expireQueued ages a row past the retry budget, so the next drain settles it instead of retrying.
|
||||
func expireQueued(q *tests.MockArtworkQueueRepo, id string) {
|
||||
GinkgoHelper()
|
||||
for k, v := range q.Data {
|
||||
if v.ItemID == id {
|
||||
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
|
||||
q.Data[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem {
|
||||
for _, it := range q.Data {
|
||||
if it.ItemKind == kind && it.ItemID == id {
|
||||
@ -318,12 +329,7 @@ var _ = Describe("Worker", func() {
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||
// Age the row past the retry budget.
|
||||
for k, v := range queueRepo.Data {
|
||||
if v.ItemID == "al9" {
|
||||
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
|
||||
queueRepo.Data[k] = v
|
||||
}
|
||||
}
|
||||
expireQueued(queueRepo, "al9")
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -345,12 +351,7 @@ var _ = Describe("Worker", func() {
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
|
||||
for k, v := range queueRepo.Data {
|
||||
if v.ItemID == "al10" {
|
||||
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
|
||||
queueRepo.Data[k] = v
|
||||
}
|
||||
}
|
||||
expireQueued(queueRepo, "al10")
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -362,6 +363,67 @@ var _ = Describe("Worker", func() {
|
||||
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
|
||||
})
|
||||
|
||||
It("records on the queue row why the last attempt failed", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
it := findQueued(queueRepo, "al", "al11")
|
||||
Expect(it).ToNot(BeNil())
|
||||
Expect(DecodeTrace(it.Trace, "")).To(ContainElement(SatisfyAll(
|
||||
HaveField("Candidate", "external:failAgent"),
|
||||
HaveField("Outcome", OutcomeError),
|
||||
HaveField("Detail", ContainSubstring("agent timed out")),
|
||||
)), "a retrying row must say why it is retrying")
|
||||
})
|
||||
|
||||
// The give-up path settles absent before recording, so the row exists by the time the
|
||||
// failure is written. Recording first would silently lose it for every unresolved item.
|
||||
It("keeps the failure for an item that never resolved at all", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}})
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
|
||||
expireQueued(queueRepo, "al13")
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to")
|
||||
Expect(ia.Hash).To(BeEmpty())
|
||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("keeps the failure on the state row after the queue row is deleted", func() {
|
||||
conf.Server.CoverArtPriority = "external"
|
||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}})
|
||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||
ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary,
|
||||
Hash: "cafebabe", Source: "external:lastfm",
|
||||
})).To(Succeed())
|
||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
|
||||
expireQueued(queueRepo, "al12")
|
||||
|
||||
_, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(findQueued(queueRepo, "al", "al12")).To(BeNil())
|
||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(),
|
||||
"the queue row is gone, so this is the only remaining record of the failure")
|
||||
Expect(ia.Hash).To(Equal("cafebabe"), "recording the failure must not disturb the served art")
|
||||
})
|
||||
|
||||
// Media files are excluded from RecheckKinds, so an absent row here would never be
|
||||
// revisited: a transient read error would look permanent.
|
||||
It("does not settle absent on exhaustion for a kind with no recheck path", func() {
|
||||
@ -371,12 +433,7 @@ var _ = Describe("Worker", func() {
|
||||
{ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true},
|
||||
})
|
||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
|
||||
for k, v := range queueRepo.Data {
|
||||
if v.ItemID == "mfX" {
|
||||
v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour))
|
||||
queueRepo.Data[k] = v
|
||||
}
|
||||
}
|
||||
expireQueued(queueRepo, "mfX")
|
||||
|
||||
n, err := w.drain(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@ -386,6 +443,8 @@ var _ = Describe("Worker", func() {
|
||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound),
|
||||
"no row leaves the track unresolved, so a later view can still recover it")
|
||||
// Known gap: with no row and no absent settle, there is nowhere to keep the failure.
|
||||
// Creating one here would write an empty hash, which every reader treats as absent.
|
||||
})
|
||||
|
||||
It("resolves a private playlist under an admin context instead of failing forever", func() {
|
||||
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@ -26,6 +28,13 @@ var (
|
||||
PublicTokenAuth *jwtauth.JWTAuth
|
||||
)
|
||||
|
||||
// Audiences a session token can be scoped to. A token with no audience is accepted anywhere.
|
||||
const (
|
||||
AudienceJellyfin = "jellyfin"
|
||||
AudienceSubsonic = "subsonic"
|
||||
AudienceNative = "native"
|
||||
)
|
||||
|
||||
// Init creates the JWTAuth objects from the secrets stored in the DB.
|
||||
// Missing or undecryptable secrets are regenerated and stored.
|
||||
func Init(ds model.DataStore) {
|
||||
@ -66,15 +75,20 @@ func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) {
|
||||
return token, err
|
||||
}
|
||||
|
||||
func CreateToken(u *model.User) (string, error) {
|
||||
claims := Claims{
|
||||
func userClaims(u *model.User, audience []string) Claims {
|
||||
return Claims{
|
||||
Issuer: consts.JWTIssuer,
|
||||
Subject: u.UserName,
|
||||
IssuedAt: time.Now(),
|
||||
UserID: u.ID,
|
||||
IsAdmin: u.IsAdmin,
|
||||
Epoch: u.TokenEpoch,
|
||||
Audience: audience,
|
||||
}
|
||||
token, _, err := TokenAuth.Encode(claims.ToMap())
|
||||
}
|
||||
|
||||
func CreateToken(u *model.User) (string, error) {
|
||||
token, _, err := TokenAuth.Encode(userClaims(u, nil).ToMap())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -82,10 +96,20 @@ func CreateToken(u *model.User) (string, error) {
|
||||
return TouchToken(token)
|
||||
}
|
||||
|
||||
// CreateAPIToken mints a non-expiring token scoped to one API, matching how Jellyfin
|
||||
// clients expect tokens to behave. Revocation is by token epoch, not expiry.
|
||||
func CreateAPIToken(u *model.User, audience string) (string, error) {
|
||||
_, token, err := TokenAuth.Encode(userClaims(u, []string{audience}).ToMap())
|
||||
return token, err
|
||||
}
|
||||
|
||||
func TouchToken(token jwt.Token) (string, error) {
|
||||
claims := ClaimsFromToken(token).
|
||||
WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
|
||||
_, newToken, err := TokenAuth.Encode(claims.ToMap())
|
||||
return TouchClaims(ClaimsFromToken(token))
|
||||
}
|
||||
|
||||
func TouchClaims(c Claims) (string, error) {
|
||||
c = c.WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
|
||||
_, newToken, err := TokenAuth.Encode(c.ToMap())
|
||||
return newToken, err
|
||||
}
|
||||
|
||||
@ -106,6 +130,29 @@ func ValidatePublic(tokenStr string) (Claims, error) {
|
||||
return ClaimsFromToken(token), nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTokenRevoked = errors.New("token revoked")
|
||||
ErrWrongAudience = errors.New("token not valid for this API")
|
||||
ErrWrongUser = errors.New("token issued for a different user")
|
||||
)
|
||||
|
||||
// CheckClaims gates a session token against the user it names. Callers must have already
|
||||
// verified the signature; this adds revocation and API scoping on top.
|
||||
func CheckClaims(c Claims, usr model.User, audience string) error {
|
||||
// Usernames can be reused: deleting a user and recreating the name yields a new random id
|
||||
// at epoch 0, which an old token would otherwise match.
|
||||
if c.UserID != "" && c.UserID != usr.ID {
|
||||
return ErrWrongUser
|
||||
}
|
||||
if c.Epoch != usr.TokenEpoch {
|
||||
return ErrTokenRevoked
|
||||
}
|
||||
if len(c.Audience) > 0 && !slices.Contains(c.Audience, audience) {
|
||||
return ErrWrongAudience
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
||||
u, err := ds.User(ctx).FindFirstAdmin()
|
||||
if err != nil {
|
||||
|
||||
@ -151,4 +151,113 @@ var _ = Describe("Auth", func() {
|
||||
Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CreateAPIToken", func() {
|
||||
var usr *model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
usr = &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 4}
|
||||
})
|
||||
|
||||
It("does not expire", func() {
|
||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
claims, err := auth.Validate(tokenStr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(claims.ExpiresAt.IsZero()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("carries the audience and the user's epoch", func() {
|
||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
claims, err := auth.Validate(tokenStr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(claims.Audience).To(Equal([]string{"jellyfin"}))
|
||||
Expect(claims.Epoch).To(Equal(4))
|
||||
Expect(claims.Subject).To(Equal("johndoe"))
|
||||
Expect(claims.UserID).To(Equal("123"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CreateToken with an epoch", func() {
|
||||
It("carries the epoch and still expires", func() {
|
||||
usr := &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 9}
|
||||
tokenStr, err := auth.CreateToken(usr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
claims, err := auth.Validate(tokenStr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(claims.Epoch).To(Equal(9))
|
||||
Expect(claims.Audience).To(BeEmpty())
|
||||
Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now()))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("TouchClaims", func() {
|
||||
It("preserves custom claims and refreshes the expiry", func() {
|
||||
tokenStr, err := auth.TouchClaims(auth.Claims{Subject: "johndoe", UserID: "123", Epoch: 5})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
claims, err := auth.Validate(tokenStr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(claims.Epoch).To(Equal(5))
|
||||
Expect(claims.Subject).To(Equal("johndoe"))
|
||||
Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now()))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CheckClaims", func() {
|
||||
usr := model.User{ID: "123", UserName: "johndoe", TokenEpoch: 2}
|
||||
|
||||
It("accepts a matching epoch and audience", func() {
|
||||
c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}}
|
||||
Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed())
|
||||
})
|
||||
|
||||
It("accepts a token with no audience on any API", func() {
|
||||
c := auth.Claims{Epoch: 2}
|
||||
Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed())
|
||||
Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed())
|
||||
Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(Succeed())
|
||||
})
|
||||
|
||||
It("rejects a stale epoch", func() {
|
||||
c := auth.Claims{Epoch: 1, Audience: []string{auth.AudienceJellyfin}}
|
||||
Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(MatchError(auth.ErrTokenRevoked))
|
||||
})
|
||||
|
||||
It("rejects a token minted for another API", func() {
|
||||
c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}}
|
||||
Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(MatchError(auth.ErrWrongAudience))
|
||||
Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(MatchError(auth.ErrWrongAudience))
|
||||
})
|
||||
|
||||
It("accepts a multi-audience token that includes this API", func() {
|
||||
c := auth.Claims{Epoch: 2, Audience: []string{"other", auth.AudienceNative}}
|
||||
Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed())
|
||||
})
|
||||
|
||||
It("accepts a pre-upgrade token against a never-bumped user", func() {
|
||||
fresh := model.User{ID: "456", UserName: "newbie"}
|
||||
Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed())
|
||||
})
|
||||
|
||||
It("accepts a token whose user id matches", func() {
|
||||
c := auth.Claims{UserID: "123", Epoch: 2}
|
||||
Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed())
|
||||
})
|
||||
|
||||
It("rejects a token for a deleted user recreated under the same name", func() {
|
||||
recreated := model.User{ID: "new-random-id", UserName: "johndoe"}
|
||||
c := auth.Claims{UserID: "123", Audience: []string{auth.AudienceJellyfin}}
|
||||
Expect(auth.CheckClaims(c, recreated, auth.AudienceJellyfin)).To(MatchError(auth.ErrWrongUser))
|
||||
})
|
||||
|
||||
It("accepts a token that carries no user id", func() {
|
||||
fresh := model.User{ID: "456", UserName: "newbie"}
|
||||
Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -11,7 +11,8 @@ import (
|
||||
type Claims struct {
|
||||
// Standard JWT claims
|
||||
Issuer string
|
||||
Subject string // username for session tokens
|
||||
Subject string // username for session tokens
|
||||
Audience []string // which API may accept this token; empty means any
|
||||
IssuedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
|
||||
@ -22,6 +23,7 @@ type Claims struct {
|
||||
Format string // "f" - audio format
|
||||
BitRate int // "b" - audio bitrate
|
||||
ShareID string // "sid" - share ID for share stream tokens
|
||||
Epoch int // "ep" - the user's token_epoch at mint time
|
||||
}
|
||||
|
||||
// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode().
|
||||
@ -34,6 +36,9 @@ func (c Claims) ToMap() map[string]any {
|
||||
if c.Subject != "" {
|
||||
m[jwt.SubjectKey] = c.Subject
|
||||
}
|
||||
if len(c.Audience) > 0 {
|
||||
m[jwt.AudienceKey] = c.Audience
|
||||
}
|
||||
if !c.IssuedAt.IsZero() {
|
||||
m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix()
|
||||
}
|
||||
@ -58,6 +63,9 @@ func (c Claims) ToMap() map[string]any {
|
||||
if c.ShareID != "" {
|
||||
m["sid"] = c.ShareID
|
||||
}
|
||||
if c.Epoch != 0 {
|
||||
m["ep"] = c.Epoch
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@ -73,6 +81,7 @@ func ClaimsFromToken(token jwt.Token) Claims {
|
||||
c.Subject, _ = token.Subject()
|
||||
c.IssuedAt, _ = token.IssuedAt()
|
||||
c.ExpiresAt, _ = token.Expiration()
|
||||
c.Audience, _ = token.Audience()
|
||||
|
||||
var uid string
|
||||
if err := token.Get("uid", &uid); err == nil {
|
||||
@ -90,15 +99,24 @@ func ClaimsFromToken(token jwt.Token) Claims {
|
||||
if err := token.Get("f", &f); err == nil {
|
||||
c.Format = f
|
||||
}
|
||||
if err := token.Get("b", &c.BitRate); err != nil {
|
||||
var bf float64
|
||||
if err := token.Get("b", &bf); err == nil {
|
||||
c.BitRate = int(bf)
|
||||
}
|
||||
}
|
||||
c.BitRate = intClaim(token, "b")
|
||||
var sid string
|
||||
if err := token.Get("sid", &sid); err == nil {
|
||||
c.ShareID = sid
|
||||
}
|
||||
c.Epoch = intClaim(token, "ep")
|
||||
return c
|
||||
}
|
||||
|
||||
// intClaim reads a numeric claim, which a parsed token may decode as either int or float64.
|
||||
func intClaim(token jwt.Token, key string) int {
|
||||
var i int
|
||||
if err := token.Get(key, &i); err == nil {
|
||||
return i
|
||||
}
|
||||
var f float64
|
||||
if err := token.Get(key, &f); err == nil {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -105,4 +105,44 @@ var _ = Describe("Claims", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Audience and Epoch claims", func() {
|
||||
It("omits both when zero", func() {
|
||||
m := auth.Claims{ID: "artwork-id"}.ToMap()
|
||||
Expect(m).ToNot(HaveKey("aud"))
|
||||
Expect(m).ToNot(HaveKey("ep"))
|
||||
})
|
||||
|
||||
It("includes them when set", func() {
|
||||
m := auth.Claims{Subject: "u", Epoch: 3, Audience: []string{"jellyfin"}}.ToMap()
|
||||
Expect(m).To(HaveKeyWithValue("ep", 3))
|
||||
Expect(m).To(HaveKeyWithValue("aud", []string{"jellyfin"}))
|
||||
})
|
||||
|
||||
It("round-trips through a signed token", func() {
|
||||
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
_, tokenStr, err := tokenAuth.Encode(auth.Claims{
|
||||
Subject: "u", Epoch: 7, Audience: []string{"jellyfin"},
|
||||
}.ToMap())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
token, err := jwtauth.VerifyToken(tokenAuth, tokenStr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
claims := auth.ClaimsFromToken(token)
|
||||
Expect(claims.Epoch).To(Equal(7))
|
||||
Expect(claims.Audience).To(Equal([]string{"jellyfin"}))
|
||||
})
|
||||
|
||||
It("reads a token that has neither claim", func() {
|
||||
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
_, tokenStr, err := tokenAuth.Encode(auth.Claims{Subject: "u"}.ToMap())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
token, err := jwtauth.VerifyToken(tokenAuth, tokenStr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
claims := auth.ClaimsFromToken(token)
|
||||
Expect(claims.Epoch).To(BeZero())
|
||||
Expect(claims.Audience).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@ -232,6 +232,16 @@ var _ = Describe("Token", func() {
|
||||
_, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
|
||||
Expect(err).To(MatchError(ErrTokenStale))
|
||||
})
|
||||
|
||||
It("rejects a Jellyfin access token", func() {
|
||||
mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
|
||||
usr := &model.User{ID: "u1", UserName: "johndoe"}
|
||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = svc.ResolveRequestFromToken(ctx, tokenStr, mf, 0)
|
||||
Expect(err).To(MatchError(ErrTokenInvalid))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("paramsFromToken", func() {
|
||||
|
||||
10
db/db.go
10
db/db.go
@ -13,10 +13,15 @@ import (
|
||||
_ "github.com/navidrome/navidrome/db/migrations"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/hasher"
|
||||
"github.com/navidrome/navidrome/utils/natural"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
// NaturalCollation sorts embedded numbers by value. It is registered on every
|
||||
// connection, but only referenced when conf.Server.EnableNaturalSorting is on.
|
||||
const NaturalCollation = "NATSORT"
|
||||
|
||||
var (
|
||||
Dialect = "sqlite3"
|
||||
Driver = Dialect + "_custom"
|
||||
@ -32,7 +37,10 @@ func Db() *sql.DB {
|
||||
return singleton.GetInstance(func() *sql.DB {
|
||||
sql.Register(Driver, &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
|
||||
if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.RegisterCollation(NaturalCollation, natural.CompareFold)
|
||||
},
|
||||
})
|
||||
Path = conf.Server.DbPath
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE item_artwork ADD COLUMN trace jsonb NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE item_artwork ADD COLUMN last_failure jsonb NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE artwork_queue ADD COLUMN trace jsonb NOT NULL DEFAULT '[]';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE artwork_queue DROP COLUMN trace;
|
||||
ALTER TABLE item_artwork DROP COLUMN last_failure;
|
||||
ALTER TABLE item_artwork DROP COLUMN trace;
|
||||
7
db/migrations/20260822062750_add_user_token_epoch.sql
Normal file
7
db/migrations/20260822062750_add_user_token_epoch.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE user ADD COLUMN token_epoch INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE user DROP COLUMN token_epoch;
|
||||
@ -47,8 +47,13 @@ var redacted = &Hook{
|
||||
|
||||
// External services query params. Values can be JWTs (dots, dashes), so match everything up
|
||||
// to the next query separator or whitespace, not just word chars. A [\w]+ class would stop
|
||||
// at a JWT's first '.' and leak its payload and signature.
|
||||
"([^\\w]api_key=)[^&\\s]+",
|
||||
// at a JWT's first '.' and leak its payload and signature. Case-insensitive with an
|
||||
// optional underscore: the API accepts api_key, apikey and ApiKey alike.
|
||||
"(?i)([^\\w]api_?key=)[^&\\s]+",
|
||||
|
||||
// Sensitive request headers, logged as a JSON blob at trace level and never matched by the
|
||||
// query-param patterns above. Blank the whole value array; values may hold escaped quotes.
|
||||
`(?i)("(?:Authorization|X-Emby-Token|X-MediaBrowser-Token|X-Nd-Authorization)":\[")[^\]]*("\])`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,9 @@ package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
@ -92,7 +94,7 @@ var _ = Describe("Logger", func() {
|
||||
SetLogSourceLine(true)
|
||||
Error("A crash happened")
|
||||
// NOTE: This assertion breaks if the line number above changes
|
||||
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:93"))
|
||||
Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:95"))
|
||||
Expect(hook.LastEntry().Message).To(Equal("A crash happened"))
|
||||
})
|
||||
|
||||
@ -264,5 +266,30 @@ var _ = Describe("Logger", func() {
|
||||
msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1"
|
||||
Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1"))
|
||||
})
|
||||
|
||||
DescribeTable("redacts every api_key spelling the Jellyfin API accepts",
|
||||
func(param string) {
|
||||
msg := "/jellyfin/Audio/abc/File?" + param + "=SECRET&other=1"
|
||||
Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/File?" + param + "=[REDACTED]&other=1"))
|
||||
},
|
||||
Entry("api_key", "api_key"),
|
||||
Entry("apikey", "apikey"),
|
||||
Entry("ApiKey", "ApiKey"),
|
||||
Entry("APIKEY", "APIKEY"),
|
||||
)
|
||||
|
||||
It("redacts sensitive request headers in a logged header blob", func() {
|
||||
h := http.Header{
|
||||
"Authorization": {`MediaBrowser Client="Finamp", Token="jwt-secret"`},
|
||||
"X-Emby-Token": {"emby-secret"},
|
||||
"X-Mediabrowser-Token": {"mb-secret"},
|
||||
"X-Nd-Authorization": {"Bearer nd-secret"},
|
||||
"User-Agent": {"Finamp/1.0"},
|
||||
}
|
||||
blob, _ := json.Marshal(h)
|
||||
got := Redact(string(blob))
|
||||
Expect(got).ToNot(ContainSubstring("secret"))
|
||||
Expect(got).To(ContainSubstring(`"User-Agent":["Finamp/1.0"]`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -51,6 +51,10 @@ type ItemArtwork struct {
|
||||
SourcePath string `structs:"source_path"`
|
||||
// RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath.
|
||||
RefMtime int64 `structs:"ref_mtime"`
|
||||
// Trace is the encoded walk that produced this state; LastFailure is the walk of the attempt
|
||||
// that exhausted the retry budget. Both are JSON, read back with artwork.DecodeTrace.
|
||||
Trace string `structs:"trace"`
|
||||
LastFailure string `structs:"last_failure"`
|
||||
// Nullable in the schema, but every insert must set them: these non-pointer fields cannot scan NULL.
|
||||
AttemptedAt time.Time `structs:"attempted_at"`
|
||||
UpdatedAt time.Time `structs:"updated_at"`
|
||||
@ -91,6 +95,8 @@ type ArtworkQueueItem struct {
|
||||
Attempts int `structs:"attempts"`
|
||||
RetryAt time.Time `structs:"retry_at"`
|
||||
EnqueuedAt time.Time `structs:"enqueued_at"`
|
||||
// Trace is why the last attempt failed. Only Get reads it; the drain projects it away.
|
||||
Trace string `structs:"trace"`
|
||||
}
|
||||
|
||||
// Queue priorities: higher drains first.
|
||||
@ -109,6 +115,8 @@ type ArtworkRepository interface {
|
||||
PurgeOrphans(createdBefore time.Time) (int64, error)
|
||||
GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error)
|
||||
PutItemArtwork(ia *ItemArtwork) error
|
||||
// PutLastFailure records the trace of the attempt that exhausted the retry budget.
|
||||
PutLastFailure(kind Kind, id, imageType, trace string) error
|
||||
DeleteForItems(kind Kind, ids []string) error
|
||||
// GetInfoForItems hydrates a page in one batched query.
|
||||
GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error)
|
||||
@ -126,8 +134,9 @@ type ArtworkQueueRepository interface {
|
||||
// EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a
|
||||
// request-triggered read-through never resets a failed resolution's backoff.
|
||||
EnqueuePreservingBackoff(items ...ArtworkQueueItem) error
|
||||
// EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff.
|
||||
EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time) (int64, error)
|
||||
// EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff, oldest
|
||||
// first; limit caps the selection, so already-queued rows use up budget (backpressure when the drain stalls).
|
||||
EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time, limit int) (int64, error)
|
||||
// EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority.
|
||||
EnqueueAllMissing(kind Kind, priority int) (int64, error)
|
||||
// EnqueueIfMissing inserts only for items with no item_artwork row yet.
|
||||
@ -145,17 +154,20 @@ type ArtworkQueueRepository interface {
|
||||
DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error)
|
||||
// MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches
|
||||
// seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility.
|
||||
MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error
|
||||
MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error
|
||||
// DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue.
|
||||
DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error
|
||||
Count() (int64, error)
|
||||
// CountByKindAndPriority reports the pending queue rows grouped by kind and priority.
|
||||
CountByKindAndPriority() ([]ArtworkQueueStat, error)
|
||||
// CountAbsent reports the absent states of a kind, and how many of those EnqueueStaleAbsent
|
||||
// would pick up at the given cutoff.
|
||||
// CountQueued reports the pending rows matching the kinds and priorities, grouped by both;
|
||||
// an empty filter means every one.
|
||||
CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error)
|
||||
// CountAbsent reports the absent states of a kind, and how many are past the given cutoff,
|
||||
// eligible for EnqueueStaleAbsent (which drains them limit rows per call).
|
||||
CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error)
|
||||
// PurgeDangling removes queue rows whose entity no longer exists.
|
||||
PurgeDangling() (int64, error)
|
||||
// PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one.
|
||||
PurgeQueued(kinds []Kind, priorities []int) (int64, error)
|
||||
}
|
||||
|
||||
type ArtworkQueueStat struct {
|
||||
|
||||
@ -6,6 +6,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type Kind struct {
|
||||
@ -40,6 +42,11 @@ var artworkKindMap = map[string]Kind{
|
||||
KindRadioArtwork.prefix: KindRadioArtwork,
|
||||
}
|
||||
|
||||
// KindPrefixes leaves the typed Kind domain for the item_kind column, or for a help string.
|
||||
func KindPrefixes(kinds []Kind) []string {
|
||||
return slice.Map(kinds, func(k Kind) string { return k.prefix })
|
||||
}
|
||||
|
||||
// ParseKind resolves an item_kind prefix (e.g. "al") to its Kind, reporting whether it was known.
|
||||
// Use it at string boundaries — URL params, the item_kind column — to enter the typed Kind domain.
|
||||
func ParseKind(prefix string) (Kind, bool) {
|
||||
|
||||
@ -2,6 +2,7 @@ package request
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
@ -9,15 +10,16 @@ import (
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
User = contextKey("user")
|
||||
Username = contextKey("username")
|
||||
Client = contextKey("client")
|
||||
Version = contextKey("version")
|
||||
Player = contextKey("player")
|
||||
Transcoding = contextKey("transcoding")
|
||||
ClientUniqueId = contextKey("clientUniqueId")
|
||||
ReverseProxyIp = contextKey("reverseProxyIp")
|
||||
InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins
|
||||
User = contextKey("user")
|
||||
Username = contextKey("username")
|
||||
Client = contextKey("client")
|
||||
Version = contextKey("version")
|
||||
Player = contextKey("player")
|
||||
Transcoding = contextKey("transcoding")
|
||||
ClientUniqueId = contextKey("clientUniqueId")
|
||||
ReverseProxyIp = contextKey("reverseProxyIp")
|
||||
InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins
|
||||
TokenEpochHolder = contextKey("tokenEpochHolder")
|
||||
)
|
||||
|
||||
var allKeys = []contextKey{
|
||||
@ -125,3 +127,32 @@ func AddValues(ctx, requestCtx context.Context) context.Context {
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
type tokenEpochHolder struct {
|
||||
value atomic.Int64
|
||||
}
|
||||
|
||||
// WithTokenEpochHolder installs a slot a handler can use to report a bumped token epoch
|
||||
// back to middleware that has already returned from the handler's perspective.
|
||||
func WithTokenEpochHolder(ctx context.Context) context.Context {
|
||||
h := &tokenEpochHolder{}
|
||||
h.value.Store(-1)
|
||||
return context.WithValue(ctx, TokenEpochHolder, h)
|
||||
}
|
||||
|
||||
func SetTokenEpoch(ctx context.Context, epoch int) {
|
||||
if h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder); ok {
|
||||
h.value.Store(int64(epoch))
|
||||
}
|
||||
}
|
||||
|
||||
func TokenEpochFrom(ctx context.Context) (int, bool) {
|
||||
h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if v := h.value.Load(); v >= 0 {
|
||||
return int(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
17
model/request/request_suite_test.go
Normal file
17
model/request/request_suite_test.go
Normal file
@ -0,0 +1,17 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// tests.Init is not used here: the tests package imports model/request, so importing it
|
||||
// back would create an import cycle.
|
||||
func TestRequest(t *testing.T) {
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Request Suite")
|
||||
}
|
||||
40
model/request/request_test.go
Normal file
40
model/request/request_test.go
Normal file
@ -0,0 +1,40 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Token epoch holder", func() {
|
||||
It("reports nothing when unset", func() {
|
||||
ctx := WithTokenEpochHolder(context.TODO())
|
||||
_, ok := TokenEpochFrom(ctx)
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("round-trips a value set by the handler", func() {
|
||||
ctx := WithTokenEpochHolder(context.TODO())
|
||||
SetTokenEpoch(ctx, 7)
|
||||
|
||||
epoch, ok := TokenEpochFrom(ctx)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(epoch).To(Equal(7))
|
||||
})
|
||||
|
||||
It("survives being wrapped in a derived context", func() {
|
||||
ctx := WithTokenEpochHolder(context.TODO())
|
||||
SetTokenEpoch(context.WithValue(ctx, contextKey("unrelated"), 1), 3)
|
||||
|
||||
epoch, ok := TokenEpochFrom(ctx)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(epoch).To(Equal(3))
|
||||
})
|
||||
|
||||
It("is a no-op with no holder installed", func() {
|
||||
Expect(func() { SetTokenEpoch(context.TODO(), 5) }).ToNot(Panic())
|
||||
_, ok := TokenEpochFrom(context.TODO())
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
})
|
||||
@ -22,6 +22,8 @@ type User struct {
|
||||
|
||||
// This is only available on the backend, and it is never sent over the wire
|
||||
Password string `structs:"-" json:"-"`
|
||||
// Bumped on password change to invalidate every issued token for this user.
|
||||
TokenEpoch int `structs:"-" json:"-"`
|
||||
// This is used to set or change a password when calling Put. If it is empty, the password is not changed.
|
||||
// It is received from the UI with the name "password"
|
||||
NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec
|
||||
|
||||
@ -113,7 +113,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
||||
"artist": "compilation, order_album_artist_name, order_album_name",
|
||||
"album_artist": "compilation, order_album_artist_name, order_album_name",
|
||||
// TODO Rename this to just year (or date)
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name",
|
||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + naturalSort("album.name"),
|
||||
"random": "random",
|
||||
"recently_added": recentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
@ -38,6 +39,44 @@ var _ = Describe("AlbumRepository", func() {
|
||||
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
|
||||
})
|
||||
|
||||
Describe("natural sorting", func() {
|
||||
var ids []string
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
ids = nil
|
||||
for _, n := range []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"} {
|
||||
aid := "nat-" + n
|
||||
ids = append(ids, aid)
|
||||
Expect(albumRepo.Put(&model.Album{
|
||||
ID: aid, LibraryID: 1, Name: n, OrderAlbumName: n,
|
||||
})).To(Succeed())
|
||||
}
|
||||
DeferCleanup(func() {
|
||||
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ids}))
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("sorts albums by name",
|
||||
func(naturalSorting, preferSortTags bool, expected []string) {
|
||||
conf.Server.EnableNaturalSorting = naturalSorting
|
||||
conf.Server.PreferSortTags = preferSortTags
|
||||
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
|
||||
albums, err := albumRepo.GetAll(model.QueryOptions{
|
||||
Sort: "name", Filters: squirrel.Eq{"album.id": ids},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(slice.Map(albums, func(a model.Album) string { return a.Name })).To(Equal(expected))
|
||||
},
|
||||
Entry("lexicographically by default", false, false,
|
||||
[]string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"}),
|
||||
Entry("by number value when natural sorting is enabled", true, false,
|
||||
[]string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}),
|
||||
Entry("by number value with sort tags preferred too", true, true,
|
||||
[]string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}),
|
||||
)
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
var Get = func(id string) (*model.Album, error) {
|
||||
album, err := albumRepo.Get(id)
|
||||
|
||||
@ -18,6 +18,7 @@ import (
|
||||
const enqueueChunkSize = 100
|
||||
|
||||
// Every insert writes these, in this order; the INSERT..SELECT forms must project them to match.
|
||||
// DequeueBatch also selects exactly these, to leave the drain's rows free of the trace it never reads.
|
||||
var enqueueColumns = []string{"item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at"}
|
||||
|
||||
type artworkQueueRepository struct {
|
||||
@ -42,11 +43,12 @@ func (r *artworkQueueRepository) Get(kind model.Kind, id, imageType string) (*mo
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
// Enqueue also resets enqueued_at, so a fresh request does not inherit an old row's spent retry budget.
|
||||
// Enqueue starts a fresh lifecycle: it resets enqueued_at (so a fresh request does not inherit an old
|
||||
// row's spent retry budget) and clears trace (so explain does not show a prior failure at attempts 0).
|
||||
func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error {
|
||||
return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
|
||||
priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at,
|
||||
attempts = 0, enqueued_at = excluded.enqueued_at`, items)
|
||||
attempts = 0, enqueued_at = excluded.enqueued_at, trace = '[]'`, items)
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error {
|
||||
@ -54,11 +56,12 @@ func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.Artwork
|
||||
priority = MAX(priority, excluded.priority)`, items)
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) {
|
||||
func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) {
|
||||
now := time.Now()
|
||||
return r.insertIfNotQueued("", `SELECT item_kind, item_id, image_type, ?, 0, ?, ?
|
||||
FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ?`,
|
||||
model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore)
|
||||
FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ?
|
||||
ORDER BY attempted_at LIMIT ?`,
|
||||
model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore, limit)
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) {
|
||||
@ -159,7 +162,7 @@ func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQ
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
||||
sel := Select("*").From(r.tableName).
|
||||
sel := Select(enqueueColumns...).From(r.tableName).
|
||||
Where(LtOrEq{"retry_at": time.Now()}).
|
||||
OrderBy("priority DESC", "enqueued_at ASC").
|
||||
Limit(uint64(n))
|
||||
@ -171,10 +174,11 @@ func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.A
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error {
|
||||
func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error {
|
||||
upd := Update(r.tableName).
|
||||
Set("attempts", Expr("attempts + 1")).
|
||||
Set("retry_at", retryAt).
|
||||
Set("trace", trace).
|
||||
Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt})
|
||||
_, err := r.executeSQL(upd)
|
||||
return err
|
||||
@ -188,20 +192,46 @@ func (r *artworkQueueRepository) PurgeDangling() (int64, error) {
|
||||
return purgeDangling(r.sqlRepository)
|
||||
}
|
||||
|
||||
// artworkQueueFilter returns no conditions for an empty filter, so an unfiltered DELETE keeps
|
||||
// SQLite's truncate path. It ignores retry_at: a backing-off row is pending work too.
|
||||
func artworkQueueFilter(kinds []model.Kind, priorities []int) And {
|
||||
var f And
|
||||
if len(kinds) > 0 {
|
||||
f = append(f, Eq{"item_kind": model.KindPrefixes(kinds)})
|
||||
}
|
||||
if len(priorities) > 0 {
|
||||
f = append(f, Eq{"priority": priorities})
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// CountQueued shares its filter with PurgeQueued, so a preview cannot count rows the delete misses.
|
||||
func (r *artworkQueueRepository) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) {
|
||||
sel := Select("item_kind", "priority", "count(*) as count").From(r.tableName).
|
||||
GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc")
|
||||
if f := artworkQueueFilter(kinds, priorities); len(f) > 0 {
|
||||
sel = sel.Where(f)
|
||||
}
|
||||
var res []model.ArtworkQueueStat
|
||||
err := r.queryAll(sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) {
|
||||
del := Delete(r.tableName)
|
||||
if f := artworkQueueFilter(kinds, priorities); len(f) > 0 {
|
||||
del = del.Where(f)
|
||||
}
|
||||
return r.executeSQL(del)
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) Count() (int64, error) {
|
||||
var res struct{ Count int64 }
|
||||
err := r.queryOne(Select("count(*) as count").From(r.tableName), &res)
|
||||
return res.Count, err
|
||||
}
|
||||
|
||||
func (r *artworkQueueRepository) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) {
|
||||
var res []model.ArtworkQueueStat
|
||||
err := r.queryAll(Select("item_kind", "priority", "count(*) as count").From(r.tableName).
|
||||
GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc"), &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is what a recheck would queue.
|
||||
// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is the pool a recheck drains from.
|
||||
func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) {
|
||||
var res model.ArtworkAbsentStat
|
||||
err := r.queryOne(Select("count(*) as total").
|
||||
|
||||
@ -127,19 +127,42 @@ var _ = Describe("ArtworkQueueRepository", func() {
|
||||
Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed())
|
||||
|
||||
future := time.Now().Add(48 * time.Hour)
|
||||
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future)).To(Succeed())
|
||||
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future, "[]")).To(Succeed())
|
||||
got, _ = repo.DequeueBatch(10)
|
||||
Expect(got).To(HaveLen(1), "the fresh re-enqueue stays immediately eligible")
|
||||
Expect(got[0].Attempts).To(BeZero(), "re-enqueue clears attempts, and the stale failure must not bump them")
|
||||
current := got[0].RetryAt
|
||||
|
||||
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future)).To(Succeed())
|
||||
Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future, `[{"c":"read","o":"error"}]`)).To(Succeed())
|
||||
got, _ = repo.DequeueBatch(10)
|
||||
Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at")
|
||||
all, _ := repo.Count()
|
||||
Expect(all).To(Equal(int64(1)))
|
||||
})
|
||||
|
||||
It("Enqueue clears a prior lifecycle's failure trace; EnqueuePreservingBackoff keeps it", func() {
|
||||
// Fail an attempt so the queue row carries a failure trace.
|
||||
Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed())
|
||||
backOff("al", "t1", time.Now().Add(-time.Hour))
|
||||
got, _ := repo.DequeueBatch(10)
|
||||
Expect(got).To(HaveLen(1))
|
||||
future := time.Now().Add(48 * time.Hour)
|
||||
Expect(repo.MarkFailedIfUnchanged("al", "t1", model.ImageTypePrimary, got[0].RetryAt, future, `[{"c":"read","o":"error"}]`)).To(Succeed())
|
||||
|
||||
// A continuation of the same lifecycle must retain the trace.
|
||||
Expect(repo.EnqueuePreservingBackoff(item("al", "t1", model.ArtworkPriorityBump))).To(Succeed())
|
||||
kept, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(kept.Trace).To(Equal(`[{"c":"read","o":"error"}]`))
|
||||
|
||||
// A fresh Enqueue resets attempts to 0, so the stale failure trace must be cleared with it.
|
||||
Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed())
|
||||
fresh, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fresh.Attempts).To(BeZero())
|
||||
Expect(fresh.Trace).To(Equal("[]"), "a fresh lifecycle has no last-attempt trace")
|
||||
})
|
||||
|
||||
It("Enqueue restarts the retry budget an existing row had spent", func() {
|
||||
Expect(repo.Enqueue(item("al", "e1", model.ArtworkPriorityScan))).To(Succeed())
|
||||
backOff("al", "e1", time.Now().Add(-time.Hour))
|
||||
@ -221,7 +244,7 @@ var _ = Describe("ArtworkQueueRepository", func() {
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old})).To(Succeed())
|
||||
|
||||
n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour))
|
||||
n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour), 100)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(int64(1)))
|
||||
|
||||
@ -232,6 +255,23 @@ var _ = Describe("ArtworkQueueRepository", func() {
|
||||
Expect(items[0].Priority).To(Equal(model.ArtworkPriorityRecheck))
|
||||
})
|
||||
|
||||
It("enqueues only the oldest stale absent states up to the limit", func() {
|
||||
awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
|
||||
now := time.Now()
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "oldest", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-72 * time.Hour)})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "older", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-60 * time.Hour)})).To(Succeed())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "old", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-48 * time.Hour)})).To(Succeed())
|
||||
|
||||
n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, now.Add(-24*time.Hour), 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(int64(2)))
|
||||
|
||||
items, err := repo.DequeueBatch(10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ids := slice.Map(items, func(it model.ArtworkQueueItem) string { return it.ItemID })
|
||||
Expect(ids).To(ConsistOf("oldest", "older"))
|
||||
})
|
||||
|
||||
It("enqueues entities that have no item_artwork row at all", func() {
|
||||
awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder())
|
||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed())
|
||||
@ -388,7 +428,7 @@ var _ = Describe("ArtworkQueueRepository", func() {
|
||||
Expect(repo.Enqueue(item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed())
|
||||
Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed())
|
||||
|
||||
Expect(repo.CountByKindAndPriority()).To(ConsistOf(
|
||||
Expect(repo.CountQueued(nil, nil)).To(ConsistOf(
|
||||
model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2},
|
||||
model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBump, Count: 1},
|
||||
model.ArtworkQueueStat{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1},
|
||||
@ -396,7 +436,7 @@ var _ = Describe("ArtworkQueueRepository", func() {
|
||||
})
|
||||
|
||||
It("reports an empty queue as no rows", func() {
|
||||
Expect(repo.CountByKindAndPriority()).To(BeEmpty())
|
||||
Expect(repo.CountQueued(nil, nil)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("counts absent states and how many are due for recheck", func() {
|
||||
@ -419,4 +459,71 @@ var _ = Describe("ArtworkQueueRepository", func() {
|
||||
Expect(repo.CountAbsent(model.KindRadioArtwork, time.Now())).To(Equal(model.ArtworkAbsentStat{}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PurgeQueued", func() {
|
||||
queuedIDs := func() []string {
|
||||
GinkgoHelper()
|
||||
got, err := repo.DequeueBatch(100)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
Expect(repo.Enqueue(
|
||||
item("ar", "ar-backfill", model.ArtworkPriorityBackfill),
|
||||
item("ar", "ar-bump", model.ArtworkPriorityBump),
|
||||
item("al", "al-backfill", model.ArtworkPriorityBackfill),
|
||||
item("mf", "mf-scan", model.ArtworkPriorityScan),
|
||||
)).To(Succeed())
|
||||
})
|
||||
|
||||
// CountQueued feeds the preview and PurgeQueued does the delete; they share one filter, so
|
||||
// every selection must count exactly what it deletes.
|
||||
DescribeTable("selects the same rows to count and to delete",
|
||||
func(kinds []model.Kind, priorities []int, deleted int, remaining []string) {
|
||||
counted, err := repo.CountQueued(kinds, priorities)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var total int64
|
||||
for _, s := range counted {
|
||||
total += s.Count
|
||||
}
|
||||
Expect(total).To(BeNumerically("==", deleted), "the preview must match the delete")
|
||||
|
||||
Expect(repo.PurgeQueued(kinds, priorities)).To(BeNumerically("==", deleted))
|
||||
Expect(queuedIDs()).To(ConsistOf(remaining))
|
||||
},
|
||||
Entry("only the given kinds", []model.Kind{model.KindArtistArtwork}, nil,
|
||||
2, []string{"al-backfill", "mf-scan"}),
|
||||
Entry("only the given priorities", nil, []int{model.ArtworkPriorityBackfill},
|
||||
2, []string{"ar-bump", "mf-scan"}),
|
||||
Entry("the intersection of both", []model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBackfill},
|
||||
1, []string{"ar-bump", "al-backfill", "mf-scan"}),
|
||||
Entry("everything, when neither filter is given", nil, nil,
|
||||
4, []string{}),
|
||||
Entry("several kinds and priorities at once",
|
||||
[]model.Kind{model.KindArtistArtwork, model.KindMediaFileArtwork},
|
||||
[]int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan},
|
||||
2, []string{"ar-bump", "al-backfill"}),
|
||||
Entry("nothing, leaving the queue alone", []model.Kind{model.KindPlaylistArtwork}, nil,
|
||||
0, []string{"ar-backfill", "ar-bump", "al-backfill", "mf-scan"}),
|
||||
)
|
||||
|
||||
It("deletes a row that is still backing off", func() {
|
||||
backOff("ar", "ar-bump", time.Now().Add(time.Hour))
|
||||
|
||||
Expect(repo.PurgeQueued([]model.Kind{model.KindArtistArtwork}, nil)).To(BeNumerically("==", 2))
|
||||
Expect(repo.Get(model.KindArtistArtwork, "ar-bump", model.ImageTypePrimary)).
|
||||
Error().To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
// A WHERE clause, even one that matches everything, costs SQLite its truncate optimization
|
||||
// and turns `artwork cancel --all` into a full scan of the queue.
|
||||
It("adds no conditions at all for an empty filter", func() {
|
||||
Expect(artworkQueueFilter(nil, nil)).To(BeEmpty())
|
||||
Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, nil)).To(HaveLen(1))
|
||||
Expect(artworkQueueFilter(nil, []int{model.ArtworkPriorityBump})).To(HaveLen(1))
|
||||
Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBump})).
|
||||
To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -134,11 +134,21 @@ func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error {
|
||||
}
|
||||
ins := Insert(itemArtworkTable).SetMap(values).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
|
||||
hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime,
|
||||
trace=excluded.trace, last_failure=excluded.last_failure,
|
||||
attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`)
|
||||
_, err = r.items.executeSQL(ins)
|
||||
return err
|
||||
}
|
||||
|
||||
// PutLastFailure records why an item exhausted its retry budget. It only updates an existing row:
|
||||
// inserting one would write an empty hash, which the rest of the system reads as a settled absent.
|
||||
func (r *artworkRepository) PutLastFailure(kind model.Kind, id, imageType, trace string) error {
|
||||
upd := Update(itemArtworkTable).Set("last_failure", trace).
|
||||
Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType})
|
||||
_, err := r.items.executeSQL(upd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *artworkRepository) DeleteForItems(kind model.Kind, ids []string) error {
|
||||
for chunk := range slices.Chunk(ids, artworkBatchSize) {
|
||||
if err := r.items.delete(Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil {
|
||||
|
||||
@ -28,6 +28,51 @@ var _ = Describe("ArtworkRepository", func() {
|
||||
repo = NewArtworkRepository(context.Background(), GetDBXBuilder())
|
||||
})
|
||||
|
||||
Context("resolution traces", func() {
|
||||
const traceJSON = `[{"c":"cover.*","o":"hit"}]`
|
||||
|
||||
It("round-trips the trace with the state row", func() {
|
||||
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t1",
|
||||
ImageType: model.ImageTypePrimary, Hash: "h1", Trace: traceJSON})).To(Succeed())
|
||||
|
||||
got, err := repo.GetItemArtwork(model.KindAlbumArtwork, "t1", model.ImageTypePrimary)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.Trace).To(Equal(traceJSON))
|
||||
Expect(got.LastFailure).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("replaces the trace when the item is resolved again", func() {
|
||||
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2",
|
||||
ImageType: model.ImageTypePrimary, Trace: traceJSON})).To(Succeed())
|
||||
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2",
|
||||
ImageType: model.ImageTypePrimary, Trace: `[{"c":"embedded","o":"hit"}]`})).To(Succeed())
|
||||
|
||||
got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t2", model.ImageTypePrimary)
|
||||
Expect(got.Trace).To(Equal(`[{"c":"embedded","o":"hit"}]`))
|
||||
})
|
||||
|
||||
It("records a last failure on an existing row", func() {
|
||||
Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t3",
|
||||
ImageType: model.ImageTypePrimary, Hash: "h3"})).To(Succeed())
|
||||
|
||||
Expect(repo.PutLastFailure(model.KindAlbumArtwork, "t3", model.ImageTypePrimary,
|
||||
`[{"c":"decode","o":"error"}]`)).To(Succeed())
|
||||
|
||||
got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t3", model.ImageTypePrimary)
|
||||
Expect(got.LastFailure).To(Equal(`[{"c":"decode","o":"error"}]`))
|
||||
Expect(got.Hash).To(Equal("h3"), "recording a failure must not disturb the served artwork")
|
||||
})
|
||||
|
||||
// Inserting here would write hash='', which every reader treats as a settled absent.
|
||||
It("never creates a row for an item that has no state", func() {
|
||||
Expect(repo.PutLastFailure(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary,
|
||||
`[{"c":"decode","o":"error"}]`)).To(Succeed())
|
||||
|
||||
_, err := repo.GetItemArtwork(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("image identity", func() {
|
||||
It("stores and retrieves an artwork by hash", func() {
|
||||
a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"}
|
||||
|
||||
@ -9,6 +9,8 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/fatih/structs"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
)
|
||||
|
||||
type PostMapper interface {
|
||||
@ -82,11 +84,28 @@ func (e existsCond) ToSql() (string, []any, error) {
|
||||
|
||||
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
|
||||
|
||||
// Convert the order_* columns to an expression using sort_* columns. Example:
|
||||
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
|
||||
// naturalSort makes a plain text column sort numbers by value, leaving it alone
|
||||
// otherwise so it keeps its declared collation. Parens guard buildSortOrder's space split.
|
||||
func naturalSort(col string) string {
|
||||
if !conf.Server.EnableNaturalSorting {
|
||||
return col
|
||||
}
|
||||
return fmt.Sprintf("(%s collate %s)", col, db.NaturalCollation)
|
||||
}
|
||||
|
||||
// Convert the order_* columns to a collated sort expression, falling back to the
|
||||
// sort_* column when those are preferred. Example:
|
||||
// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
|
||||
// It finds order column names anywhere in the substring
|
||||
func mapSortOrder(tableName, order string) string {
|
||||
order = strings.ToLower(order)
|
||||
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName)
|
||||
return sortOrderRegex.ReplaceAllString(order, repl)
|
||||
col := tableName + ".order_$1"
|
||||
if conf.Server.PreferSortTags {
|
||||
col = fmt.Sprintf("coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1)", tableName)
|
||||
}
|
||||
collation := "nocase"
|
||||
if conf.Server.EnableNaturalSorting {
|
||||
collation = db.NaturalCollation
|
||||
}
|
||||
repl := fmt.Sprintf("(%s collate %s)", col, collation)
|
||||
return sortOrderRegex.ReplaceAllString(strings.ToLower(order), repl)
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -85,22 +87,51 @@ var _ = Describe("Helpers", func() {
|
||||
})
|
||||
|
||||
Describe("mapSortOrder", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
It("does not change the sort string if there are no order columns", func() {
|
||||
sort := "album_name asc"
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(sort))
|
||||
})
|
||||
It("changes order columns to sort expression", func() {
|
||||
sort := "ORDER_ALBUM_NAME asc"
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` +
|
||||
` collate nocase) asc`))
|
||||
Expect(mapSortOrder("album", "album_name asc")).To(Equal("album_name asc"))
|
||||
})
|
||||
|
||||
DescribeTable("maps order columns to a collated expression",
|
||||
func(preferSortTags, naturalSorting bool, expected string) {
|
||||
conf.Server.PreferSortTags = preferSortTags
|
||||
conf.Server.EnableNaturalSorting = naturalSorting
|
||||
Expect(mapSortOrder("album", "ORDER_ALBUM_NAME asc")).To(Equal(expected))
|
||||
},
|
||||
Entry("qualified column", false, false,
|
||||
"(album.order_album_name collate nocase) asc"),
|
||||
Entry("natural collation", false, true,
|
||||
"(album.order_album_name collate NATSORT) asc"),
|
||||
Entry("sort tags preferred", true, false,
|
||||
`(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate nocase) asc`),
|
||||
Entry("sort tags preferred, natural collation", true, true,
|
||||
`(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate NATSORT) asc`),
|
||||
)
|
||||
|
||||
It("changes multiple order columns to sort expressions", func() {
|
||||
conf.Server.PreferSortTags = true
|
||||
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
|
||||
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
|
||||
Expect(mapSortOrder("album", sort)).To(Equal(
|
||||
`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
|
||||
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("naturalSort", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
It("leaves the column alone by default, keeping its declared collation", func() {
|
||||
Expect(naturalSort("media_file.title")).To(Equal("media_file.title"))
|
||||
})
|
||||
|
||||
It("applies the natural collation when enabled", func() {
|
||||
conf.Server.EnableNaturalSorting = true
|
||||
Expect(naturalSort("media_file.title")).To(Equal("(media_file.title collate NATSORT)"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -86,7 +86,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
|
||||
"title": "order_title",
|
||||
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
|
||||
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
|
||||
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
|
||||
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("media_file.title"),
|
||||
"random": "random",
|
||||
"created_at": "media_file.created_at",
|
||||
"recently_added": mediaFileRecentlyAddedSort(),
|
||||
|
||||
@ -60,7 +60,8 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
|
||||
"starred": annotationBoolFilter("starred"),
|
||||
})
|
||||
r.setSortMappings(map[string]string{
|
||||
"owner_name": "owner_name",
|
||||
"name": naturalSort("playlist.name"),
|
||||
"owner_name": naturalSort("owner_name"),
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
@ -24,6 +26,39 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("natural sorting", func() {
|
||||
var ids []string
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableNaturalSorting = true
|
||||
ctx := log.NewContext(GinkgoT().Context())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
|
||||
|
||||
ids = nil
|
||||
for _, n := range []string{"mix 1", "mix 10", "mix 2"} {
|
||||
pls := model.Playlist{Name: n, OwnerID: "userid"}
|
||||
Expect(repo.Put(&pls)).To(Succeed())
|
||||
ids = append(ids, pls.ID)
|
||||
}
|
||||
DeferCleanup(func() {
|
||||
for _, id := range ids {
|
||||
_ = repo.Delete(id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
It("sorts playlist names by number value", func() {
|
||||
all, err := repo.GetAll(model.QueryOptions{
|
||||
Sort: "name", Filters: squirrel.Eq{"playlist.id": ids},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(slice.Map(all, func(p model.Playlist) string { return p.Name })).To(
|
||||
Equal([]string{"mix 1", "mix 2", "mix 10"}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Count", func() {
|
||||
It("returns the number of playlists in the DB", func() {
|
||||
Expect(repo.CountAll()).To(Equal(int64(2)))
|
||||
|
||||
@ -56,7 +56,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
|
||||
"id": "playlist_tracks.id",
|
||||
"artist": "order_artist_name",
|
||||
"album_artist": "order_album_artist_name",
|
||||
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
|
||||
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("f.title"),
|
||||
"title": "order_title",
|
||||
"random": "random()",
|
||||
// To make sure these fields will be whitelisted
|
||||
|
||||
@ -113,10 +113,9 @@ func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName ..
|
||||
if len(tableName) > 0 {
|
||||
tn = tableName[0]
|
||||
}
|
||||
if conf.Server.PreferSortTags {
|
||||
if conf.Server.PreferSortTags || conf.Server.EnableNaturalSorting {
|
||||
for k, v := range mappings {
|
||||
v = mapSortOrder(tn, v)
|
||||
mappings[k] = v
|
||||
mappings[k] = mapSortOrder(tn, v)
|
||||
}
|
||||
}
|
||||
r.sortMappings = mappings
|
||||
|
||||
@ -18,6 +18,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/pocketbase/dbx"
|
||||
@ -126,14 +127,30 @@ func (r *userRepository) Put(u *model.User) error {
|
||||
}
|
||||
delete(values, "current_password")
|
||||
|
||||
// Save/update the user
|
||||
// The epoch bump rides the password UPDATE: as two statements they can interleave with a
|
||||
// concurrent change and leave a session valid that the other change should have revoked.
|
||||
update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values)
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
return err
|
||||
var isNewUser bool
|
||||
var epoch int
|
||||
if u.NewPassword != "" {
|
||||
var res struct{ TokenEpoch int }
|
||||
err = r.queryOne(update.Set("token_epoch", Expr("token_epoch + 1")).
|
||||
Suffix("RETURNING token_epoch"), &res)
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
isNewUser = true
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
epoch = res.TokenEpoch
|
||||
}
|
||||
} else {
|
||||
count, err := r.executeSQL(update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
isNewUser = count == 0
|
||||
}
|
||||
|
||||
isNewUser := count == 0
|
||||
if isNewUser {
|
||||
values["created_at"] = time.Now()
|
||||
insert := Insert(r.tableName).SetMap(values)
|
||||
@ -163,6 +180,12 @@ func (r *userRepository) Put(u *model.User) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Only the caller's own token can be refreshed in-flight; an admin resetting another
|
||||
// user must keep their own epoch.
|
||||
if u.NewPassword != "" && !isNewUser && loggedUser(r.ctx).ID == u.ID {
|
||||
request.SetTokenEpoch(r.ctx, epoch)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/deluan/rest"
|
||||
@ -13,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -683,4 +685,159 @@ var _ = Describe("UserRepository", func() {
|
||||
Expect(query).To(ContainSubstring("user.id = {:p0}"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("token epoch", func() {
|
||||
var repo model.UserRepository
|
||||
var usr model.User
|
||||
|
||||
newUser := func() model.User {
|
||||
uid := id.NewRandom()
|
||||
// user_name is unique; suffix it so each It gets its own row in the shared suite DB.
|
||||
return model.User{ID: uid, UserName: "epoch-user-" + uid, Name: "Epoch", NewPassword: "hunter2"}
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true})
|
||||
repo = NewUserRepository(ctx, GetDBXBuilder())
|
||||
usr = newUser()
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
})
|
||||
|
||||
It("starts at zero for a new user", func() {
|
||||
got, err := repo.Get(usr.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.TokenEpoch).To(Equal(0))
|
||||
})
|
||||
|
||||
It("increments once per password change", func() {
|
||||
usr.NewPassword = "second"
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
got, err := repo.Get(usr.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.TokenEpoch).To(Equal(1))
|
||||
|
||||
usr.NewPassword = "third"
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
got, err = repo.Get(usr.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.TokenEpoch).To(Equal(2))
|
||||
})
|
||||
|
||||
It("leaves the epoch alone when the password is untouched", func() {
|
||||
usr.NewPassword = ""
|
||||
usr.Name = "Renamed"
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
|
||||
got, err := repo.Get(usr.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.TokenEpoch).To(Equal(0))
|
||||
Expect(got.Name).To(Equal("Renamed"))
|
||||
})
|
||||
|
||||
It("never signals the same epoch to two concurrent password changes", func() {
|
||||
// Each writer's epoch must be the one its own UPDATE produced.
|
||||
const callers = 4
|
||||
var mu sync.Mutex
|
||||
var signalled []int
|
||||
var wg sync.WaitGroup
|
||||
for range callers {
|
||||
wg.Go(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: usr.ID})
|
||||
ctx = request.WithTokenEpochHolder(ctx)
|
||||
own := NewUserRepository(ctx, GetDBXBuilder())
|
||||
|
||||
u := usr
|
||||
u.NewPassword = "concurrent"
|
||||
if err := own.Put(&u); err != nil {
|
||||
return // the shared in-memory test DB can raise SQLITE_LOCKED
|
||||
}
|
||||
epoch, ok := request.TokenEpochFrom(ctx)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
signalled = append(signalled, epoch)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
Expect(signalled).To(HaveLen(len(slice.Unique(signalled))),
|
||||
"an epoch was signalled to more than one writer: %v", signalled)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Put and the token epoch", func() {
|
||||
newRepo := func(actingUserID string) model.UserRepository {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true})
|
||||
ctx = request.WithTokenEpochHolder(ctx)
|
||||
return NewUserRepository(ctx, GetDBXBuilder())
|
||||
}
|
||||
|
||||
It("does not bump when creating a user", func() {
|
||||
repo := newRepo("admin")
|
||||
usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"}
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
|
||||
got, err := repo.Get(usr.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.TokenEpoch).To(Equal(0))
|
||||
})
|
||||
|
||||
It("bumps when the password changes", func() {
|
||||
repo := newRepo("admin")
|
||||
usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"}
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
|
||||
usr.NewPassword = "pw2"
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
|
||||
got, err := repo.Get(usr.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.TokenEpoch).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not bump on an edit that leaves the password alone", func() {
|
||||
repo := newRepo("admin")
|
||||
usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"}
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
|
||||
usr.NewPassword = ""
|
||||
usr.Name = "New Display Name"
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
|
||||
got, err := repo.Get(usr.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(got.TokenEpoch).To(Equal(0))
|
||||
})
|
||||
|
||||
It("signals the new epoch when a user changes their own password", func() {
|
||||
userID := id.NewRandom()
|
||||
repo := newRepo(userID)
|
||||
usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"}
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
|
||||
usr.NewPassword = "pw2"
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
|
||||
epoch, ok := request.TokenEpochFrom(repo.(*userRepository).ctx)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(epoch).To(Equal(1))
|
||||
})
|
||||
|
||||
It("does not signal when an admin changes someone else's password", func() {
|
||||
repo := newRepo("some-admin")
|
||||
usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"}
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
|
||||
usr.NewPassword = "pw2"
|
||||
Expect(repo.Put(&usr)).To(Succeed())
|
||||
|
||||
_, ok := request.TokenEpochFrom(repo.(*userRepository).ctx)
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -174,6 +174,14 @@ Capabilities define what your plugin can do. They're automatically detected base
|
||||
|
||||
Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports.
|
||||
|
||||
> **Returning "not found".** When you have no data for an item, return an empty response and no
|
||||
> error. In the Go PDK that is `return nil, nil`. Navidrome reads it as a definitive "not found"
|
||||
> and stops asking.
|
||||
>
|
||||
> Return an error only when the plugin itself failed, such as an unreachable API or a broken host
|
||||
> call. Navidrome retries failed calls with backoff. A plugin that errors on "no data" makes
|
||||
> Navidrome retry every item it has no data for.
|
||||
|
||||
| Function | Input | Output | Description |
|
||||
|-----------------------------------|----------------------------|----------------------------------|--------------------------|
|
||||
| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID |
|
||||
|
||||
@ -9,6 +9,9 @@ import "github.com/navidrome/navidrome/plugins/types"
|
||||
// Plugins implementing this capability can choose which methods to implement.
|
||||
// Each method is optional - plugins only need to provide the functionality they support.
|
||||
//
|
||||
// To say "no data for this item", return a nil response and a nil error. Return an error only when
|
||||
// the plugin itself failed, because Navidrome retries failed calls with backoff.
|
||||
//
|
||||
//nd:capability name=metadata
|
||||
type MetadataAgent interface {
|
||||
// GetArtistMBID retrieves the MusicBrainz ID for an artist.
|
||||
|
||||
@ -434,8 +434,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
return fmt.Errorf("manifest validation: %w", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.plugins[p.ID] = &plugin{
|
||||
loadedPlugin := &plugin{
|
||||
name: p.ID,
|
||||
path: p.Path,
|
||||
manifest: pkg.Manifest,
|
||||
@ -449,13 +448,16 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
|
||||
fsConfig: fsConfig,
|
||||
lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls),
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.plugins[p.ID] = loadedPlugin
|
||||
m.mu.Unlock()
|
||||
loaded = true
|
||||
|
||||
// Init is the plugin's first chance to run arbitrary code: open sockets, create task queues,
|
||||
// schedule work. Only a caller that already intends to reach the network asks for it.
|
||||
// Use the local: loads run concurrently, so reading the map back here would race the writes.
|
||||
if m.transient == nil || m.transient.runInit {
|
||||
callPluginInit(ctx, m.plugins[p.ID])
|
||||
callPluginInit(ctx, loadedPlugin)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@ -186,6 +186,9 @@ type TopSongsResponse struct {
|
||||
//
|
||||
// Plugins implementing this capability can choose which methods to implement.
|
||||
// Each method is optional - plugins only need to provide the functionality they support.
|
||||
//
|
||||
// To say "no data for this item", return a nil response and a nil error. Return an error only when
|
||||
// the plugin itself failed, because Navidrome retries failed calls with backoff.
|
||||
type Metadata interface{}
|
||||
|
||||
// ArtistMBIDProvider provides the GetArtistMBID function.
|
||||
|
||||
@ -184,6 +184,9 @@ type TopSongsResponse struct {
|
||||
//
|
||||
// Plugins implementing this capability can choose which methods to implement.
|
||||
// Each method is optional - plugins only need to provide the functionality they support.
|
||||
//
|
||||
// To say "no data for this item", return a nil response and a nil error. Return an error only when
|
||||
// the plugin itself failed, because Navidrome retries failed calls with backoff.
|
||||
type Metadata interface{}
|
||||
|
||||
// ArtistMBIDProvider provides the GetArtistMBID function.
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
FROM public.ecr.aws/docker/library/alpine
|
||||
FROM alpine
|
||||
RUN apk update && apk add jq msitools
|
||||
WORKDIR /workspace
|
||||
@ -12,10 +12,12 @@ import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
@ -262,7 +264,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
|
||||
if err != nil {
|
||||
if err != nil || !tokenAllowed(ctx) {
|
||||
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
return
|
||||
}
|
||||
@ -272,24 +274,88 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// JWTRefresher updates the expiry date of the received JWT token, and add the new one to the Authorization Header
|
||||
// tokenAllowed re-checks a JWT that actually identifies the resolved user. Header and
|
||||
// config auth carry no token, so they short-circuit to true.
|
||||
func tokenAllowed(ctx context.Context) bool {
|
||||
token, _, err := jwtauth.FromContext(ctx)
|
||||
if err != nil || token == nil {
|
||||
return true
|
||||
}
|
||||
usr, ok := request.UserFrom(ctx)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
claims := auth.ClaimsFromToken(token)
|
||||
if !strings.EqualFold(claims.Subject, usr.UserName) {
|
||||
return true
|
||||
}
|
||||
if err := auth.CheckClaims(claims, usr, auth.AudienceNative); err != nil {
|
||||
log.Warn(ctx, "Native API: rejected token", "user", claims.Subject, err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// refreshingWriter defers the refreshed-token header until the handler's first write, so an
|
||||
// epoch the handler bumped reaches the token the client stores.
|
||||
type refreshingWriter struct {
|
||||
http.ResponseWriter
|
||||
ctx context.Context
|
||||
token jwt.Token
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (w *refreshingWriter) setToken() {
|
||||
w.once.Do(func() {
|
||||
claims := auth.ClaimsFromToken(w.token)
|
||||
if epoch, ok := request.TokenEpochFrom(w.ctx); ok {
|
||||
claims.Epoch = epoch
|
||||
}
|
||||
newToken, err := auth.TouchClaims(claims)
|
||||
if err != nil {
|
||||
log.Error(w.ctx, "Could not sign new token", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set(consts.UIAuthorizationHeader, newToken)
|
||||
})
|
||||
}
|
||||
|
||||
func (w *refreshingWriter) WriteHeader(code int) {
|
||||
w.setToken()
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *refreshingWriter) Write(b []byte) (int, error) {
|
||||
w.setToken()
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Flush keeps the SSE events route working through the wrap.
|
||||
func (w *refreshingWriter) Flush() {
|
||||
w.setToken()
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap lets capability lookups, such as SSE's write deadline, see past this wrap.
|
||||
func (w *refreshingWriter) Unwrap() http.ResponseWriter {
|
||||
return w.ResponseWriter
|
||||
}
|
||||
|
||||
// JWTRefresher updates the expiry date of the received JWT token, and adds the new one to
|
||||
// the Authorization Header.
|
||||
func JWTRefresher(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
token, _, err := jwtauth.FromContext(ctx)
|
||||
if err != nil {
|
||||
token, _, err := jwtauth.FromContext(r.Context())
|
||||
if err != nil || token == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
newTokenString, err := auth.TouchToken(token)
|
||||
if err != nil {
|
||||
log.Error(r, "Could not sign new token", err)
|
||||
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set(consts.UIAuthorizationHeader, newTokenString)
|
||||
next.ServeHTTP(w, r)
|
||||
ctx := request.WithTokenEpochHolder(r.Context())
|
||||
rw := &refreshingWriter{ResponseWriter: w, ctx: ctx, token: token}
|
||||
next.ServeHTTP(rw, r.WithContext(ctx))
|
||||
rw.setToken()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -357,4 +358,138 @@ var _ = Describe("Auth", func() {
|
||||
Expect(u.IsAdmin).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Authenticator token gating", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var usr *model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SessionTimeout = time.Hour
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
ur := ds.User(context.TODO()).(*tests.MockedUserRepo)
|
||||
usr = &model.User{ID: "u1", UserName: "johndoe", NewPassword: "pw", TokenEpoch: 2}
|
||||
Expect(ur.Put(usr)).To(Succeed())
|
||||
})
|
||||
|
||||
serve := func(token string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", "/api/song", nil)
|
||||
r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
handler := JWTVerifier(Authenticator(ds)(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) },
|
||||
)))
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
It("accepts a current session token", func() {
|
||||
tokenStr, err := auth.CreateToken(usr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("rejects a jellyfin-scoped token", func() {
|
||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("rejects a token with a stale epoch", func() {
|
||||
tokenStr, err := auth.CreateToken(usr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
usr.TokenEpoch = 3
|
||||
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("ignores a stray token for someone else when config auto-login resolves the user", func() {
|
||||
conf.Server.DevAutoLoginUsername = usr.UserName
|
||||
tokenStr, err := auth.CreateToken(&model.User{UserName: "someone-else"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("rejects a stale-epoch token whose subject differs only in case from the resolved user", func() {
|
||||
tokenStr, err := auth.CreateToken(&model.User{UserName: strings.ToUpper(usr.UserName), TokenEpoch: usr.TokenEpoch})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
usr.TokenEpoch = 5
|
||||
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("JWTRefresher", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
// TouchClaims reads this; left at zero every refreshed token is born expired.
|
||||
conf.Server.SessionTimeout = time.Hour
|
||||
auth.Init(&tests.MockDataStore{})
|
||||
})
|
||||
|
||||
serveWith := func(handler http.HandlerFunc) *httptest.ResponseRecorder {
|
||||
usr := model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1}
|
||||
tokenStr, err := auth.CreateToken(&usr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
r := httptest.NewRequest("GET", "/api/song", nil)
|
||||
r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+tokenStr)
|
||||
w := httptest.NewRecorder()
|
||||
JWTVerifier(JWTRefresher(handler)).ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
It("writes a refreshed token when the handler writes a body", func() {
|
||||
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("writes a refreshed token when the handler writes no body", func() {
|
||||
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("picks up an epoch the handler reported", func() {
|
||||
w := serveWith(func(w http.ResponseWriter, r *http.Request) {
|
||||
request.SetTokenEpoch(r.Context(), 42)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(claims.Epoch).To(Equal(42))
|
||||
})
|
||||
|
||||
It("keeps the original epoch when the handler reports nothing", func() {
|
||||
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(claims.Epoch).To(Equal(1))
|
||||
})
|
||||
|
||||
It("propagates Flush to the underlying ResponseWriter", func() {
|
||||
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.(http.Flusher).Flush()
|
||||
})
|
||||
Expect(w.Flushed).To(BeTrue())
|
||||
})
|
||||
|
||||
It("exposes the underlying ResponseWriter via Unwrap, for http.ResponseController lookups", func() {
|
||||
var unwrapped http.ResponseWriter
|
||||
w := serveWith(func(w http.ResponseWriter, _ *http.Request) {
|
||||
u, ok := w.(interface{ Unwrap() http.ResponseWriter })
|
||||
Expect(ok).To(BeTrue())
|
||||
unwrapped = u.Unwrap()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
Expect(unwrapped).To(BeIdenticalTo(w))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -58,6 +58,8 @@ query param — all forms are accepted, matching what different clients do).
|
||||
`/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force
|
||||
surface.
|
||||
|
||||
Access tokens do not expire, matching real Jellyfin. They are revoked by a password change, which bumps the user's token epoch.
|
||||
|
||||
### Public user list (login picker)
|
||||
|
||||
`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the
|
||||
|
||||
@ -36,7 +36,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) {
|
||||
log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err)
|
||||
}
|
||||
|
||||
token, err := auth.CreateToken(usr)
|
||||
token, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
||||
if err != nil {
|
||||
api.internalError(w, r, err)
|
||||
return
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -61,6 +62,42 @@ var _ = Describe("Authentication", func() {
|
||||
It("rejects a malformed body", func() {
|
||||
Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("mints a non-expiring token scoped to the Jellyfin audience", func() {
|
||||
w := authenticate("admin", "password")
|
||||
var res dto.AuthenticationResult
|
||||
parseInto(w, &res)
|
||||
|
||||
claims, err := auth.Validate(res.AccessToken)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(claims.ExpiresAt.IsZero()).To(BeTrue())
|
||||
Expect(claims.Audience).To(Equal([]string{"jellyfin"}))
|
||||
Expect(claims.Subject).To(Equal("admin"))
|
||||
})
|
||||
|
||||
It("revokes an already-issued token when the user's epoch is bumped", func() {
|
||||
w := authenticate("admin", "password")
|
||||
var res dto.AuthenticationResult
|
||||
parseInto(w, &res)
|
||||
|
||||
r := httptest.NewRequest("GET", "/Users/Me", nil)
|
||||
r.Header.Set("X-Emby-Token", res.AccessToken)
|
||||
pw := httptest.NewRecorder()
|
||||
router.ServeHTTP(pw, r)
|
||||
Expect(pw.Code).To(Equal(http.StatusOK))
|
||||
|
||||
// A real password change through the repository, which is what revokes in production.
|
||||
admin, err := ds.User(ctx).Get(testID("admin-1"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
admin.NewPassword = "rotated"
|
||||
Expect(ds.User(ctx).Put(admin)).To(Succeed())
|
||||
|
||||
r = httptest.NewRequest("GET", "/Users/Me", nil)
|
||||
r.Header.Set("X-Emby-Token", res.AccessToken)
|
||||
pw = httptest.NewRecorder()
|
||||
router.ServeHTTP(pw, r)
|
||||
Expect(pw.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GET /Users/Public", func() {
|
||||
|
||||
@ -167,6 +167,10 @@ func (api *Router) userFromToken(r *http.Request) (model.User, bool) {
|
||||
log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err)
|
||||
return model.User{}, false
|
||||
}
|
||||
if err := auth.CheckClaims(claims, *usr, auth.AudienceJellyfin); err != nil {
|
||||
log.Warn(r.Context(), "Jellyfin API: rejected token", "user", claims.Subject, err)
|
||||
return model.User{}, false
|
||||
}
|
||||
return *usr, true
|
||||
}
|
||||
|
||||
|
||||
@ -95,6 +95,52 @@ var _ = Describe("authenticate middleware", func() {
|
||||
api.authenticate(next).ServeHTTP(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
Context("token scoping and revocation", func() {
|
||||
var usr *model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
ur := ds.User(context.Background()).(*tests.MockedUserRepo)
|
||||
usr = &model.User{ID: testID("u2"), UserName: "bob", NewPassword: "secret", TokenEpoch: 3}
|
||||
Expect(ur.Put(usr)).To(Succeed())
|
||||
})
|
||||
|
||||
serve := func(token string) *httptest.ResponseRecorder {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items", nil)
|
||||
r.Header.Set("X-Emby-Token", token)
|
||||
api.authenticate(next).ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
It("accepts a jellyfin-scoped token with the current epoch", func() {
|
||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("rejects a token whose epoch is stale", func() {
|
||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
usr.TokenEpoch = 4
|
||||
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("rejects a token minted for another API", func() {
|
||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceNative)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("still accepts an unscoped session token", func() {
|
||||
tokenStr, err := auth.CreateToken(usr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(serve(tokenStr).Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("withPlayer middleware", func() {
|
||||
|
||||
80
server/nativeapi/user_password_token_refresh_test.go
Normal file
80
server/nativeapi/user_password_token_refresh_test.go
Normal file
@ -0,0 +1,80 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type noopPluginUnloader struct{}
|
||||
|
||||
func (noopPluginUnloader) UnloadDisabledPlugins(context.Context) {}
|
||||
|
||||
// Pins that the token-epoch handoff survives a real request through the real middleware chain.
|
||||
var _ = Describe("PUT /user/{id}: token refresh on self password change", func() {
|
||||
var ds model.DataStore
|
||||
var router http.Handler
|
||||
|
||||
BeforeEach(func() {
|
||||
// db.Db() is a process-wide singleton that this DeferCleanup closes for the whole binary; keep this the only real-DB spec in this package.
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableUserEditing = true
|
||||
conf.Server.EnableSharing = false
|
||||
conf.Server.SessionTimeout = time.Hour
|
||||
conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "nativeapi-user-refresh.db") + "?_journal_mode=WAL"
|
||||
DeferCleanup(db.Init(GinkgoT().Context()))
|
||||
|
||||
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||
auth.Init(ds)
|
||||
|
||||
userService := core.NewUser(ds, noopPluginUnloader{})
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
})
|
||||
|
||||
It("carries the bumped epoch in the refreshed token, not the epoch the token was minted with", func() {
|
||||
usr := model.User{UserName: "selfchanger", Name: "Self Changer", NewPassword: "old-password"}
|
||||
Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed())
|
||||
|
||||
token, err := auth.CreateToken(&usr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"userName": usr.UserName,
|
||||
"name": usr.Name,
|
||||
"currentPassword": "old-password",
|
||||
"password": "new-password",
|
||||
})
|
||||
req := createAuthenticatedRequest(http.MethodPut, "/user/"+usr.ID, bytes.NewBuffer(body), token)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusOK), w.Body.String())
|
||||
|
||||
refreshed := w.Header().Get(consts.UIAuthorizationHeader)
|
||||
Expect(refreshed).ToNot(BeEmpty())
|
||||
claims, err := auth.Validate(refreshed)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
reloaded, err := ds.User(GinkgoT().Context()).Get(usr.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(reloaded.TokenEpoch).To(Equal(1))
|
||||
Expect(claims.Epoch).To(Equal(reloaded.TokenEpoch))
|
||||
})
|
||||
})
|
||||
@ -178,7 +178,9 @@ func validateCredentials(user *model.User, pass, token, salt, jwt string) error
|
||||
switch {
|
||||
case jwt != "":
|
||||
claims, err := auth.Validate(jwt)
|
||||
valid = err == nil && claims.Subject == user.UserName
|
||||
valid = err == nil &&
|
||||
claims.Subject == user.UserName &&
|
||||
auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil
|
||||
case pass != "":
|
||||
if strings.HasPrefix(pass, "enc:") {
|
||||
if dec, err := hex.DecodeString(pass[4:]); err == nil {
|
||||
|
||||
@ -470,6 +470,7 @@ var _ = Describe("Middlewares", func() {
|
||||
var validToken string
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
auth.Init(ds)
|
||||
|
||||
@ -499,6 +500,36 @@ var _ = Describe("Middlewares", func() {
|
||||
Expect(err).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
|
||||
Context("JWT credentials", func() {
|
||||
var usr *model.User
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
auth.Init(ds)
|
||||
usr = &model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1}
|
||||
})
|
||||
|
||||
It("accepts an unscoped session token", func() {
|
||||
tokenStr, err := auth.CreateToken(usr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(validateCredentials(usr, "", "", "", tokenStr)).To(Succeed())
|
||||
})
|
||||
|
||||
It("rejects a jellyfin-scoped token", func() {
|
||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
|
||||
It("rejects a token with a stale epoch", func() {
|
||||
tokenStr, err := auth.CreateToken(usr)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
usr.TokenEpoch = 2
|
||||
Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -118,7 +118,7 @@ func (m *MockArtworkQueueRepo) DequeueBatch(n int, kinds ...string) ([]model.Art
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error {
|
||||
func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Err != nil {
|
||||
@ -128,6 +128,7 @@ func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string,
|
||||
if it, ok := m.Data[k]; ok && it.RetryAt.Equal(seenRetryAt) {
|
||||
it.Attempts++
|
||||
it.RetryAt = retryAt
|
||||
it.Trace = trace
|
||||
m.Data[k] = it
|
||||
}
|
||||
return nil
|
||||
@ -166,6 +167,30 @@ func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) {
|
||||
return purged, nil
|
||||
}
|
||||
|
||||
// queueFilterMatches mirrors artworkQueueFilter, so the mock cannot let a preview and a delete disagree.
|
||||
func queueFilterMatches(it model.ArtworkQueueItem, kinds []model.Kind, priorities []int) bool {
|
||||
prefixes := model.KindPrefixes(kinds)
|
||||
return (len(prefixes) == 0 || slices.Contains(prefixes, it.ItemKind)) &&
|
||||
(len(priorities) == 0 || slices.Contains(priorities, it.Priority))
|
||||
}
|
||||
|
||||
func (m *MockArtworkQueueRepo) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Err != nil {
|
||||
return 0, m.Err
|
||||
}
|
||||
var purged int64
|
||||
for k, it := range m.Data {
|
||||
if !queueFilterMatches(it, kinds, priorities) {
|
||||
continue
|
||||
}
|
||||
delete(m.Data, k)
|
||||
purged++
|
||||
}
|
||||
return purged, nil
|
||||
}
|
||||
|
||||
func (m *MockArtworkQueueRepo) Count() (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@ -175,7 +200,7 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) {
|
||||
return int64(len(m.Data)), nil
|
||||
}
|
||||
|
||||
func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) {
|
||||
func (m *MockArtworkQueueRepo) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Err != nil {
|
||||
@ -183,6 +208,9 @@ func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueSta
|
||||
}
|
||||
var res []model.ArtworkQueueStat
|
||||
for _, it := range m.Data {
|
||||
if !queueFilterMatches(it, kinds, priorities) {
|
||||
continue
|
||||
}
|
||||
i := slices.IndexFunc(res, func(s model.ArtworkQueueStat) bool {
|
||||
return s.ItemKind == it.ItemKind && s.Priority == it.Priority
|
||||
})
|
||||
@ -244,18 +272,24 @@ func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQu
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) {
|
||||
func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Err != nil || m.ItemArtworkSource == nil {
|
||||
return 0, m.Err
|
||||
}
|
||||
var stale []model.ItemArtwork
|
||||
for _, ia := range m.ItemArtworkSource.ItemData {
|
||||
if ia.ItemKind == kind.Prefix() && ia.Hash == "" && ia.AttemptedAt.Before(attemptedBefore) {
|
||||
stale = append(stale, ia)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(stale, func(a, b model.ItemArtwork) int { return a.AttemptedAt.Compare(b.AttemptedAt) })
|
||||
// The limit caps the selection, like the SQL's LIMIT before ON CONFLICT: queued rows use up budget.
|
||||
stale = stale[:min(limit, len(stale))]
|
||||
now := time.Now()
|
||||
var inserted int64
|
||||
for _, ia := range m.ItemArtworkSource.ItemData {
|
||||
if ia.ItemKind != kind.Prefix() || ia.Hash != "" || !ia.AttemptedAt.Before(attemptedBefore) {
|
||||
continue
|
||||
}
|
||||
for _, ia := range stale {
|
||||
k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)
|
||||
if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows
|
||||
continue
|
||||
|
||||
@ -122,6 +122,20 @@ func (m *MockArtworkRepo) GetItemArtwork(kind model.Kind, id, imageType string)
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockArtworkRepo) PutLastFailure(kind model.Kind, id, imageType, trace string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.Err != nil {
|
||||
return m.Err
|
||||
}
|
||||
key := iaKey(kind.Prefix(), id, imageType)
|
||||
if ia, ok := m.ItemData[key]; ok {
|
||||
ia.LastFailure = trace
|
||||
m.ItemData[key] = ia
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@ -10,15 +10,32 @@ import "strings"
|
||||
// or a positive value if a > b using natural sort ordering.
|
||||
//
|
||||
// When two numeric segments are numerically equal (e.g. "01" vs "1"),
|
||||
// comparison continues with the remaining suffixes. If one or both
|
||||
// strings end at the digit boundary, the raw strings are compared
|
||||
// lexically, which makes leading zeros significant as a tie-breaker
|
||||
// (e.g. "a01" < "a1", "a0" < "a00").
|
||||
// comparison continues with the remaining suffixes, and the padding
|
||||
// difference is kept as a final tie-breaker that only decides strings
|
||||
// that are otherwise equal (e.g. "a01" < "a1", "a0" < "a00"). Deferring
|
||||
// it that way is what keeps the ordering transitive, which SQLite
|
||||
// requires of a collating function.
|
||||
func Compare(a, b string) int {
|
||||
return compare(a, b, false)
|
||||
}
|
||||
|
||||
// CompareFold is Compare with ASCII case folding, matching SQLite's NOCASE
|
||||
// collation: only A-Z fold, bytes >= 0x80 are compared as-is.
|
||||
func CompareFold(a, b string) int {
|
||||
return compare(a, b, true)
|
||||
}
|
||||
|
||||
func compare(a, b string, fold bool) int {
|
||||
ia, ib := 0, 0
|
||||
// Set when two runs are numerically equal but differently padded. Applying it
|
||||
// immediately would break transitivity, so it only decides otherwise-equal strings.
|
||||
padTie := 0
|
||||
for ia < len(a) && ib < len(b) {
|
||||
ca, cb := a[ia], b[ib]
|
||||
da, db := isDigit(ca), isDigit(cb)
|
||||
if fold {
|
||||
ca, cb = lower(ca), lower(cb)
|
||||
}
|
||||
|
||||
switch {
|
||||
case da && db:
|
||||
@ -35,17 +52,11 @@ func Compare(a, b string) int {
|
||||
if c := compareNumbers(a[ia:endA], b[ib:endB]); c != 0 {
|
||||
return c
|
||||
}
|
||||
|
||||
// Numerically equal. If both sides have trailing data, continue
|
||||
// comparing after the digit runs. Otherwise fall through to
|
||||
// lexical comparison of the full remaining strings (which makes
|
||||
// leading-zero differences significant as a tie-breaker).
|
||||
if endA < len(a) && endB < len(b) {
|
||||
ia = endA
|
||||
ib = endB
|
||||
continue
|
||||
if t := strings.Compare(a[ia:endA], b[ib:endB]); t != 0 {
|
||||
padTie = t
|
||||
}
|
||||
return strings.Compare(a[ia:], b[ib:])
|
||||
ia = endA
|
||||
ib = endB
|
||||
case da != db:
|
||||
return int(ca) - int(cb)
|
||||
default:
|
||||
@ -56,7 +67,10 @@ func Compare(a, b string) int {
|
||||
ib++
|
||||
}
|
||||
}
|
||||
return (len(a) - ia) - (len(b) - ib)
|
||||
if c := (len(a) - ia) - (len(b) - ib); c != 0 {
|
||||
return c
|
||||
}
|
||||
return padTie
|
||||
}
|
||||
|
||||
// compareNumbers compares two digit strings numerically.
|
||||
@ -96,3 +110,10 @@ func stripZeros(s string) string {
|
||||
func isDigit(c byte) bool {
|
||||
return c >= '0' && c <= '9'
|
||||
}
|
||||
|
||||
func lower(c byte) byte {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
return c + 'a' - 'A'
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@ -13,17 +13,23 @@ func TestNatural(t *testing.T) {
|
||||
RunSpecs(t, "Natural Suite")
|
||||
}
|
||||
|
||||
// expectOrder asserts the sign of cmp(a, b) matches expected.
|
||||
func expectOrder(cmp func(string, string) int, a, b string, expected int) {
|
||||
result := cmp(a, b)
|
||||
switch {
|
||||
case expected < 0:
|
||||
ExpectWithOffset(1, result).To(BeNumerically("<", 0), "expected %q < %q", a, b)
|
||||
case expected > 0:
|
||||
ExpectWithOffset(1, result).To(BeNumerically(">", 0), "expected %q > %q", a, b)
|
||||
default:
|
||||
ExpectWithOffset(1, result).To(Equal(0), "expected %q == %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = Describe("Compare", func() {
|
||||
DescribeTable("returns correct ordering",
|
||||
func(a, b string, expected int) {
|
||||
result := natural.Compare(a, b)
|
||||
if expected < 0 {
|
||||
Expect(result).To(BeNumerically("<", 0), "expected %q < %q", a, b)
|
||||
} else if expected > 0 {
|
||||
Expect(result).To(BeNumerically(">", 0), "expected %q > %q", a, b)
|
||||
} else {
|
||||
Expect(result).To(Equal(0), "expected %q == %q", a, b)
|
||||
}
|
||||
expectOrder(natural.Compare, a, b, expected)
|
||||
},
|
||||
// Basic string ordering
|
||||
Entry("a < b", "a", "b", -1),
|
||||
@ -67,7 +73,9 @@ var _ = Describe("Compare", func() {
|
||||
Entry("a00b00 < a0b1", "a00b00", "a0b1", -1),
|
||||
Entry("a00b00 > a0b0", "a00b00", "a0b0", 1),
|
||||
Entry("a00b01 > a0b00", "a00b01", "a0b00", 1),
|
||||
Entry("a00b00 == a0b00", "a00b00", "a0b00", 0),
|
||||
// Distinct strings must not compare equal: the padding difference in the first
|
||||
// run decides once everything else matches.
|
||||
Entry("a00b00 > a0b00", "a00b00", "a0b00", 1),
|
||||
|
||||
// Leading zeros at end of string — lexical tie-break
|
||||
Entry("file01 < file1", "file01", "file1", -1),
|
||||
@ -109,8 +117,78 @@ var _ = Describe("Compare", func() {
|
||||
Entry("large: equal",
|
||||
"a100000000000000000000", "a100000000000000000000", 0),
|
||||
Entry("large: leading zeros with trailing data",
|
||||
"a00000000000000000000001x", "a1x", 0),
|
||||
"a00000000000000000000001x", "a1x", -1),
|
||||
Entry("large: leading zeros with trailing data (2)",
|
||||
"a099999999999999999999x", "a99999999999999999999x", 0),
|
||||
"a099999999999999999999x", "a99999999999999999999x", -1),
|
||||
)
|
||||
})
|
||||
|
||||
var _ = Describe("CompareFold", func() {
|
||||
DescribeTable("orders case-insensitively",
|
||||
func(a, b string, expected int) {
|
||||
expectOrder(natural.CompareFold, a, b, expected)
|
||||
},
|
||||
Entry("numbers compare numerically", "foo 2", "foo 10", -1),
|
||||
Entry("numbers compare numerically, reversed", "foo 10", "foo 2", 1),
|
||||
Entry("case is ignored", "apple 2", "Banana 10", -1),
|
||||
Entry("case is ignored, reversed", "Banana 10", "apple 2", 1),
|
||||
Entry("same word, different case, is equal", "ABC", "abc", 0),
|
||||
Entry("case ignored while comparing numbers", "Vol 2", "vol 10", -1),
|
||||
Entry("uppercase digits boundary", "Track9", "track10", -1),
|
||||
Entry("empty vs empty", "", "", 0),
|
||||
Entry("empty sorts first", "", "a", -1),
|
||||
Entry("non-ASCII is left untouched", "café 2", "café 10", -1),
|
||||
)
|
||||
|
||||
// SQLite requires a collating function to be transitive; if it is not, the behavior of
|
||||
// ORDER BY is undefined and paginated queries can drop or duplicate rows.
|
||||
It("is transitive, as a SQLite collation requires", func() {
|
||||
var corpus []string
|
||||
var build func(prefix string, depth int)
|
||||
build = func(prefix string, depth int) {
|
||||
if prefix != "" {
|
||||
corpus = append(corpus, prefix)
|
||||
}
|
||||
if depth == 0 {
|
||||
return
|
||||
}
|
||||
for _, c := range []string{"0", "1", "a"} {
|
||||
build(prefix+c, depth-1)
|
||||
}
|
||||
}
|
||||
build("", 3)
|
||||
|
||||
sign := func(n int) int {
|
||||
switch {
|
||||
case n < 0:
|
||||
return -1
|
||||
case n > 0:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
for _, a := range corpus {
|
||||
for _, b := range corpus {
|
||||
ab := sign(natural.CompareFold(a, b))
|
||||
for _, c := range corpus {
|
||||
bc := sign(natural.CompareFold(b, c))
|
||||
ac := sign(natural.CompareFold(a, c))
|
||||
if ab == 0 && bc == 0 {
|
||||
Expect(ac).To(Equal(0), "%q==%q and %q==%q but %q vs %q is %d", a, b, b, c, a, c, ac)
|
||||
}
|
||||
if ab < 0 && bc < 0 {
|
||||
Expect(ac).To(BeNumerically("<", 0), "%q<%q<%q but %q vs %q is %d", a, b, c, a, c, ac)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
It("matches Compare when both sides are already lowercase", func() {
|
||||
pairs := [][2]string{{"foo 2", "foo 10"}, {"a01", "a1"}, {"a", "aa"}, {"vol 3", "vol 3"}}
|
||||
for _, p := range pairs {
|
||||
Expect(natural.CompareFold(p[0], p[1])).To(Equal(natural.Compare(p[0], p[1])),
|
||||
"CompareFold(%q,%q) should match Compare", p[0], p[1])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user