Filter library tracks on the server and add track publishing, bulk delete and album types
This commit is contained in:
@@ -1,21 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TrackStatus } from '$lib/types';
|
import type { LibraryTrack } from '$lib/types';
|
||||||
import { TRACK_STATUS } from '$lib/data/libraryTracks';
|
import { trackState } from '$lib/data/libraryTracks';
|
||||||
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
|
import StatusBadge from '$lib/components/ui/media/StatusBadge.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: TrackStatus;
|
track: Pick<LibraryTrack, 'status' | 'published'>;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { status, class: klass = '' }: Props = $props();
|
let { track, class: klass = '' }: Props = $props();
|
||||||
|
|
||||||
const info = $derived(TRACK_STATUS[status]);
|
const info = $derived(trackState(track));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<StatusBadge
|
<StatusBadge label={info.label} tone={info.tone} pulse={info.pulse} class={klass} />
|
||||||
label={info.label}
|
|
||||||
tone={info.tone}
|
|
||||||
pulse={status === 'Processing' || status === 'Pending'}
|
|
||||||
class={klass}
|
|
||||||
/>
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
layout="split"
|
layout="split"
|
||||||
{artist}
|
{artist}
|
||||||
showType
|
showType
|
||||||
|
initialType={album ? (album.type.toLowerCase() as 'album' | 'ep' | 'single') : 'album'}
|
||||||
initialTitle={album?.title}
|
initialTitle={album?.title}
|
||||||
initialDescription={album?.description ?? ''}
|
initialDescription={album?.description ?? ''}
|
||||||
initialReleaseYear={album?.releaseYear ?? new Date().getFullYear()}
|
initialReleaseYear={album?.releaseYear ?? new Date().getFullYear()}
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
const FADE = { duration: 200 };
|
const FADE = { duration: 200 };
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import SearchX from '@lucide/svelte/icons/search-x';
|
import SearchX from '@lucide/svelte/icons/search-x';
|
||||||
import type { LibraryTrack } from '$lib/types';
|
import type { LibraryTrack, Paged, TrackStatusCounts } from '$lib/types';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { filterLibraryTracks, TRACK_FILTERS, type TrackFilter } from '$lib/data/libraryTracks';
|
import {
|
||||||
|
canPublish,
|
||||||
|
filterCounts,
|
||||||
|
TRACK_FILTERS,
|
||||||
|
type TrackFilter
|
||||||
|
} from '$lib/data/libraryTracks';
|
||||||
|
import { createPagedList } from '$lib/state/pagedList.svelte';
|
||||||
import { plural } from '$lib/utils/format';
|
import { plural } from '$lib/utils/format';
|
||||||
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
||||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
|
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
|
||||||
import SelectionBar from '$lib/components/ui/primitives/SelectionBar.svelte';
|
import SelectionBar from '$lib/components/ui/primitives/SelectionBar.svelte';
|
||||||
import SearchInput from '$lib/components/ui/primitives/SearchInput.svelte';
|
import SearchInput from '$lib/components/ui/primitives/SearchInput.svelte';
|
||||||
import ChipGroup from '$lib/components/ui/primitives/ChipGroup.svelte';
|
import ChipGroup from '$lib/components/ui/primitives/ChipGroup.svelte';
|
||||||
@@ -21,54 +32,115 @@
|
|||||||
import ManageTrackList from '$lib/components/music/library/ManageTrackList.svelte';
|
import ManageTrackList from '$lib/components/music/library/ManageTrackList.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tracks: LibraryTrack[];
|
tracks: Paged<LibraryTrack>;
|
||||||
|
counts: TrackStatusCounts;
|
||||||
|
query: string;
|
||||||
|
filter: TrackFilter;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
notice?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { tracks, errorMessage }: Props = $props();
|
let { tracks, counts, query, filter, errorMessage, notice }: Props = $props();
|
||||||
|
|
||||||
let query = $state('');
|
const list = createPagedList<LibraryTrack>(
|
||||||
let filter = $state<TrackFilter>('all');
|
async (pageNumber) => {
|
||||||
|
const params = new URLSearchParams({ pageNumber: String(pageNumber), status: filter });
|
||||||
|
if (query) params.set('q', query);
|
||||||
|
const res = await fetch(`/api/library/tracks?${params}`);
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
return (await res.json()) as Paged<LibraryTrack>;
|
||||||
|
},
|
||||||
|
(track) => track.id
|
||||||
|
);
|
||||||
|
|
||||||
|
let search = $state('');
|
||||||
let selectedIds = $state<string[]>([]);
|
let selectedIds = $state<string[]>([]);
|
||||||
let pendingDelete = $state<LibraryTrack[]>([]);
|
let pendingDelete = $state<LibraryTrack[]>([]);
|
||||||
|
let pendingUnpublish = $state<LibraryTrack[]>([]);
|
||||||
const visible = $derived(filterLibraryTracks(tracks, query, filter));
|
let publishForm = $state<HTMLFormElement>();
|
||||||
const filterOptions = $derived(
|
let publishIds = $state('');
|
||||||
TRACK_FILTERS.map(({ value, label }) => ({
|
let publishValue = $state('true');
|
||||||
value,
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
label,
|
|
||||||
count: filterLibraryTracks(tracks, '', value).length
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const pendingTitle = $derived(
|
|
||||||
pendingDelete.length === 1
|
|
||||||
? `«${pendingDelete[0].title}»`
|
|
||||||
: plural(pendingDelete.length, 'canción', 'canciones')
|
|
||||||
);
|
|
||||||
|
|
||||||
function togglePlay(index: number) {
|
|
||||||
player.playOrToggle(visible, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const visibleIds = new Set(visible.map((track) => track.id));
|
list.reset(tracks);
|
||||||
if (selectedIds.some((id) => !visibleIds.has(id))) {
|
search = query;
|
||||||
selectedIds = selectedIds.filter((id) => visibleIds.has(id));
|
const ids = new Set(list.items.map((track) => track.id));
|
||||||
}
|
selectedIds = selectedIds.filter((id) => ids.has(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
function deleteSelected() {
|
const items = $derived(list.items);
|
||||||
const ids = new Set(selectedIds);
|
const hasFilters = $derived(query !== '' || filter !== 'all');
|
||||||
pendingDelete = visible.filter((track) => ids.has(track.id));
|
const counters = $derived(filterCounts(counts));
|
||||||
|
const filterOptions = $derived(
|
||||||
|
TRACK_FILTERS.map(({ value, label }) => ({ value, label, count: counters[value] }))
|
||||||
|
);
|
||||||
|
const selected = $derived(items.filter((track) => selectedIds.includes(track.id)));
|
||||||
|
const pendingTitle = (group: LibraryTrack[]) =>
|
||||||
|
group.length === 1 ? `«${group[0].title}»` : plural(group.length, 'canción', 'canciones');
|
||||||
|
|
||||||
|
function navigate(nextQuery: string, nextFilter: TrackFilter) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (nextQuery.trim()) params.set('q', nextQuery.trim());
|
||||||
|
if (nextFilter !== 'all') params.set('status', nextFilter);
|
||||||
|
const queryString = params.toString();
|
||||||
|
goto(queryString ? `?${queryString}` : '?', {
|
||||||
|
keepFocus: true,
|
||||||
|
noScroll: true,
|
||||||
|
replaceState: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => navigate(search, filter), SEARCH_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlay(index: number) {
|
||||||
|
player.playOrToggle(items, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish(group: LibraryTrack[]) {
|
||||||
|
const ready = group.filter((track) => canPublish(track) && !track.published);
|
||||||
|
if (ready.length === 0) return;
|
||||||
|
publishIds = ready.map((track) => track.id).join(',');
|
||||||
|
publishValue = 'true';
|
||||||
|
await tick();
|
||||||
|
publishForm?.requestSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestToggle(track: LibraryTrack) {
|
||||||
|
if (track.published) pendingUnpublish = [track];
|
||||||
|
else publish([track]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpublishSelected() {
|
||||||
|
pendingUnpublish = selected.filter((track) => track.published);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<Alert tone="danger" class="mt-4">{errorMessage}</Alert>
|
<Alert tone="danger" class="mt-4">{errorMessage}</Alert>
|
||||||
|
{:else if notice}
|
||||||
|
<Alert class="mt-4">{notice}</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if tracks.length > 0}
|
<form
|
||||||
|
bind:this={publishForm}
|
||||||
|
method="POST"
|
||||||
|
action="?/setPublished"
|
||||||
|
class="hidden"
|
||||||
|
use:enhance={() =>
|
||||||
|
async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
selectedIds = [];
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="trackIds" value={publishIds} />
|
||||||
|
<input type="hidden" name="isPublished" value={publishValue} />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if counts.total > 0}
|
||||||
<div class="mt-4 grid">
|
<div class="mt-4 grid">
|
||||||
{#if selectedIds.length > 0}
|
{#if selectedIds.length > 0}
|
||||||
<div class="col-start-1 row-start-1" in:fade={FADE} out:fade={FADE}>
|
<div class="col-start-1 row-start-1" in:fade={FADE} out:fade={FADE}>
|
||||||
@@ -76,7 +148,11 @@
|
|||||||
label={plural(selectedIds.length, 'seleccionada', 'seleccionadas')}
|
label={plural(selectedIds.length, 'seleccionada', 'seleccionadas')}
|
||||||
onClear={() => (selectedIds = [])}
|
onClear={() => (selectedIds = [])}
|
||||||
>
|
>
|
||||||
<Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button>
|
<Button size="sm" variant="secondary" onclick={() => publish(selected)}>Publicar</Button>
|
||||||
|
<Button size="sm" variant="secondary" onclick={unpublishSelected}>Despublicar</Button>
|
||||||
|
<Button size="sm" variant="danger" onclick={() => (pendingDelete = selected)}>
|
||||||
|
Borrar
|
||||||
|
</Button>
|
||||||
</SelectionBar>
|
</SelectionBar>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -86,29 +162,43 @@
|
|||||||
out:fade={FADE}
|
out:fade={FADE}
|
||||||
>
|
>
|
||||||
<SearchInput
|
<SearchInput
|
||||||
bind:value={query}
|
bind:value={search}
|
||||||
placeholder="Filtrar por título o artista"
|
oninput={onSearch}
|
||||||
|
placeholder="Filtrar por título"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-full sm:max-w-search sm:flex-1"
|
class="w-full sm:max-w-search sm:flex-1"
|
||||||
/>
|
/>
|
||||||
<ChipGroup label="Filtrar por estado" options={filterOptions} bind:value={filter} />
|
<ChipGroup
|
||||||
|
label="Filtrar por estado"
|
||||||
|
options={filterOptions}
|
||||||
|
bind:value={() => filter, (value) => navigate(search, value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if visible.length > 0}
|
{#if items.length > 0}
|
||||||
<ManageTrackList
|
<ManageTrackList
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
tracks={visible}
|
tracks={items}
|
||||||
bind:selectedIds
|
bind:selectedIds
|
||||||
onPlay={togglePlay}
|
onPlay={togglePlay}
|
||||||
|
onPublish={requestToggle}
|
||||||
onDelete={(track) => (pendingDelete = [track])}
|
onDelete={(track) => (pendingDelete = [track])}
|
||||||
/>
|
/>
|
||||||
|
<InfiniteScroll
|
||||||
|
onLoadMore={() => list.loadMore()}
|
||||||
|
hasMore={list.hasMore}
|
||||||
|
loading={list.loading}
|
||||||
|
error={list.error}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={SearchX}
|
icon={SearchX}
|
||||||
title="Sin resultados"
|
title="Sin resultados"
|
||||||
description="Ninguna canción coincide con la búsqueda o el filtro."
|
description={hasFilters
|
||||||
|
? 'Ninguna canción coincide con la búsqueda o el filtro.'
|
||||||
|
: 'No hay canciones en esta lista.'}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
@@ -129,9 +219,19 @@
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={pendingDelete.length > 0}
|
open={pendingDelete.length > 0}
|
||||||
onClose={() => (pendingDelete = [])}
|
onClose={() => (pendingDelete = [])}
|
||||||
title="¿Borrar {pendingTitle}?"
|
title="¿Borrar {pendingTitle(pendingDelete)}?"
|
||||||
description="Esta acción no se puede deshacer: se eliminarán de tu biblioteca, de tus álbumes y de las playlists donde estén."
|
description="Esta acción no se puede deshacer: se eliminarán de tu biblioteca, de tus álbumes y de las playlists donde estén."
|
||||||
confirmLabel="Borrar"
|
confirmLabel="Borrar"
|
||||||
action="?/deleteTracks"
|
action="?/deleteTracks"
|
||||||
fields={{ trackIds: pendingDelete.map((track) => track.id).join(',') }}
|
fields={{ trackIds: pendingDelete.map((track) => track.id).join(',') }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingUnpublish.length > 0}
|
||||||
|
onClose={() => (pendingUnpublish = [])}
|
||||||
|
title="¿Despublicar {pendingTitle(pendingUnpublish)}?"
|
||||||
|
description="Dejarán de verse en álbumes, playlists, búsquedas y perfiles. Solo tú podrás verlas y reproducirlas."
|
||||||
|
confirmLabel="Despublicar"
|
||||||
|
action="?/setPublished"
|
||||||
|
fields={{ trackIds: pendingUnpublish.map((track) => track.id).join(','), isPublished: 'false' }}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
align: 'center',
|
align: 'center',
|
||||||
class: `${TEXT} tabular-nums`
|
class: `${TEXT} tabular-nums`
|
||||||
},
|
},
|
||||||
|
{ id: 'publish', width: COL.publish },
|
||||||
{ id: 'delete', width: COL.action, mobile: COL.actionMobile }
|
{ id: 'delete', width: COL.action, mobile: COL.actionMobile }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
@@ -26,6 +27,8 @@
|
|||||||
import type { LibraryTrack } from '$lib/types';
|
import type { LibraryTrack } from '$lib/types';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { genreLabels } from '$lib/data/genres';
|
import { genreLabels } from '$lib/data/genres';
|
||||||
|
import { canPublish } from '$lib/data/libraryTracks';
|
||||||
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import { fmtTime } from '$lib/utils/format';
|
import { fmtTime } from '$lib/utils/format';
|
||||||
import TrackTitleCell from '$lib/components/music/TrackTitleCell.svelte';
|
import TrackTitleCell from '$lib/components/music/TrackTitleCell.svelte';
|
||||||
import TrackStatusBadge from '$lib/components/music/TrackStatusBadge.svelte';
|
import TrackStatusBadge from '$lib/components/music/TrackStatusBadge.svelte';
|
||||||
@@ -37,11 +40,19 @@
|
|||||||
tracks: LibraryTrack[];
|
tracks: LibraryTrack[];
|
||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
onPlay: (index: number) => void;
|
onPlay: (index: number) => void;
|
||||||
|
onPublish: (track: LibraryTrack) => void;
|
||||||
onDelete: (track: LibraryTrack) => void;
|
onDelete: (track: LibraryTrack) => void;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { tracks, selectedIds = $bindable(), onPlay, onDelete, class: klass = '' }: Props = $props();
|
let {
|
||||||
|
tracks,
|
||||||
|
selectedIds = $bindable(),
|
||||||
|
onPlay,
|
||||||
|
onPublish,
|
||||||
|
onDelete,
|
||||||
|
class: klass = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const selected = $derived(new Set(selectedIds));
|
const selected = $derived(new Set(selectedIds));
|
||||||
const allSelected = $derived(
|
const allSelected = $derived(
|
||||||
@@ -81,9 +92,20 @@
|
|||||||
{:else if column.id === 'genres'}
|
{:else if column.id === 'genres'}
|
||||||
{genreLabels(track.genres)}
|
{genreLabels(track.genres)}
|
||||||
{:else if column.id === 'status'}
|
{:else if column.id === 'status'}
|
||||||
<TrackStatusBadge status={track.status} />
|
<TrackStatusBadge {track} />
|
||||||
{:else if column.id === 'duration'}
|
{:else if column.id === 'duration'}
|
||||||
{fmtTime(track.duration)}
|
{fmtTime(track.duration)}
|
||||||
|
{:else if column.id === 'publish'}
|
||||||
|
<span title={canPublish(track) ? undefined : 'Disponible cuando termine de procesarse'}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!canPublish(track)}
|
||||||
|
onclick={() => onPublish(track)}
|
||||||
|
>
|
||||||
|
{track.published ? 'Despublicar' : 'Publicar'}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<IconButton label="Borrar canción" size="xs" revealOnHover onclick={() => onDelete(track)}>
|
<IconButton label="Borrar canción" size="xs" revealOnHover onclick={() => onDelete(track)}>
|
||||||
<X class="size-icon-sm" />
|
<X class="size-icon-sm" />
|
||||||
|
|||||||
@@ -1,42 +1,47 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { LibraryTrack } from '$lib/types';
|
import {
|
||||||
import { filterLibraryTracks } from './libraryTracks';
|
canPublish,
|
||||||
|
filterCounts,
|
||||||
|
filterQuery,
|
||||||
|
parseTrackFilter,
|
||||||
|
trackState
|
||||||
|
} from './libraryTracks';
|
||||||
|
|
||||||
function track(id: string, title: string, status: LibraryTrack['status']): LibraryTrack {
|
describe('trackState', () => {
|
||||||
return {
|
it('never calls a processed track published unless it is', () => {
|
||||||
id,
|
expect(trackState({ status: 'Completed', published: false }).label).toBe('Sin publicar');
|
||||||
title,
|
expect(trackState({ status: 'Completed', published: true }).label).toBe('Publicada');
|
||||||
artist: 'Artista',
|
|
||||||
ownerUserId: '1',
|
|
||||||
explicit: false,
|
|
||||||
duration: 60,
|
|
||||||
listensCount: 0,
|
|
||||||
date: '2026-01-01T00:00:00Z',
|
|
||||||
status,
|
|
||||||
genres: [],
|
|
||||||
albumTitle: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const tracks = [
|
|
||||||
track('1', 'Alba', 'Completed'),
|
|
||||||
track('2', 'Noche', 'Processing'),
|
|
||||||
track('3', 'Ruido', 'Pending'),
|
|
||||||
track('4', 'Roto', 'Failed')
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('filterLibraryTracks', () => {
|
|
||||||
it('returns everything for the "all" filter without a query', () => {
|
|
||||||
expect(filterLibraryTracks(tracks, '', 'all')).toHaveLength(4);
|
|
||||||
});
|
});
|
||||||
it('groups pending and processing tracks together', () => {
|
it('pulses while processing', () => {
|
||||||
expect(filterLibraryTracks(tracks, '', 'processing').map((t) => t.id)).toEqual(['2', '3']);
|
expect(trackState({ status: 'Processing', published: false })).toMatchObject({
|
||||||
|
label: 'Procesando',
|
||||||
|
pulse: true
|
||||||
|
});
|
||||||
|
expect(trackState({ status: 'Failed', published: false }).tone).toBe('danger');
|
||||||
});
|
});
|
||||||
it('combines the status filter with the text query', () => {
|
});
|
||||||
expect(filterLibraryTracks(tracks, ' alb ', 'published').map((t) => t.id)).toEqual(['1']);
|
|
||||||
expect(filterLibraryTracks(tracks, 'alb', 'failed')).toEqual([]);
|
describe('canPublish', () => {
|
||||||
|
it('only allows processed tracks', () => {
|
||||||
|
expect(canPublish({ status: 'Completed' })).toBe(true);
|
||||||
|
expect(canPublish({ status: 'Pending' })).toBe(false);
|
||||||
});
|
});
|
||||||
it('matches the artist too', () => {
|
});
|
||||||
expect(filterLibraryTracks(tracks, 'artista', 'all')).toHaveLength(4);
|
|
||||||
|
describe('filters', () => {
|
||||||
|
it('falls back to "all" for unknown values', () => {
|
||||||
|
expect(parseTrackFilter('nope')).toBe('all');
|
||||||
|
expect(parseTrackFilter(null)).toBe('all');
|
||||||
|
expect(parseTrackFilter('failed')).toBe('failed');
|
||||||
|
});
|
||||||
|
it('maps a filter to an API query', () => {
|
||||||
|
expect(filterQuery('processing')).toEqual({ status: ['Pending', 'Processing'] });
|
||||||
|
expect(filterQuery('unpublished')).toEqual({ status: ['Completed'], published: false });
|
||||||
|
expect(filterQuery('all')).toEqual({});
|
||||||
|
});
|
||||||
|
it('derives the chip counts', () => {
|
||||||
|
expect(
|
||||||
|
filterCounts({ total: 10, processing: 2, completed: 6, failed: 2, published: 4 })
|
||||||
|
).toEqual({ all: 10, published: 4, unpublished: 2, processing: 2, failed: 2 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,41 +1,67 @@
|
|||||||
import type { LibraryTrack, TrackStatus } from '$lib/types';
|
import type { LibraryTrack, TrackStatus, TrackStatusCounts } from '$lib/types';
|
||||||
|
|
||||||
export type TrackTone = 'success' | 'neutral' | 'danger';
|
export type TrackTone = 'success' | 'neutral' | 'danger';
|
||||||
|
|
||||||
export const TRACK_STATUS: Record<TrackStatus, { label: string; tone: TrackTone }> = {
|
export const TRACK_STATUS: Record<TrackStatus, { label: string; tone: TrackTone }> = {
|
||||||
Completed: { label: 'Publicada', tone: 'success' },
|
Completed: { label: 'Lista', tone: 'neutral' },
|
||||||
Processing: { label: 'Procesando', tone: 'neutral' },
|
Processing: { label: 'Procesando', tone: 'neutral' },
|
||||||
Pending: { label: 'En cola', tone: 'neutral' },
|
Pending: { label: 'En cola', tone: 'neutral' },
|
||||||
Failed: { label: 'Con error', tone: 'danger' }
|
Failed: { label: 'Con error', tone: 'danger' }
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TRACK_FILTERS = [
|
export function trackState(track: Pick<LibraryTrack, 'status' | 'published'>): {
|
||||||
{ value: 'all', label: 'Todas', statuses: null },
|
|
||||||
{ value: 'published', label: 'Publicadas', statuses: ['Completed'] },
|
|
||||||
{ value: 'processing', label: 'Procesando', statuses: ['Pending', 'Processing'] },
|
|
||||||
{ value: 'failed', label: 'Con error', statuses: ['Failed'] }
|
|
||||||
] as const satisfies readonly {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
label: string;
|
||||||
statuses: readonly TrackStatus[] | null;
|
tone: TrackTone;
|
||||||
}[];
|
pulse: boolean;
|
||||||
|
} {
|
||||||
|
if (track.status === 'Completed') {
|
||||||
|
return track.published
|
||||||
|
? { label: 'Publicada', tone: 'success', pulse: false }
|
||||||
|
: { label: 'Sin publicar', tone: 'neutral', pulse: false };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...TRACK_STATUS[track.status],
|
||||||
|
pulse: track.status === 'Processing' || track.status === 'Pending'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canPublish(track: Pick<LibraryTrack, 'status'>): boolean {
|
||||||
|
return track.status === 'Completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackFilterQuery {
|
||||||
|
status?: TrackStatus[];
|
||||||
|
published?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRACK_FILTERS = [
|
||||||
|
{ value: 'all', label: 'Todas', query: {} },
|
||||||
|
{ value: 'published', label: 'Publicadas', query: { published: true } },
|
||||||
|
{
|
||||||
|
value: 'unpublished',
|
||||||
|
label: 'Sin publicar',
|
||||||
|
query: { status: ['Completed'], published: false }
|
||||||
|
},
|
||||||
|
{ value: 'processing', label: 'Procesando', query: { status: ['Pending', 'Processing'] } },
|
||||||
|
{ value: 'failed', label: 'Con error', query: { status: ['Failed'] } }
|
||||||
|
] as const satisfies readonly { value: string; label: string; query: TrackFilterQuery }[];
|
||||||
|
|
||||||
export type TrackFilter = (typeof TRACK_FILTERS)[number]['value'];
|
export type TrackFilter = (typeof TRACK_FILTERS)[number]['value'];
|
||||||
|
|
||||||
export function filterLibraryTracks(
|
export function parseTrackFilter(value: string | null): TrackFilter {
|
||||||
tracks: LibraryTrack[],
|
return TRACK_FILTERS.find((option) => option.value === value)?.value ?? 'all';
|
||||||
query: string,
|
}
|
||||||
filter: TrackFilter
|
|
||||||
): LibraryTrack[] {
|
|
||||||
const statuses: readonly TrackStatus[] | null =
|
|
||||||
TRACK_FILTERS.find((option) => option.value === filter)?.statuses ?? null;
|
|
||||||
const needle = query.trim().toLowerCase();
|
|
||||||
|
|
||||||
return tracks.filter(
|
export function filterQuery(filter: TrackFilter): TrackFilterQuery {
|
||||||
(track) =>
|
return TRACK_FILTERS.find((option) => option.value === filter)?.query ?? {};
|
||||||
(!statuses || statuses.includes(track.status)) &&
|
}
|
||||||
(!needle ||
|
|
||||||
track.title.toLowerCase().includes(needle) ||
|
export function filterCounts(counts: TrackStatusCounts): Record<TrackFilter, number> {
|
||||||
(track.artist ?? '').toLowerCase().includes(needle))
|
return {
|
||||||
);
|
all: counts.total,
|
||||||
|
published: counts.published,
|
||||||
|
unpublished: Math.max(0, counts.completed - counts.published),
|
||||||
|
processing: counts.processing,
|
||||||
|
failed: counts.failed
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,29 +20,18 @@ export const saveAlbum = tagged('saveAlbum', async ({ api, form }) => {
|
|||||||
if ('failMessage' in cover)
|
if ('failMessage' in cover)
|
||||||
return fail(502, { message: cover.failMessage, detail: cover.detail });
|
return fail(502, { message: cover.failMessage, detail: cover.detail });
|
||||||
|
|
||||||
let albumId = existingId;
|
const addIds = formIds(form, 'addTrackIds');
|
||||||
if (existingId) {
|
if (!existingId) {
|
||||||
const failure = await updateAlbum(api, existingId, parsed.body, cover.intentId);
|
|
||||||
if (failure) return failure;
|
|
||||||
} else {
|
|
||||||
if (!cover.intentId) return fail(400, { message: 'La portada es obligatoria.' });
|
if (!cover.intentId) return fail(400, { message: 'La portada es obligatoria.' });
|
||||||
const created = await createAlbum(api, parsed.body, cover.intentId);
|
const created = await createAlbum(api, parsed.body, cover.intentId, addIds);
|
||||||
if ('failMessage' in created) return fail(502, { message: created.failMessage });
|
if ('failMessage' in created) return fail(502, { message: created.failMessage });
|
||||||
albumId = created.id;
|
return { saved: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const failure = await syncAlbumTracks(
|
const updateFailure = await updateAlbum(api, existingId, parsed.body, cover.intentId);
|
||||||
api,
|
if (updateFailure) return updateFailure;
|
||||||
albumId,
|
|
||||||
formIds(form, 'addTrackIds'),
|
const failure = await syncAlbumTracks(api, existingId, addIds, formIds(form, 'removeTrackIds'));
|
||||||
formIds(form, 'removeTrackIds')
|
|
||||||
);
|
|
||||||
if (failure && !existingId) {
|
|
||||||
return fail(502, {
|
|
||||||
message: 'El álbum se creó, pero no se pudieron añadir las canciones. Inténtalo de nuevo.',
|
|
||||||
albumId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return failure ?? { saved: true };
|
return failure ?? { saved: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,14 @@
|
|||||||
import type { Album } from '$lib/types';
|
import { toAlbum, toPage } from '$lib/server/mappers';
|
||||||
import { toAlbum, toLibraryTrack, toPage } from '$lib/server/mappers';
|
|
||||||
import { failOnError, unwrapOrError, type ApiClient } from '$lib/server/api';
|
import { failOnError, unwrapOrError, type ApiClient } from '$lib/server/api';
|
||||||
import { uploadOptionalCover } from '$lib/server/upload';
|
import { uploadOptionalCover } from '$lib/server/upload';
|
||||||
import type { AlbumFormBody } from '$lib/server/forms/albumForm';
|
import type { AlbumFormBody } from '$lib/server/forms/albumForm';
|
||||||
import { ALBUMS_PAGE_SIZE, ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PAGE_SIZE } from '$lib/config';
|
import { ALBUMS_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
export const ALBUMS_SECTION_URL = '/library/albums';
|
export const ALBUMS_SECTION_URL = '/library/albums';
|
||||||
|
|
||||||
export async function trackIdsByAlbum(
|
export async function albumTrackIds(api: ApiClient, albumId: string): Promise<string[]> {
|
||||||
api: ApiClient,
|
const result = await api.GET('/albums/{id}/tracks/ids', { params: { path: { id: albumId } } });
|
||||||
albums: Album[]
|
return unwrapOrError(result, 'No se pudieron cargar las canciones del álbum.').trackIds;
|
||||||
): Promise<Record<string, string[]>> {
|
|
||||||
const entries = await Promise.all(
|
|
||||||
albums.map(async (album) => {
|
|
||||||
const tracks = await api.GET('/albums/{id}/tracks', {
|
|
||||||
params: {
|
|
||||||
path: { id: album.id },
|
|
||||||
query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return [album.id, (tracks.data?.items ?? []).map((track) => track.id)] as const;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return Object.fromEntries(entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
function albumTitleByTrackId(albums: Album[], trackIds: Record<string, string[]>) {
|
|
||||||
return new Map(
|
|
||||||
albums.flatMap((album) => (trackIds[album.id] ?? []).map((id) => [id, album.title] as const))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadUserAlbums(api: ApiClient, userId: string) {
|
export async function loadUserAlbums(api: ApiClient, userId: string) {
|
||||||
@@ -38,19 +18,6 @@ export async function loadUserAlbums(api: ApiClient, userId: string) {
|
|||||||
return toPage(unwrapOrError(result, 'No se pudieron cargar tus álbumes.'), toAlbum);
|
return toPage(unwrapOrError(result, 'No se pudieron cargar tus álbumes.'), toAlbum);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLibraryTracks(api: ApiClient, userId: string) {
|
|
||||||
const [tracksResult, albums] = await Promise.all([
|
|
||||||
api.GET('/tracks/users/{userId}', {
|
|
||||||
params: { path: { userId }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
|
|
||||||
}),
|
|
||||||
loadUserAlbums(api, userId)
|
|
||||||
]);
|
|
||||||
const albumTitles = albumTitleByTrackId(albums.items, await trackIdsByAlbum(api, albums.items));
|
|
||||||
return toPage(unwrapOrError(tracksResult, 'No se pudo cargar tu biblioteca.'), (dto) =>
|
|
||||||
toLibraryTrack(dto, albumTitles.get(dto.id) ?? null)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CoverUpload = { intentId: string | null } | { failMessage: string; detail?: string };
|
export type CoverUpload = { intentId: string | null } | { failMessage: string; detail?: string };
|
||||||
|
|
||||||
export function uploadAlbumCover(api: ApiClient, file: File | null): Promise<CoverUpload> {
|
export function uploadAlbumCover(api: ApiClient, file: File | null): Promise<CoverUpload> {
|
||||||
@@ -60,9 +27,10 @@ export function uploadAlbumCover(api: ApiClient, file: File | null): Promise<Cov
|
|||||||
export async function createAlbum(
|
export async function createAlbum(
|
||||||
api: ApiClient,
|
api: ApiClient,
|
||||||
body: AlbumFormBody,
|
body: AlbumFormBody,
|
||||||
pictureIntentId: string
|
pictureIntentId: string,
|
||||||
|
trackIds: string[] = []
|
||||||
): Promise<{ id: string } | { failMessage: string }> {
|
): Promise<{ id: string } | { failMessage: string }> {
|
||||||
const created = await api.POST('/albums', { body: { ...body, pictureIntentId } });
|
const created = await api.POST('/albums', { body: { ...body, pictureIntentId, trackIds } });
|
||||||
if (created.error || !created.data) return { failMessage: 'No se pudo crear el álbum.' };
|
if (created.error || !created.data) return { failMessage: 'No se pudo crear el álbum.' };
|
||||||
return { id: created.data.id };
|
return { id: created.data.id };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,19 @@ describe('parseAlbumForm', () => {
|
|||||||
it('parses a valid form and trims values', () => {
|
it('parses a valid form and trims values', () => {
|
||||||
expect(
|
expect(
|
||||||
parseAlbumForm(form({ title: ' Title ', description: ' d ', releaseYear: '2020' }))
|
parseAlbumForm(form({ title: ' Title ', description: ' d ', releaseYear: '2020' }))
|
||||||
).toEqual({ body: { title: 'Title', description: 'd', releaseYear: 2020 } });
|
).toEqual({ body: { title: 'Title', description: 'd', releaseYear: 2020, type: 'Album' } });
|
||||||
});
|
});
|
||||||
it('turns empty optional fields into null', () => {
|
it('turns empty optional fields into null', () => {
|
||||||
expect(parseAlbumForm(form({ title: 'T' }))).toEqual({
|
expect(parseAlbumForm(form({ title: 'T' }))).toEqual({
|
||||||
body: { title: 'T', description: null, releaseYear: null }
|
body: { title: 'T', description: null, releaseYear: null, type: 'Album' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('maps the album type', () => {
|
||||||
|
expect(parseAlbumForm(form({ title: 'T', type: 'ep' }))).toMatchObject({
|
||||||
|
body: { type: 'EP' }
|
||||||
|
});
|
||||||
|
expect(parseAlbumForm(form({ title: 'T', type: 'x' }))).toMatchObject({
|
||||||
|
body: { type: 'Album' }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('rejects empty or too long titles', () => {
|
it('rejects empty or too long titles', () => {
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ export interface AlbumFormBody {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
releaseYear: number | null;
|
releaseYear: number | null;
|
||||||
|
type: 'Album' | 'EP' | 'Single';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ALBUM_TYPES: Record<string, AlbumFormBody['type']> = {
|
||||||
|
album: 'Album',
|
||||||
|
ep: 'EP',
|
||||||
|
single: 'Single'
|
||||||
|
};
|
||||||
|
|
||||||
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
|
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
|
||||||
const title = formString(form, 'title').trim();
|
const title = formString(form, 'title').trim();
|
||||||
if (title === '' || title.length > LIMITS.albumTitle) {
|
if (title === '' || title.length > LIMITS.albumTitle) {
|
||||||
@@ -36,7 +43,8 @@ export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { fail
|
|||||||
body: {
|
body: {
|
||||||
title,
|
title,
|
||||||
description: description === '' ? null : description,
|
description: description === '' ? null : description,
|
||||||
releaseYear
|
releaseYear,
|
||||||
|
type: ALBUM_TYPES[formString(form, 'type')] ?? 'Album'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { toLibraryTrack, toPage, toStatusCounts } from '$lib/server/mappers';
|
||||||
|
import { unwrapOrError, type ApiClient } from '$lib/server/api';
|
||||||
|
import { filterQuery, type TrackFilter } from '$lib/data/libraryTracks';
|
||||||
|
import { LIBRARY_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
|
export interface LibraryTracksQuery {
|
||||||
|
q?: string;
|
||||||
|
filter?: TrackFilter;
|
||||||
|
pageNumber?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLibraryTracks(
|
||||||
|
api: ApiClient,
|
||||||
|
userId: string,
|
||||||
|
{ q, filter = 'all', pageNumber = 1, pageSize = LIBRARY_PAGE_SIZE }: LibraryTracksQuery = {}
|
||||||
|
) {
|
||||||
|
const { status, published } = filterQuery(filter);
|
||||||
|
const result = await api.GET('/tracks/users/{userId}', {
|
||||||
|
params: {
|
||||||
|
path: { userId },
|
||||||
|
query: {
|
||||||
|
name: q || undefined,
|
||||||
|
status: status ? [...status] : undefined,
|
||||||
|
published,
|
||||||
|
pageNumber,
|
||||||
|
pageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return toPage(unwrapOrError(result, 'No se pudo cargar tu biblioteca.'), toLibraryTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadStatusCounts(api: ApiClient, userId: string) {
|
||||||
|
const result = await api.GET('/tracks/users/{userId}/status-counts', {
|
||||||
|
params: { path: { userId } }
|
||||||
|
});
|
||||||
|
return toStatusCounts(unwrapOrError(result, 'No se pudieron cargar los contadores.'));
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { components } from '$lib/api/schema';
|
|||||||
import type {
|
import type {
|
||||||
Album,
|
Album,
|
||||||
LibraryTrack,
|
LibraryTrack,
|
||||||
|
TrackStatusCounts,
|
||||||
LikedTrack,
|
LikedTrack,
|
||||||
ListeningStats,
|
ListeningStats,
|
||||||
Mix,
|
Mix,
|
||||||
@@ -11,6 +12,8 @@ import type {
|
|||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
|
|
||||||
type TrackDto = components['schemas']['TrackApplicationResponse'];
|
type TrackDto = components['schemas']['TrackApplicationResponse'];
|
||||||
|
type UserTrackDto = components['schemas']['UserTrackApplicationResponse'];
|
||||||
|
type StatusCountsDto = components['schemas']['TrackStatusCountsResponse'];
|
||||||
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
|
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
|
||||||
type PlaylistDto = components['schemas']['PlayListApplicationResponse'];
|
type PlaylistDto = components['schemas']['PlayListApplicationResponse'];
|
||||||
type MixDto = components['schemas']['MixApplicationResponse'];
|
type MixDto = components['schemas']['MixApplicationResponse'];
|
||||||
@@ -39,12 +42,24 @@ export function toDatedTrack(dto: TrackDto): Track & { date: string } {
|
|||||||
return { ...toTrack(dto), date: dto.createdAt };
|
return { ...toTrack(dto), date: dto.createdAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toLibraryTrack(dto: TrackDto, albumTitle: string | null): LibraryTrack {
|
export function toLibraryTrack(dto: UserTrackDto): LibraryTrack {
|
||||||
return {
|
return {
|
||||||
...toDatedTrack(dto),
|
...toDatedTrack(dto),
|
||||||
status: dto.audioStatus,
|
status: dto.audioStatus,
|
||||||
genres: dto.tags,
|
genres: dto.tags,
|
||||||
albumTitle
|
albums: dto.albums,
|
||||||
|
albumTitle: dto.albums.length > 0 ? dto.albums.map((album) => album.title).join(', ') : null,
|
||||||
|
published: dto.isPublished
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toStatusCounts(dto: StatusCountsDto): TrackStatusCounts {
|
||||||
|
return {
|
||||||
|
total: Number(dto.total) || 0,
|
||||||
|
processing: (Number(dto.pending) || 0) + (Number(dto.processing) || 0),
|
||||||
|
completed: Number(dto.completed) || 0,
|
||||||
|
failed: Number(dto.failed) || 0,
|
||||||
|
published: Number(dto.published) || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +104,7 @@ export function toAlbum(dto: AlbumDto): Album {
|
|||||||
title: dto.title,
|
title: dto.title,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
releaseYear: dto.releaseYear === null ? null : Number(dto.releaseYear),
|
releaseYear: dto.releaseYear === null ? null : Number(dto.releaseYear),
|
||||||
|
type: dto.type,
|
||||||
ownerUserId: dto.ownerUserId,
|
ownerUserId: dto.ownerUserId,
|
||||||
trackCount: Number(dto.trackCount) || 0,
|
trackCount: Number(dto.trackCount) || 0,
|
||||||
coverTrackIds: dto.coverTrackIds,
|
coverTrackIds: dto.coverTrackIds,
|
||||||
|
|||||||
@@ -1,19 +1,44 @@
|
|||||||
|
import { isActionFailure } from '@sveltejs/kit';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import type { ApiClient } from '$lib/server/api';
|
import type { ApiClient } from '$lib/server/api';
|
||||||
import { deleteTracks } from '$lib/server/tracks';
|
import { deleteTracks, setTracksPublished } from '$lib/server/tracks';
|
||||||
|
|
||||||
describe('deleteTracks', () => {
|
describe('deleteTracks', () => {
|
||||||
it('counts the deletions that failed', async () => {
|
it('counts the tracks the API could not delete', async () => {
|
||||||
const DELETE = vi.fn(async (_path: string, init: { params: { path: { id: string } } }) =>
|
const DELETE = vi.fn(async () => ({
|
||||||
init.params.path.id === 'b' ? { error: { status: 500 } } : {}
|
data: { deletedTrackIds: ['a', 'c'], failedTrackIds: ['b'] }
|
||||||
);
|
}));
|
||||||
const result = await deleteTracks({ DELETE } as unknown as ApiClient, ['a', 'b', 'c']);
|
const result = await deleteTracks({ DELETE } as unknown as ApiClient, ['a', 'b', 'c']);
|
||||||
expect(result).toEqual({ failed: 1 });
|
expect(result).toEqual({ failed: 1 });
|
||||||
expect(DELETE).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports no failures when every deletion succeeds', async () => {
|
it('counts a whole batch as failed when the request errors', async () => {
|
||||||
const DELETE = vi.fn(async () => ({}));
|
const DELETE = vi.fn(async () => ({ error: { status: 500 } }));
|
||||||
expect(await deleteTracks({ DELETE } as unknown as ApiClient, ['a'])).toEqual({ failed: 0 });
|
expect(await deleteTracks({ DELETE } as unknown as ApiClient, ['a', 'b'])).toEqual({
|
||||||
|
failed: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits large deletions into batches of 100', async () => {
|
||||||
|
const DELETE = vi.fn(async () => ({ data: { deletedTrackIds: [], failedTrackIds: [] } }));
|
||||||
|
const ids = Array.from({ length: 250 }, (_, i) => String(i));
|
||||||
|
await deleteTracks({ DELETE } as unknown as ApiClient, ids);
|
||||||
|
expect(DELETE).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setTracksPublished', () => {
|
||||||
|
it('reports updated and skipped tracks', async () => {
|
||||||
|
const PUT = vi.fn(async () => ({
|
||||||
|
data: { updatedTrackIds: ['a'], skippedTrackIds: ['b'] }
|
||||||
|
}));
|
||||||
|
const result = await setTracksPublished({ PUT } as unknown as ApiClient, ['a', 'b'], true);
|
||||||
|
expect(result).toEqual({ updated: 1, skipped: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a failure when the request errors', async () => {
|
||||||
|
const PUT = vi.fn(async () => ({ error: { status: 400 } }));
|
||||||
|
const result = await setTracksPublished({ PUT } as unknown as ApiClient, ['a'], true);
|
||||||
|
expect(isActionFailure(result)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,37 @@
|
|||||||
import type { ApiClient } from '$lib/server/api';
|
import { failOnError, type ApiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
const BULK_LIMIT = 100;
|
||||||
|
|
||||||
|
function chunks<T>(items: T[]): T[][] {
|
||||||
|
const result: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += BULK_LIMIT) result.push(items.slice(i, i + BULK_LIMIT));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteTracks(api: ApiClient, ids: string[]): Promise<{ failed: number }> {
|
export async function deleteTracks(api: ApiClient, ids: string[]): Promise<{ failed: number }> {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
ids.map((id) => api.DELETE('/tracks/{id}', { params: { path: { id } } }))
|
chunks(ids).map(async (batch) => {
|
||||||
|
const result = await api.DELETE('/tracks', { body: { trackIds: batch } });
|
||||||
|
return result.error || !result.data ? batch.length : result.data.failedTrackIds.length;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
return { failed: results.filter((result) => result.error).length };
|
return { failed: results.reduce((total, count) => total + count, 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTracksPublished(
|
||||||
|
api: ApiClient,
|
||||||
|
ids: string[],
|
||||||
|
isPublished: boolean
|
||||||
|
): Promise<{ updated: number; skipped: number } | NonNullable<ReturnType<typeof failOnError>>> {
|
||||||
|
let updated = 0;
|
||||||
|
for (const batch of chunks(ids)) {
|
||||||
|
const result = await api.PUT('/tracks/published', { body: { trackIds: batch, isPublished } });
|
||||||
|
const failure = failOnError(
|
||||||
|
result,
|
||||||
|
isPublished ? 'No se pudieron publicar las canciones.' : 'No se pudieron despublicar.'
|
||||||
|
);
|
||||||
|
if (failure) return failure;
|
||||||
|
updated += result.data?.updatedTrackIds.length ?? 0;
|
||||||
|
}
|
||||||
|
return { updated, skipped: ids.length - updated };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,17 +29,30 @@ export interface LibraryTrack extends Track {
|
|||||||
status: TrackStatus;
|
status: TrackStatus;
|
||||||
genres: string[];
|
genres: string[];
|
||||||
albumTitle: string | null;
|
albumTitle: string | null;
|
||||||
|
albums: { id: string; title: string }[];
|
||||||
|
published: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackStatusCounts {
|
||||||
|
total: number;
|
||||||
|
processing: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
published: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LikedTrack extends Track {
|
export interface LikedTrack extends Track {
|
||||||
likedAt: number;
|
likedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AlbumType = 'Album' | 'EP' | 'Single';
|
||||||
|
|
||||||
export interface Album {
|
export interface Album {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
releaseYear: number | null;
|
releaseYear: number | null;
|
||||||
|
type: AlbumType;
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
trackCount: number;
|
trackCount: number;
|
||||||
coverTrackIds: string[];
|
coverTrackIds: string[];
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const COL = {
|
|||||||
genres: 'minmax(6rem,12.5rem)',
|
genres: 'minmax(6rem,12.5rem)',
|
||||||
genresWide: 'minmax(6rem,13.75rem)',
|
genresWide: 'minmax(6rem,13.75rem)',
|
||||||
status: '7rem',
|
status: '7rem',
|
||||||
|
publish: '6.5rem',
|
||||||
statusQueue: '8.75rem',
|
statusQueue: '8.75rem',
|
||||||
statusMobile: '6rem',
|
statusMobile: '6rem',
|
||||||
access: '6rem',
|
access: '6rem',
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail, isActionFailure } from '@sveltejs/kit';
|
||||||
import { apiFor } from '$lib/server/api';
|
import { apiFor } from '$lib/server/api';
|
||||||
import { requireUser } from '$lib/server/guards';
|
import { requireUser } from '$lib/server/guards';
|
||||||
import { formIds } from '$lib/server/forms/fields';
|
import { formIds, formString } from '$lib/server/forms/fields';
|
||||||
import { tagged } from '$lib/server/actions/tagged';
|
import { tagged } from '$lib/server/actions/tagged';
|
||||||
import { loadLibraryTracks } from '$lib/server/albums';
|
import { loadLibraryTracks, loadStatusCounts } from '$lib/server/libraryTracks';
|
||||||
import { deleteTracks } from '$lib/server/tracks';
|
import { deleteTracks, setTracksPublished } from '$lib/server/tracks';
|
||||||
|
import { parseTrackFilter } from '$lib/data/libraryTracks';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
const user = requireUser(locals, url);
|
const user = requireUser(locals, url);
|
||||||
const api = apiFor({ fetch, locals });
|
const api = apiFor({ fetch, locals });
|
||||||
return { tracks: await loadLibraryTracks(api, user.sub) };
|
const q = url.searchParams.get('q')?.trim() ?? '';
|
||||||
|
const filter = parseTrackFilter(url.searchParams.get('status'));
|
||||||
|
|
||||||
|
const [tracks, counts] = await Promise.all([
|
||||||
|
loadLibraryTracks(api, user.sub, { q, filter }),
|
||||||
|
loadStatusCounts(api, user.sub)
|
||||||
|
]);
|
||||||
|
return { tracks, counts, q, filter };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
@@ -26,5 +34,15 @@ export const actions: Actions = {
|
|||||||
? 'No se pudo borrar la canción.'
|
? 'No se pudo borrar la canción.'
|
||||||
: `No se pudieron borrar ${failed} de ${trackIds.length} canciones.`
|
: `No se pudieron borrar ${failed} de ${trackIds.length} canciones.`
|
||||||
});
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
setPublished: tagged('setPublished', async ({ api, form }) => {
|
||||||
|
const trackIds = formIds(form, 'trackIds');
|
||||||
|
if (trackIds.length === 0) return fail(400, { message: 'Falta la canción.' });
|
||||||
|
|
||||||
|
const isPublished = formString(form, 'isPublished') === 'true';
|
||||||
|
const result = await setTracksPublished(api, trackIds, isPublished);
|
||||||
|
if (isActionFailure(result)) return result;
|
||||||
|
return { ...result, isPublished };
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LibraryTracksSection from '$lib/components/music/library/LibraryTracksSection.svelte';
|
import LibraryTracksSection from '$lib/components/music/library/LibraryTracksSection.svelte';
|
||||||
|
import { plural } from '$lib/utils/format';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
const errorMessage = $derived(
|
||||||
|
form && 'message' in form && form.action !== 'saveAlbum' ? form.message : undefined
|
||||||
|
);
|
||||||
|
const published = $derived(
|
||||||
|
form?.action === 'setPublished' ? (form as { skipped?: number; isPublished?: boolean }) : null
|
||||||
|
);
|
||||||
|
const notice = $derived(
|
||||||
|
published?.skipped
|
||||||
|
? `${plural(published.skipped, 'canción', 'canciones')} sin procesar: no se ${published.isPublished ? 'pueden publicar' : 'han despublicado'} todavía.`
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<LibraryTracksSection
|
<LibraryTracksSection
|
||||||
tracks={data.tracks.items}
|
tracks={data.tracks}
|
||||||
errorMessage={form?.action === 'deleteTracks' ? form.message : undefined}
|
counts={data.counts}
|
||||||
|
query={data.q}
|
||||||
|
filter={data.filter}
|
||||||
|
{errorMessage}
|
||||||
|
{notice}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import type { Actions, PageServerLoad } from './$types';
|
|||||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||||
import { requireUser } from '$lib/server/guards';
|
import { requireUser } from '$lib/server/guards';
|
||||||
import { toAlbum } from '$lib/server/mappers';
|
import { toAlbum } from '$lib/server/mappers';
|
||||||
import { loadLibraryTracks, trackIdsByAlbum } from '$lib/server/albums';
|
import { albumTrackIds } from '$lib/server/albums';
|
||||||
|
import { loadLibraryTracks } from '$lib/server/libraryTracks';
|
||||||
import { removeAlbum, saveAlbum } from '$lib/server/actions/libraryAlbum';
|
import { removeAlbum, saveAlbum } from '$lib/server/actions/libraryAlbum';
|
||||||
|
import { LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||||
const user = requireUser(locals, url);
|
const user = requireUser(locals, url);
|
||||||
@@ -11,10 +13,10 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
|||||||
const albumResult = await api.GET('/albums/{id}', { params: { path: { id: params.id } } });
|
const albumResult = await api.GET('/albums/{id}', { params: { path: { id: params.id } } });
|
||||||
const album = toAlbum(unwrapOrError(albumResult, 'Álbum no encontrado.', 404));
|
const album = toAlbum(unwrapOrError(albumResult, 'Álbum no encontrado.', 404));
|
||||||
const [tracks, memberIds] = await Promise.all([
|
const [tracks, memberIds] = await Promise.all([
|
||||||
loadLibraryTracks(api, user.sub),
|
loadLibraryTracks(api, user.sub, { pageSize: LIBRARY_PICKER_PAGE_SIZE }),
|
||||||
trackIdsByAlbum(api, [album])
|
albumTrackIds(api, album.id)
|
||||||
]);
|
]);
|
||||||
return { album, tracks, memberIds: memberIds[album.id] ?? [] };
|
return { album, tracks, memberIds };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = { saveAlbum, deleteAlbum: removeAlbum };
|
export const actions: Actions = { saveAlbum, deleteAlbum: removeAlbum };
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { apiFor } from '$lib/server/api';
|
import { apiFor } from '$lib/server/api';
|
||||||
import { requireUser } from '$lib/server/guards';
|
import { requireUser } from '$lib/server/guards';
|
||||||
import { loadLibraryTracks } from '$lib/server/albums';
|
import { loadLibraryTracks } from '$lib/server/libraryTracks';
|
||||||
import { saveAlbum } from '$lib/server/actions/libraryAlbum';
|
import { saveAlbum } from '$lib/server/actions/libraryAlbum';
|
||||||
|
import { LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
const user = requireUser(locals, url);
|
const user = requireUser(locals, url);
|
||||||
const api = apiFor({ fetch, locals });
|
const api = apiFor({ fetch, locals });
|
||||||
return { tracks: await loadLibraryTracks(api, user.sub) };
|
return { tracks: await loadLibraryTracks(api, user.sub, { pageSize: LIBRARY_PICKER_PAGE_SIZE }) };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = { saveAlbum };
|
export const actions: Actions = { saveAlbum };
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { apiFor } from '$lib/server/api';
|
||||||
|
import { requireUser } from '$lib/server/guards';
|
||||||
|
import { loadLibraryTracks } from '$lib/server/libraryTracks';
|
||||||
|
import { parseTrackFilter } from '$lib/data/libraryTracks';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
|
||||||
|
const user = requireUser(locals, url);
|
||||||
|
const api = apiFor({ fetch, locals });
|
||||||
|
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
|
||||||
|
const tracks = await loadLibraryTracks(api, user.sub, {
|
||||||
|
q: url.searchParams.get('q')?.trim() ?? '',
|
||||||
|
filter: parseTrackFilter(url.searchParams.get('status')),
|
||||||
|
pageNumber
|
||||||
|
});
|
||||||
|
return json(tracks);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user