release / govulncheck (push) Successful in 14s
release / web (push) Failing after 15s
release / go (push) Failing after 1m16s
release / integration (push) Failing after 4m49s
release / android (push) Successful in 4m59s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
A finished download is checked against MusicBrainz before anything is filed: the files are paired with an edition's tracks by position or path, and AcoustID, tag ids, lengths and titles say whether it is the album. What is the album is copied into the library as Artist/Album (Year)/Artist - Album - NN - Title, tagged with Picard's MusicBrainz ids, and scanned; the download keeps seeding. What is not is refused: set aside, removed from the client, and the album searched for again. A download the check cannot settle waits for a person, who imports it anyway or refuses it. Tracks the library already holds are skipped. The wanted list leaves Admin for a top-level Wanted view, admin-only and web-only, with tabs for Wanted, Activity (downloads and their imports, the ones needing a person first), Missing files, Held, Following (with the release-type profiles, moved off Integrations) and Blocked. The search settings gain "File new music into" when the library has several folders. The search pass logs why each failed search failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
8.0 KiB
Svelte
233 lines
8.0 KiB
Svelte
<script lang="ts">
|
|
import { useQueryClient } from '@tanstack/svelte-query';
|
|
import {
|
|
createReleaseProfilesQuery,
|
|
createReleaseProfile,
|
|
updateReleaseProfile,
|
|
deleteReleaseProfile
|
|
} from '#lib/api/wanted.js';
|
|
import { qk } from '#lib/api/queries.js';
|
|
import { errMessage } from '#lib/api/errors.js';
|
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
|
import type { ReleaseProfile, ReleaseProfileInput } from '#lib/api/types.js';
|
|
|
|
// Release-type profiles (M502 #5520): which kinds of release a followed
|
|
// artist's new releases must be to be wanted. A release passes when its
|
|
// primary type is ticked and so is every one of its secondary types, so a
|
|
// profile with no secondary types wants studio releases only and leaves out
|
|
// live recordings, compilations and remixes.
|
|
|
|
const client = useQueryClient();
|
|
const store = createReleaseProfilesQuery();
|
|
const query = $derived($store);
|
|
const profiles = $derived(query.data?.profiles ?? []);
|
|
const primaryTypes = $derived(query.data?.primary_types ?? []);
|
|
const secondaryTypes = $derived(query.data?.secondary_types ?? []);
|
|
|
|
// The profile being edited: its id, 'new', or null when none is.
|
|
let editing = $state<string | null>(null);
|
|
let form = $state<ReleaseProfileInput>({ name: '', primary_types: [], secondary_types: [], is_default: false });
|
|
let saving = $state(false);
|
|
|
|
function startEdit(p: ReleaseProfile) {
|
|
editing = p.id;
|
|
form = {
|
|
name: p.name,
|
|
primary_types: [...p.primary_types],
|
|
secondary_types: [...p.secondary_types],
|
|
is_default: p.is_default
|
|
};
|
|
}
|
|
|
|
function startNew() {
|
|
editing = 'new';
|
|
form = { name: '', primary_types: ['Album', 'EP'], secondary_types: [], is_default: false };
|
|
}
|
|
|
|
function toggle(list: string[], value: string): string[] {
|
|
return list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
|
|
}
|
|
|
|
async function refresh() {
|
|
await client.invalidateQueries({ queryKey: qk.releaseProfiles() });
|
|
await client.invalidateQueries({ queryKey: ['adminFollows'] });
|
|
}
|
|
|
|
async function save() {
|
|
saving = true;
|
|
try {
|
|
if (editing === 'new') {
|
|
await createReleaseProfile(form);
|
|
pushToast(`${form.name} added.`);
|
|
} else if (editing) {
|
|
await updateReleaseProfile(editing, form);
|
|
pushToast(`${form.name} saved. Followed artists judge new releases by it from their next check.`);
|
|
}
|
|
editing = null;
|
|
await refresh();
|
|
} catch (e) {
|
|
pushToast(errMessage(e), 'error');
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
async function remove(p: ReleaseProfile) {
|
|
try {
|
|
await deleteReleaseProfile(p.id);
|
|
pushToast(`${p.name} deleted.`);
|
|
if (editing === p.id) editing = null;
|
|
await refresh();
|
|
} catch (e) {
|
|
pushToast(errMessage(e), 'error');
|
|
}
|
|
}
|
|
|
|
function describe(p: ReleaseProfile): string {
|
|
const extra = p.secondary_types.length ? `, including ${p.secondary_types.join(', ')}` : ', studio only';
|
|
return p.primary_types.join(', ') + extra;
|
|
}
|
|
</script>
|
|
|
|
{#snippet editor()}
|
|
<div class="space-y-4 rounded-lg border border-border p-4" data-testid="profile-editor">
|
|
<label class="block">
|
|
<span class="text-sm text-text-primary">Name</span>
|
|
<input
|
|
type="text"
|
|
bind:value={form.name}
|
|
class="mt-1 w-full max-w-xs rounded border border-border bg-background px-2 py-1 text-sm text-text-primary
|
|
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
|
/>
|
|
</label>
|
|
|
|
<fieldset>
|
|
<legend class="text-sm text-text-primary">Kinds of release</legend>
|
|
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
|
{#each primaryTypes as t (t)}
|
|
<label class="flex items-center gap-1.5 text-sm text-text-secondary">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.primary_types.includes(t)}
|
|
onchange={() => (form.primary_types = toggle(form.primary_types, t))}
|
|
/>
|
|
{t}
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend class="text-sm text-text-primary">Also allow</legend>
|
|
<span class="block text-xs text-text-secondary">
|
|
Leave these unticked for studio releases only. A live album is wanted only when Live is
|
|
ticked here.
|
|
</span>
|
|
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1">
|
|
{#each secondaryTypes as t (t)}
|
|
<label class="flex items-center gap-1.5 text-sm text-text-secondary">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.secondary_types.includes(t)}
|
|
onchange={() => (form.secondary_types = toggle(form.secondary_types, t))}
|
|
/>
|
|
{t}
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</fieldset>
|
|
|
|
<label class="flex items-center gap-2 text-sm text-text-secondary">
|
|
<input type="checkbox" bind:checked={form.is_default} />
|
|
Default for new follows
|
|
</label>
|
|
|
|
<div class="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onclick={() => (editing = null)}
|
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={save}
|
|
disabled={saving || !form.name.trim() || form.primary_types.length === 0}
|
|
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-action-fg hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{saving ? 'Saving…' : 'Save profile'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
|
|
<section class="space-y-4 rounded-xl border border-border bg-surface p-5" data-testid="release-profiles-card">
|
|
<div>
|
|
<h3 class="font-display text-lg font-medium text-text-primary">Release-type profiles</h3>
|
|
<p class="mt-1 text-sm text-text-secondary">
|
|
Each followed artist is on one profile, which says which of their new releases are
|
|
wanted. Requests for a specific album aren't filtered.
|
|
</p>
|
|
</div>
|
|
|
|
{#if query.isPending}
|
|
<p class="text-sm text-text-secondary">Loading…</p>
|
|
{:else if query.isError}
|
|
<p class="text-sm text-action-destructive">Couldn't load the profiles.</p>
|
|
{:else}
|
|
<ul class="divide-y divide-border rounded-lg border border-border">
|
|
{#each profiles as p (p.id)}
|
|
<li class="space-y-3 p-3" data-testid="profile-row">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2 text-sm font-medium text-text-primary">
|
|
{p.name}
|
|
{#if p.is_default}
|
|
<span class="rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg">Default</span>
|
|
{/if}
|
|
</div>
|
|
<div class="text-xs text-text-secondary">
|
|
{describe(p)} · {p.follow_count === 1 ? '1 artist' : `${p.follow_count} artists`}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onclick={() => startEdit(p)}
|
|
aria-label="Edit {p.name}"
|
|
class="rounded-md border border-border px-2.5 py-1 text-xs text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
|
>
|
|
Edit
|
|
</button>
|
|
{#if !p.is_default}
|
|
<button
|
|
type="button"
|
|
onclick={() => remove(p)}
|
|
aria-label="Delete {p.name}"
|
|
class="rounded-md px-2.5 py-1 text-xs text-text-muted hover:bg-surface-hover hover:text-text-primary"
|
|
>
|
|
Delete
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{#if editing === p.id}
|
|
{@render editor()}
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
|
|
{#if editing === 'new'}
|
|
{@render editor()}
|
|
{:else}
|
|
<button
|
|
type="button"
|
|
onclick={startNew}
|
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover"
|
|
>
|
|
New profile
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
</section>
|