refactor: track lengths and file sizes come from the shared formatters on Missing files, Duplicates, a want's Releases and the app download; an hour-long track reads 1:01:05, not 61:05 (M505 #5639 #5640)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Download, Smartphone } from 'lucide-svelte';
|
import { Download, Smartphone } from 'lucide-svelte';
|
||||||
import { api } from '#lib/api/client.js';
|
import { api } from '#lib/api/client.js';
|
||||||
|
import { formatBytes } from '#lib/utils/bytes.js';
|
||||||
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
|
||||||
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
import SettingRow from '#lib/components/settings/SettingRow.svelte';
|
||||||
|
|
||||||
@@ -20,11 +21,6 @@
|
|||||||
|
|
||||||
let info = $state<Info | null>(null);
|
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 () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const body = await api.get<Partial<WireInfo>>('/api/client/version');
|
const body = await api.get<Partial<WireInfo>>('/api/client/version');
|
||||||
@@ -44,7 +40,7 @@
|
|||||||
<SettingsGroup title="Android app">
|
<SettingsGroup title="Android app">
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="Minstrel for Android"
|
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" />
|
<Smartphone size={18} strokeWidth={1.5} class="text-text-secondary" aria-hidden="true" />
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { errMessage } from '#lib/api/errors.js';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
import { relativeTime } from '#lib/utils/relativeTime.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';
|
import type { Candidate, CandidateVerdict, GrabResult } from '#lib/api/types.js';
|
||||||
|
|
||||||
// Every release a want's last search found (M502 #5521), with the verdict
|
// Every release a want's last search found (M502 #5521), with the verdict
|
||||||
@@ -41,14 +42,9 @@
|
|||||||
blocked: 'bg-surface-hover text-text-muted'
|
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 {
|
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`);
|
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);
|
if (c.indexer) parts.push(c.folded_from.length ? `${c.indexer} +${c.folded_from.length}` : c.indexer);
|
||||||
return parts.filter(Boolean).join(' · ');
|
return parts.filter(Boolean).join(' · ');
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
import { errMessage } from '#lib/api/errors.js';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||||
|
import { formatBytes } from '#lib/utils/bytes.js';
|
||||||
|
import { formatDuration } from '#lib/media/duration.js';
|
||||||
import type {
|
import type {
|
||||||
AdminDuplicateGroup,
|
AdminDuplicateGroup,
|
||||||
AdminDuplicateMember,
|
AdminDuplicateMember,
|
||||||
@@ -134,16 +136,6 @@
|
|||||||
return `Same recording · ${match}% match`;
|
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 {
|
function countLabel(n: number, one: string, many: string): string {
|
||||||
return n === 1 ? `1 ${one}` : `${n} ${many}`;
|
return n === 1 ? `1 ${one}` : `${n} ${many}`;
|
||||||
}
|
}
|
||||||
@@ -440,7 +432,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 space-y-0.5 text-right text-xs text-text-muted">
|
<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 data-testid="member-history">{historyLabel(m)}</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -132,6 +132,17 @@ describe('admin duplicates', () => {
|
|||||||
expect(history).toEqual(['2 likes · 14 plays', 'no likes or plays']);
|
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.
|
// The badge must sit on the proposed survivor, not merely appear somewhere.
|
||||||
test('the keep badge is on the survivor row', () => {
|
test('the keep badge is on the survivor row', () => {
|
||||||
renderWith(response());
|
renderWith(response());
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import { errMessage } from '#lib/api/errors.js';
|
import { errMessage } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
import { relativeTime } from '#lib/utils/relativeTime.js';
|
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||||
|
import { formatDuration } from '#lib/media/duration.js';
|
||||||
import { coverUrl } from '#lib/media/covers.js';
|
import { coverUrl } from '#lib/media/covers.js';
|
||||||
import { needsYou, searched, wantStatus } from '#lib/wanted/wantStatus.js';
|
import { needsYou, searched, wantStatus } from '#lib/wanted/wantStatus.js';
|
||||||
import WantCandidates from '#lib/components/WantCandidates.svelte';
|
import WantCandidates from '#lib/components/WantCandidates.svelte';
|
||||||
@@ -43,12 +44,6 @@
|
|||||||
return n === 1 ? '1 track' : `${n} tracks`;
|
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
|
// 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.
|
// the list says what it is now, since this is where the question comes up.
|
||||||
let policy = $state<ReacquisitionSettings | null>(null);
|
let policy = $state<ReacquisitionSettings | null>(null);
|
||||||
@@ -269,7 +264,7 @@
|
|||||||
never played
|
never played
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -147,6 +147,19 @@ describe('/collection/missing', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The deciding fact for whether a missing file is worth chasing.
|
// 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', () => {
|
test('distinguishes a played track from one never played', () => {
|
||||||
renderWith(response);
|
renderWith(response);
|
||||||
expect(screen.getByText(/last played 2d ago/)).toBeTruthy();
|
expect(screen.getByText(/last played 2d ago/)).toBeTruthy();
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ describe('Settings › Account', () => {
|
|||||||
const { container } = render(AccountPage);
|
const { container } = render(AccountPage);
|
||||||
const download = await screen.findByRole('link', { name: /download/i });
|
const download = await screen.findByRole('link', { name: /download/i });
|
||||||
expect(download).toHaveAttribute('href', '/api/client/apk');
|
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());
|
const titles = Array.from(container.querySelectorAll('h2')).map((h) => h.textContent?.trim());
|
||||||
expect(titles[0]).toBe('Android app');
|
expect(titles[0]).toBe('Android app');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user