Compare commits
2
Commits
9e7d50b38e
...
af2d4f9859
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2d4f9859 | ||
|
|
622f248ea2 |
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { Download, Smartphone } from 'lucide-svelte';
|
||||
import { api } from '#lib/api/client.js';
|
||||
import { formatBytes } from '#lib/utils/bytes.js';
|
||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||
|
||||
@@ -20,11 +21,6 @@
|
||||
|
||||
let info = $state<Info | null>(null);
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const body = await api.get<Partial<WireInfo>>('/api/client/version');
|
||||
@@ -44,7 +40,7 @@
|
||||
<SettingsGroup title="Android app">
|
||||
<SettingRow
|
||||
label="Minstrel for Android"
|
||||
hint="{info.version}{info.sizeBytes > 0 ? ` · ${formatSize(info.sizeBytes)}` : ''} · sign in with this account"
|
||||
hint="{info.version}{info.sizeBytes > 0 ? ` · ${formatBytes(info.sizeBytes)}` : ''} · sign in with this account"
|
||||
>
|
||||
<Smartphone size={18} strokeWidth={1.5} class="text-text-secondary" aria-hidden="true" />
|
||||
<a
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import { formatBytes } from '#lib/utils/bytes.js';
|
||||
import type { Candidate, CandidateVerdict, GrabResult } from '#lib/api/types.js';
|
||||
|
||||
// Every release a want's last search found (M502 #5521), with the verdict
|
||||
@@ -41,14 +42,9 @@
|
||||
blocked: 'bg-surface-hover text-text-muted'
|
||||
};
|
||||
|
||||
function size(bytes: number): string {
|
||||
if (bytes <= 0) return '';
|
||||
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1e6))} MB`;
|
||||
}
|
||||
|
||||
function meta(c: Candidate): string {
|
||||
const parts = [c.format || 'format not stated', size(c.size_bytes)];
|
||||
// An indexer that states no size sends 0.
|
||||
const parts = [c.format || 'format not stated', c.size_bytes > 0 ? formatBytes(c.size_bytes) : ''];
|
||||
parts.push(c.seeders === 1 ? '1 seeder' : `${c.seeders} seeders`);
|
||||
if (c.indexer) parts.push(c.folded_from.length ? `${c.indexer} +${c.folded_from.length}` : c.indexer);
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { liveDownloadLine } from './downloadState.js';
|
||||
|
||||
describe('liveDownloadLine', () => {
|
||||
const g = (import_state: Parameters<typeof liveDownloadLine>[0]['import_state'], import_reason = '') => ({
|
||||
import_state,
|
||||
progress: 0.428,
|
||||
import_reason
|
||||
});
|
||||
|
||||
test('says each state of a running download', () => {
|
||||
expect(liveDownloadLine(g('downloading'))).toBe('Downloading 42%');
|
||||
expect(liveDownloadLine(g('importing'))).toBe('Downloaded; checking it is the album and filing it');
|
||||
expect(liveDownloadLine(g('waiting'))).toBe('Downloaded; waiting to be imported');
|
||||
expect(liveDownloadLine(g('waiting', 'the library is busy'))).toBe('Downloaded; the library is busy');
|
||||
expect(liveDownloadLine(g('review', '9 files against 12 tracks'))).toBe('Needs you: 9 files against 12 tracks');
|
||||
expect(liveDownloadLine(g('failed', 'disk full'))).toBe('Import failed: disk full');
|
||||
});
|
||||
|
||||
test('has nothing to say before the client reports', () => {
|
||||
expect(liveDownloadLine(g(''))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ImportState } from '#lib/api/types.js';
|
||||
|
||||
// A running download's state in a line: how far it has got, or what its
|
||||
// import is doing or waiting on. The wanted list and the admin Overview say
|
||||
// it the same way (M505). Null before the download client has reported on it.
|
||||
export function liveDownloadLine(g: { import_state: ImportState; progress: number; import_reason: string }): string | null {
|
||||
switch (g.import_state) {
|
||||
case 'downloading':
|
||||
return `Downloading ${Math.floor(g.progress * 100)}%`;
|
||||
case 'importing':
|
||||
return 'Downloaded; checking it is the album and filing it';
|
||||
case 'waiting':
|
||||
return `Downloaded; ${g.import_reason || 'waiting to be imported'}`;
|
||||
case 'review':
|
||||
return `Needs you: ${g.import_reason}`;
|
||||
case 'failed':
|
||||
return `Import failed: ${g.import_reason}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import type { SearchResult, Want } from '#lib/api/types.js';
|
||||
import { liveDownloadLine } from './downloadState.js';
|
||||
|
||||
// How a want reads in a line, wherever it is shown: the Collection's lists
|
||||
// and the Missing page's folders (M504).
|
||||
@@ -29,18 +30,9 @@ export function searched(w: Want, r: SearchResult): string {
|
||||
export function downloading(w: Want): string | null {
|
||||
const g = w.grab;
|
||||
if (!g?.live) return null;
|
||||
switch (g.import_state) {
|
||||
case 'downloading':
|
||||
return `Downloading ${Math.floor(g.progress * 100)}% · ${g.name}`;
|
||||
case 'importing':
|
||||
return `Downloaded; checking it is the album and filing it · ${g.name}`;
|
||||
case 'waiting':
|
||||
return `Downloaded; ${g.import_reason || 'waiting to be imported'}`;
|
||||
case 'review':
|
||||
return `Needs you: ${g.import_reason}`;
|
||||
case 'failed':
|
||||
return `Import failed: ${g.import_reason}`;
|
||||
}
|
||||
const line = liveDownloadLine(g);
|
||||
// While it downloads or files, the line names the release it took.
|
||||
if (line) return g.import_state === 'downloading' || g.import_state === 'importing' ? `${line} · ${g.name}` : line;
|
||||
const who = g.origin === 'operator' ? 'taken by you' : 'taken by the schedule';
|
||||
return `Downloading ${g.name}, ${who} ${relativeTime(g.grabbed_at)}`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import { formatBytes } from '#lib/utils/bytes.js';
|
||||
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js';
|
||||
import { liveDownloadLine } from '#lib/wanted/downloadState.js';
|
||||
import type { Download } from '#lib/api/types.js';
|
||||
|
||||
// The admin Overview (#5605) answers "does anything need me?" first: a
|
||||
@@ -67,21 +68,9 @@
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;
|
||||
|
||||
function downloadLine(d: Download): string {
|
||||
switch (d.import_state) {
|
||||
case 'downloading':
|
||||
return `${Math.floor(d.progress * 100)}%`;
|
||||
case 'importing':
|
||||
return 'Checking and filing it';
|
||||
case 'waiting':
|
||||
return d.import_reason || 'Waiting to be imported';
|
||||
case 'review':
|
||||
case 'failed':
|
||||
return `Needs you: ${d.import_reason}`;
|
||||
default:
|
||||
return 'Starting';
|
||||
}
|
||||
}
|
||||
// Worded as the wanted list words it; before the client reports, it is
|
||||
// simply downloading, as the Downloads list says.
|
||||
const downloadLine = (d: Download) => liveDownloadLine(d) ?? 'Downloading';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('admin/+layout.ts load gate', () => {
|
||||
// Importing at module load means the mocks attach to the same handles the
|
||||
// page binds against.
|
||||
import { createAdminStatusQuery, createAdminSummaryQuery, createScanStatusQuery } from '#lib/api/admin.js';
|
||||
import type { AdminStatus, AdminSummary } from '#lib/api/types.js';
|
||||
import type { AdminStatus, AdminSummary, Download } from '#lib/api/types.js';
|
||||
import OverviewPage from './+page.svelte';
|
||||
import AdminLayout from './+layout.svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
@@ -247,6 +247,29 @@ describe('admin Overview status', () => {
|
||||
]);
|
||||
expect(textOf('downloads-line')).toBe('1 downloading. In the last day, 4 imported. Activity');
|
||||
});
|
||||
|
||||
// Worded as the wanted list words a download (M505).
|
||||
test('each live download says what it is doing, as the wanted list says it', () => {
|
||||
const d = (id: string, import_state: Download['import_state'], import_reason = ''): Download => ({
|
||||
id, want_id: null, want_title: `Album ${id}`, want_artist: 'Blur', name: id, indexer: '', client: 'qbittorrent',
|
||||
quality: 'FLAC', origin: 'schedule', grabbed_at: minutesAgo(5), ended_at: null, end_reason: '', progress: 0.42,
|
||||
import_state, import_reason, imported_at: null, imported_files: 0, destination: '', files: [], skipped: 0,
|
||||
end_detail: '', seeding: false, ratio: 0, seed_released_at: null, condition: null
|
||||
});
|
||||
setupOverview(summary(), vi.fn(), status({
|
||||
downloads: {
|
||||
running: 3, needs_you: 1, needs_you_since: minutesAgo(5), imported_day: 0, given_up_day: 0, clients_down: [],
|
||||
live: [d('a', 'review', '9 files against 12 tracks'), d('b', 'downloading'), d('c', '')]
|
||||
}
|
||||
}));
|
||||
render(OverviewPage);
|
||||
const rows = screen.getAllByTestId('download-row').map((li) => li.textContent?.replace(/\s+/g, ' ').trim());
|
||||
expect(rows).toEqual([
|
||||
'Album a · Blur Needs you: 9 files against 12 tracks · 5m ago',
|
||||
'Album b · Blur Downloading 42% · 5m ago',
|
||||
'Album c · Blur Downloading · 5m ago'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin layout', () => {
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import { formatBytes } from '#lib/utils/bytes.js';
|
||||
import { formatDuration } from '#lib/media/duration.js';
|
||||
import type {
|
||||
AdminDuplicateGroup,
|
||||
AdminDuplicateMember,
|
||||
@@ -134,16 +136,6 @@
|
||||
return `Same recording · ${match}% match`;
|
||||
}
|
||||
|
||||
function sizeLabel(bytes: number): string {
|
||||
return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function durationLabel(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function countLabel(n: number, one: string, many: string): string {
|
||||
return n === 1 ? `1 ${one}` : `${n} ${many}`;
|
||||
}
|
||||
@@ -440,7 +432,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 space-y-0.5 text-right text-xs text-text-muted">
|
||||
<div>{m.file_format.toUpperCase()} · {sizeLabel(m.file_size)} · {durationLabel(m.duration_sec)}</div>
|
||||
<div data-testid="member-file">{m.file_format.toUpperCase()} · {formatBytes(m.file_size)} · {formatDuration(m.duration_sec)}</div>
|
||||
<div data-testid="member-history">{historyLabel(m)}</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -132,6 +132,17 @@ describe('admin duplicates', () => {
|
||||
expect(history).toEqual(['2 likes · 14 plays', 'no likes or plays']);
|
||||
});
|
||||
|
||||
// Size and length come from the shared formatters (M505).
|
||||
test('each copy shows its format, size and length', () => {
|
||||
const group = response().groups[0];
|
||||
group.members = [member('www-01'), member('www-02', { file_size: 812_000, duration_sec: 3665 })];
|
||||
renderWith(response({ groups: [group] }));
|
||||
expect(screen.getAllByTestId('member-file').map(text)).toEqual([
|
||||
'MP3 · 6.9 MB · 3:35',
|
||||
'MP3 · 812 KB · 1:01:05'
|
||||
]);
|
||||
});
|
||||
|
||||
// The badge must sit on the proposed survivor, not merely appear somewhere.
|
||||
test('the keep badge is on the survivor row', () => {
|
||||
renderWith(response());
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { errMessage } from '#lib/api/errors.js';
|
||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||
import { formatDuration } from '#lib/media/duration.js';
|
||||
import { coverUrl } from '#lib/media/covers.js';
|
||||
import { needsYou, searched, wantStatus } from '#lib/wanted/wantStatus.js';
|
||||
import WantCandidates from '#lib/components/WantCandidates.svelte';
|
||||
@@ -43,12 +44,6 @@
|
||||
return n === 1 ? '1 track' : `${n} tracks`;
|
||||
}
|
||||
|
||||
function durationLabel(sec: number): string {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// What happens to these is set under Acquisition (#5607); the line above
|
||||
// the list says what it is now, since this is where the question comes up.
|
||||
let policy = $state<ReacquisitionSettings | null>(null);
|
||||
@@ -269,7 +264,7 @@
|
||||
never played
|
||||
{/if}
|
||||
</span>
|
||||
<span class="shrink-0 text-xs text-text-muted">{durationLabel(t.duration_sec)}</span>
|
||||
<span class="shrink-0 text-xs text-text-muted" data-testid="missing-track-length">{formatDuration(t.duration_sec)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -147,6 +147,19 @@ describe('/collection/missing', () => {
|
||||
});
|
||||
|
||||
// The deciding fact for whether a missing file is worth chasing.
|
||||
// The page's lengths come from the shared formatter: an hour-long track
|
||||
// reads 1:01:05, not 61:05 (M505).
|
||||
test('each track shows its length, hours and all', () => {
|
||||
const long = structuredClone(response);
|
||||
long.groups[1].tracks[0].duration_sec = 3665;
|
||||
renderWith(long);
|
||||
expect(screen.getAllByTestId('missing-track-length').map((el) => el.textContent?.trim())).toEqual([
|
||||
'3:05',
|
||||
'3:05',
|
||||
'1:01:05'
|
||||
]);
|
||||
});
|
||||
|
||||
test('distinguishes a played track from one never played', () => {
|
||||
renderWith(response);
|
||||
expect(screen.getByText(/last played 2d ago/)).toBeTruthy();
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('Settings › Account', () => {
|
||||
const { container } = render(AccountPage);
|
||||
const download = await screen.findByRole('link', { name: /download/i });
|
||||
expect(download).toHaveAttribute('href', '/api/client/apk');
|
||||
expect(screen.getByText(/2026\.10\.10\.2300 · 30\.0 MB/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2026\.10\.10\.2300 · 31\.5 MB/)).toBeInTheDocument();
|
||||
const titles = Array.from(container.querySelectorAll('h2')).map((h) => h.textContent?.trim());
|
||||
expect(titles[0]).toBe('Android app');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user