Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126

Merged
bvandeusen merged 23 commits from dev into main 2026-08-17 16:28:14 -04:00
6 changed files with 264 additions and 3 deletions
Showing only changes of commit 414dfb23b6 - Show all commits
+117 -1
View File
@@ -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
}
+13
View File
@@ -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 = {
+40 -1
View File
@@ -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');
});
});
+22
View File
@@ -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';
}
@@ -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 @@
</span>
</div>
<!-- What the sweeper has done about this folder. Without it the only
way to tell "not tried yet" from "asked twice, nothing came
back" is to go and read the Requests queue. -->
{#if group.reacquisition}
{@const r = group.reacquisition}
<p
class="border-b border-border px-4 py-2 text-xs text-text-secondary"
data-testid="reacquisition-state"
>
{#if r.gave_up_at}
<span class="text-action-destructive">Gave up</span>
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}
</p>
{/if}
<ul class="divide-y divide-border">
{#each group.tracks as t (t.track_id)}
<li class="flex items-center gap-3 px-4 py-2" data-testid="missing-track-row">
@@ -49,6 +49,7 @@ const response: AdminMissingResponse = {
{
directory: '/music/Linkin Park/Minutes to Midnight',
missing_since: new Date(Date.now() - 3 * DAY).toISOString(),
reacquisition: null,
tracks: [
track('Given Up', { lastPlayed: new Date(Date.now() - 2 * DAY).toISOString() }),
track('Bleed It Out')
@@ -57,6 +58,7 @@ const response: AdminMissingResponse = {
{
directory: '/music/Boards of Canada/Geogaddi',
missing_since: new Date(Date.now() - 9 * DAY).toISOString(),
reacquisition: null,
tracks: [track('1969')]
}
]
@@ -116,6 +118,45 @@ describe('admin missing files', () => {
expect(screen.getByText(/couldn't load the missing-files list/i)).toBeTruthy();
});
// Nothing attempted yet is the common case for a folder that just went
// missing; a line saying so would be noise on every row.
test('no re-acquisition line before anything has been attempted', () => {
renderWith(response);
expect(screen.queryByTestId('reacquisition-state')).toBeNull();
});
test('an in-flight re-acquisition says how often and when next', () => {
const withState = structuredClone(response);
withState.groups[0].reacquisition = {
attempts: 2,
last_attempt_at: new Date(Date.now() - 2 * DAY).toISOString(),
next_attempt_at: new Date(Date.now() + 4 * 3_600_000).toISOString(),
gave_up_at: null
};
renderWith(withState);
const line = screen.getByTestId('reacquisition-state');
expect(line.textContent).toMatch(/asked lidarr twice/i);
expect(line.textContent).toMatch(/last 2d ago/i);
// Forward-looking, not relativeTime — which would say "just now" for a
// future timestamp and read as nonsense.
expect(line.textContent).toMatch(/next in 4h/i);
});
test('a given-up album says so and says it can come back', () => {
const withState = structuredClone(response);
withState.groups[0].reacquisition = {
attempts: 3,
last_attempt_at: new Date(Date.now() - 5 * DAY).toISOString(),
next_attempt_at: null,
gave_up_at: new Date(Date.now() - 5 * DAY).toISOString()
};
renderWith(withState);
const line = screen.getByTestId('reacquisition-state');
expect(line.textContent).toMatch(/gave up/i);
expect(line.textContent).toMatch(/after 3 times/i);
expect(line.textContent).toMatch(/tried again/i);
});
// Paging only appears when it can do something: a single page of results
// should not render dead Previous/Next buttons.
test('no pager when everything fits on one page', () => {