diff --git a/internal/api/admin_library_missing.go b/internal/api/admin_library_missing.go index 6725f8ef..abbafb0b 100644 --- a/internal/api/admin_library_missing.go +++ b/internal/api/admin_library_missing.go @@ -2,8 +2,12 @@ package api import ( "net/http" + "time" + + "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition" ) // missingTrackView is one track whose file the scan could not find. @@ -26,6 +30,21 @@ type missingTrackView struct { LastPlayedAt *string `json:"last_played_at"` } +// reacquisitionStateView is what the sweeper has done about one album +// (milestone #290), attached to the group so the operator can tell "nothing +// has happened yet" from "asked twice, still nothing" without cross-checking +// the Requests queue. +// +// NextAttemptAt is computed rather than stored: the schedule is a function of +// the attempt count and the current settings, so persisting it would go stale +// the moment an operator edited the backoff. +type reacquisitionStateView struct { + Attempts int `json:"attempts"` + LastAttemptAt *string `json:"last_attempt_at"` + NextAttemptAt *string `json:"next_attempt_at"` + GaveUpAt *string `json:"gave_up_at"` +} + // missingGroupView is a directory's worth of missing tracks. // // Grouping is the whole ergonomic argument for this surface. The case that @@ -37,6 +56,10 @@ type missingGroupView struct { Directory string `json:"directory"` MissingSince string `json:"missing_since"` Tracks []missingTrackView `json:"tracks"` + // Nil when nothing has been attempted for this group's album — the + // common case for a folder that just went missing, and distinct from + // an attempts:0 record, which cannot occur. + Reacquisition *reacquisitionStateView `json:"reacquisition"` } // adminMissingResponse is the paged envelope. Total counts TRACKS, not @@ -79,11 +102,14 @@ func (h *handlers) handleListMissingTracks(w http.ResponseWriter, r *http.Reques return } + groups := groupMissingByDirectory(rows) + h.attachReacquisitionState(r, q, rows, groups) + out := adminMissingResponse{ Total: total, Limit: limit, Offset: offset, - Groups: groupMissingByDirectory(rows), + Groups: groups, } writeJSON(w, http.StatusOK, out) } @@ -132,3 +158,93 @@ func groupMissingByDirectory(rows []dbq.ListMissingTracksRow) []missingGroupView } return groups } + +// attachReacquisitionState decorates each group with what the sweeper has +// done about its album (milestone #290). +// +// Best-effort: this is context on a list whose primary job is showing what is +// missing, so a failure here leaves the groups bare rather than failing the +// page. One batched query for the whole page, not one per group. +// +// A group is keyed by directory while re-acquisition is keyed by album, and +// those line up in practice (an album's files live in one folder) but are not +// guaranteed to — a directory holding two albums takes the first album's +// state, which is the same album its first track belongs to. +func (h *handlers) attachReacquisitionState( + r *http.Request, + q *dbq.Queries, + rows []dbq.ListMissingTracksRow, + groups []missingGroupView, +) { + if len(groups) == 0 { + return + } + // Directory -> the album its first row belongs to, matching the order + // groupMissingByDirectory folded them in. + dirAlbum := make(map[string]pgtype.UUID, len(groups)) + ids := make([]pgtype.UUID, 0, len(groups)) + for _, row := range rows { + if _, seen := dirAlbum[row.Directory]; seen { + continue + } + dirAlbum[row.Directory] = row.AlbumID + ids = append(ids, row.AlbumID) + } + + states, err := q.GetReacquisitionForAlbums(r.Context(), ids) + if err != nil { + h.logger.Warn("admin missing: reacquisition state lookup failed", "err", err) + return + } + byAlbum := make(map[string]dbq.MissingReacquisition, len(states)) + for _, s := range states { + byAlbum[uuidToString(s.AlbumID)] = s + } + + // The settings service is optional in contexts that only wire routing + // (tests), and the backoff projection is decoration on a list whose real + // job is elsewhere — so fall back to the shipped defaults rather than + // making this a nil-pointer waiting to happen (rule #48). + cfg := reacquisition.Defaults + if h.reacqSettings != nil { + cfg = h.reacqSettings.Get() + } + for i := range groups { + albumID, ok := dirAlbum[groups[i].Directory] + if !ok { + continue + } + state, ok := byAlbum[uuidToString(albumID)] + if !ok { + continue + } + groups[i].Reacquisition = buildReacquisitionState(state, cfg.Backoff(state.Attempts)) + } +} + +// buildReacquisitionState renders one album's attempt record, projecting the +// next attempt from the last one plus the backoff the current settings imply. +func buildReacquisitionState( + state dbq.MissingReacquisition, + backoff time.Duration, +) *reacquisitionStateView { + out := &reacquisitionStateView{Attempts: int(state.Attempts)} + if state.LastAttemptAt.Valid { + s := formatTimestamp(state.LastAttemptAt) + out.LastAttemptAt = &s + // Only meaningful while more attempts remain; an album that has given + // up has no next attempt to promise. + if !state.GaveUpAt.Valid { + next := formatTimestamp(pgtype.Timestamptz{ + Time: state.LastAttemptAt.Time.Add(backoff), + Valid: true, + }) + out.NextAttemptAt = &next + } + } + if state.GaveUpAt.Valid { + s := formatTimestamp(state.GaveUpAt) + out.GaveUpAt = &s + } + return out +} diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index a954814c..51cab7fa 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -390,6 +390,18 @@ export type AdminMissingTrack = { last_played_at: string | null; }; +// What the re-acquisition sweeper has done about this group's album (#290). +// null when nothing has been attempted yet — the common case for a folder +// that just went missing, and distinct from attempts: 0, which cannot occur. +export type AdminReacquisitionState = { + attempts: number; + last_attempt_at: string | null; + // Projected from the last attempt plus the configured backoff, so it moves + // when the operator edits the schedule. Null once the album has given up. + next_attempt_at: string | null; + gave_up_at: string | null; +}; + // A directory's worth of missing tracks. The server groups because the unit an // operator reasons about is a folder: three reorganised albums are three // decisions, not forty. @@ -397,6 +409,7 @@ export type AdminMissingGroup = { directory: string; missing_since: string; tracks: AdminMissingTrack[]; + reacquisition: AdminReacquisitionState | null; }; export type AdminMissingResponse = { diff --git a/web/src/lib/utils/relativeTime.test.ts b/web/src/lib/utils/relativeTime.test.ts index 05fc20da..72d2184e 100644 --- a/web/src/lib/utils/relativeTime.test.ts +++ b/web/src/lib/utils/relativeTime.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { relativeTime } from './relativeTime'; +import { relativeTime, timeUntil } from './relativeTime'; // Fixed "now" so the thresholds are exercised deterministically rather than // against the wall clock, which would make the minute boundary flaky. @@ -43,3 +43,42 @@ describe('relativeTime', () => { expect(relativeTime(new Date(NOW.getTime() + 60_000).toISOString())).toBe('just now'); }); }); + +describe('timeUntil', () => { + test.each([ + ['minutes out', 42 * 60 * 1_000, 'in 42m'], + ['exactly an hour', 3_600_000, 'in 1h'], + ['hours out', 5 * 3_600_000, 'in 5h'], + ['exactly a day', 24 * 3_600_000, 'in 1d'], + ['days out', 6 * 24 * 3_600_000, 'in 6d'] + ])('%s', (_label, delta, expected) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(timeUntil(new Date(NOW.getTime() + delta).toISOString())).toBe(expected); + }); + + // A due-or-overdue attempt is the sweeper's next tick away, not "3h ago" — + // the operator wants to know it is imminent, not how late it is. + test('a moment already passed reads as imminent', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(timeUntil(ago(3 * 3_600_000))).toBe('any moment'); + }); + + test('under a minute reads as imminent', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(timeUntil(new Date(NOW.getTime() + 30_000).toISOString())).toBe('any moment'); + }); + + // The pair must not converge: relativeTime collapses a future timestamp to + // "just now", which is right for clock skew on a past event and wrong for a + // scheduled one. That difference is why both exist. + test('the two formatters disagree about the future, deliberately', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + const soon = new Date(NOW.getTime() + 4 * 3_600_000).toISOString(); + expect(relativeTime(soon)).toBe('just now'); + expect(timeUntil(soon)).toBe('in 4h'); + }); +}); diff --git a/web/src/lib/utils/relativeTime.ts b/web/src/lib/utils/relativeTime.ts index 7ab3398a..a70a8276 100644 --- a/web/src/lib/utils/relativeTime.ts +++ b/web/src/lib/utils/relativeTime.ts @@ -25,3 +25,25 @@ export function relativeTime(iso: string): string { if (minutes >= 1) return `${minutes}m ago`; return 'just now'; } + +/** + * Forward-looking companion to [relativeTime]: "in 4h", "in 2d", or "any + * moment" once the moment has passed. + * + * Separate function rather than a sign-aware relativeTime, because the two + * read differently in a sentence ("last tried 3d ago, next in 4h") and + * because relativeTime deliberately collapses future timestamps to "just + * now" — that is the right answer for a clock-skewed past event and the + * wrong one for a scheduled future one. + */ +export function timeUntil(iso: string): string { + const ms = new Date(iso).getTime() - Date.now(); + if (ms <= 0) return 'any moment'; + const days = Math.floor(ms / (24 * 3_600_000)); + if (days >= 1) return `in ${days}d`; + const hours = Math.floor(ms / 3_600_000); + if (hours >= 1) return `in ${hours}h`; + const minutes = Math.floor(ms / 60_000); + if (minutes >= 1) return `in ${minutes}m`; + return 'any moment'; +} diff --git a/web/src/routes/admin/missing-files/+page.svelte b/web/src/routes/admin/missing-files/+page.svelte index 3e8a4172..c2c91e68 100644 --- a/web/src/routes/admin/missing-files/+page.svelte +++ b/web/src/routes/admin/missing-files/+page.svelte @@ -2,7 +2,7 @@ import { pageTitle } from '$lib/branding'; import { FolderX, Music2 } from 'lucide-svelte'; import { createMissingFilesQuery } from '$lib/api/admin'; - import { relativeTime } from '$lib/utils/relativeTime'; + import { relativeTime, timeUntil } from '$lib/utils/relativeTime'; import { coverUrl } from '$lib/media/covers'; import ReacquisitionSettingsCard from '$lib/components/ReacquisitionSettingsCard.svelte'; import type { AdminMissingGroup } from '$lib/api/types'; @@ -26,6 +26,14 @@ const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0)); const hasMore = $derived(offset + shown < total); + // "once" / "twice" reads far better than "1 times" in the sentence these + // land in, and the count is almost always small. + function attemptLabel(n: number): string { + if (n === 1) return 'once'; + if (n === 2) return 'twice'; + return `${n} times`; + } + function trackCountLabel(n: number): string { return n === 1 ? '1 track' : `${n} tracks`; } @@ -95,6 +103,28 @@ + + {#if group.reacquisition} + {@const r = group.reacquisition} +

+ {#if r.gave_up_at} + Gave up + after {attemptLabel(r.attempts)} — last tried + {relativeTime(r.last_attempt_at ?? r.gave_up_at)}. It'll be tried again + if the files come back and go missing later. + {:else if r.last_attempt_at} + Asked Lidarr {attemptLabel(r.attempts)}, last + {relativeTime(r.last_attempt_at)}{#if r.next_attempt_at}, next + {timeUntil(r.next_attempt_at)}{/if}. + {/if} +

+ {/if} +