Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user