Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126
@@ -2,8 +2,12 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"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.
|
// missingTrackView is one track whose file the scan could not find.
|
||||||
@@ -26,6 +30,21 @@ type missingTrackView struct {
|
|||||||
LastPlayedAt *string `json:"last_played_at"`
|
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.
|
// missingGroupView is a directory's worth of missing tracks.
|
||||||
//
|
//
|
||||||
// Grouping is the whole ergonomic argument for this surface. The case that
|
// Grouping is the whole ergonomic argument for this surface. The case that
|
||||||
@@ -37,6 +56,10 @@ type missingGroupView struct {
|
|||||||
Directory string `json:"directory"`
|
Directory string `json:"directory"`
|
||||||
MissingSince string `json:"missing_since"`
|
MissingSince string `json:"missing_since"`
|
||||||
Tracks []missingTrackView `json:"tracks"`
|
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
|
// adminMissingResponse is the paged envelope. Total counts TRACKS, not
|
||||||
@@ -79,11 +102,14 @@ func (h *handlers) handleListMissingTracks(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
groups := groupMissingByDirectory(rows)
|
||||||
|
h.attachReacquisitionState(r, q, rows, groups)
|
||||||
|
|
||||||
out := adminMissingResponse{
|
out := adminMissingResponse{
|
||||||
Total: total,
|
Total: total,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
Groups: groupMissingByDirectory(rows),
|
Groups: groups,
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
@@ -132,3 +158,93 @@ func groupMissingByDirectory(rows []dbq.ListMissingTracksRow) []missingGroupView
|
|||||||
}
|
}
|
||||||
return groups
|
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;
|
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
|
// 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
|
// operator reasons about is a folder: three reorganised albums are three
|
||||||
// decisions, not forty.
|
// decisions, not forty.
|
||||||
@@ -397,6 +409,7 @@ export type AdminMissingGroup = {
|
|||||||
directory: string;
|
directory: string;
|
||||||
missing_since: string;
|
missing_since: string;
|
||||||
tracks: AdminMissingTrack[];
|
tracks: AdminMissingTrack[];
|
||||||
|
reacquisition: AdminReacquisitionState | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminMissingResponse = {
|
export type AdminMissingResponse = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
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
|
// Fixed "now" so the thresholds are exercised deterministically rather than
|
||||||
// against the wall clock, which would make the minute boundary flaky.
|
// 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');
|
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`;
|
if (minutes >= 1) return `${minutes}m ago`;
|
||||||
return 'just now';
|
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 { pageTitle } from '$lib/branding';
|
||||||
import { FolderX, Music2 } from 'lucide-svelte';
|
import { FolderX, Music2 } from 'lucide-svelte';
|
||||||
import { createMissingFilesQuery } from '$lib/api/admin';
|
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 { coverUrl } from '$lib/media/covers';
|
||||||
import ReacquisitionSettingsCard from '$lib/components/ReacquisitionSettingsCard.svelte';
|
import ReacquisitionSettingsCard from '$lib/components/ReacquisitionSettingsCard.svelte';
|
||||||
import type { AdminMissingGroup } from '$lib/api/types';
|
import type { AdminMissingGroup } from '$lib/api/types';
|
||||||
@@ -26,6 +26,14 @@
|
|||||||
const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0));
|
const shown = $derived(groups.reduce((n, g) => n + g.tracks.length, 0));
|
||||||
const hasMore = $derived(offset + shown < total);
|
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 {
|
function trackCountLabel(n: number): string {
|
||||||
return n === 1 ? '1 track' : `${n} tracks`;
|
return n === 1 ? '1 track' : `${n} tracks`;
|
||||||
}
|
}
|
||||||
@@ -95,6 +103,28 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<ul class="divide-y divide-border">
|
||||||
{#each group.tracks as t (t.track_id)}
|
{#each group.tracks as t (t.track_id)}
|
||||||
<li class="flex items-center gap-3 px-4 py-2" data-testid="missing-track-row">
|
<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',
|
directory: '/music/Linkin Park/Minutes to Midnight',
|
||||||
missing_since: new Date(Date.now() - 3 * DAY).toISOString(),
|
missing_since: new Date(Date.now() - 3 * DAY).toISOString(),
|
||||||
|
reacquisition: null,
|
||||||
tracks: [
|
tracks: [
|
||||||
track('Given Up', { lastPlayed: new Date(Date.now() - 2 * DAY).toISOString() }),
|
track('Given Up', { lastPlayed: new Date(Date.now() - 2 * DAY).toISOString() }),
|
||||||
track('Bleed It Out')
|
track('Bleed It Out')
|
||||||
@@ -57,6 +58,7 @@ const response: AdminMissingResponse = {
|
|||||||
{
|
{
|
||||||
directory: '/music/Boards of Canada/Geogaddi',
|
directory: '/music/Boards of Canada/Geogaddi',
|
||||||
missing_since: new Date(Date.now() - 9 * DAY).toISOString(),
|
missing_since: new Date(Date.now() - 9 * DAY).toISOString(),
|
||||||
|
reacquisition: null,
|
||||||
tracks: [track('1969')]
|
tracks: [track('1969')]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -116,6 +118,45 @@ describe('admin missing files', () => {
|
|||||||
expect(screen.getByText(/couldn't load the missing-files list/i)).toBeTruthy();
|
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
|
// Paging only appears when it can do something: a single page of results
|
||||||
// should not render dead Previous/Next buttons.
|
// should not render dead Previous/Next buttons.
|
||||||
test('no pager when everything fits on one page', () => {
|
test('no pager when everything fits on one page', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user