feat(dailyq): add generated DailyQ view and dynamic suggest client
cx
This commit is contained in:
1 parent
741559184d
commit
ea9051409a
5 files changed
+674
No files matched your search
@@ -0,0 +1,543 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { ListPlus, Play, RefreshCw, Shuffle, Sparkles } from 'lucide-svelte';
|
||||
import PlaylistModal from '$lib/components/PlaylistModal.svelte';
|
||||
import TrackRow from '$lib/components/TrackRow.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters';
|
||||
import { getDynamicSuggest } from '$lib/services/dynamicSuggest';
|
||||
import { getHomeSeeds, getHomeSeedsML } from '$lib/services/recoService';
|
||||
import { showToast } from '$lib/stores/toastStore';
|
||||
import { getUserItem, setUserItem } from '$lib/utils/userStorage';
|
||||
import type {
|
||||
DynamicSuggestResponse,
|
||||
DynamicTrackToAnalyseInput,
|
||||
DynamicSuggestTrack
|
||||
} from '$lib/types/dynamicSuggest';
|
||||
|
||||
interface RawSuggestTrack {
|
||||
performer?: { id?: number };
|
||||
composer?: { id?: number };
|
||||
album?: {
|
||||
label?: { id?: number };
|
||||
genre?: { id?: number };
|
||||
};
|
||||
}
|
||||
|
||||
interface Playlist {
|
||||
id: number;
|
||||
name: string;
|
||||
tracks_count: number;
|
||||
}
|
||||
|
||||
interface DailyMixCacheEntry {
|
||||
localDate: string;
|
||||
result: DynamicSuggestResponse;
|
||||
cachedAtIso: string;
|
||||
}
|
||||
|
||||
const DAILY_MIX_CACHE_KEY = 'qbz-dailyq-cache-v2';
|
||||
const DAILY_MIX_TARGET_SIZE = 50;
|
||||
const DAILY_ANALYSE_TRACKS = 9;
|
||||
const DAILY_SEED_POOL = 120;
|
||||
|
||||
let loading = $state(false);
|
||||
let mixSource = $state<'cache' | 'live' | null>(null);
|
||||
let showPlaylistModal = $state(false);
|
||||
let userPlaylists = $state<Playlist[]>([]);
|
||||
let playlistModalTrackIds = $state<number[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let result = $state<DynamicSuggestResponse | null>(null);
|
||||
let autoRunDone = false;
|
||||
let searchQuery = $state('');
|
||||
|
||||
function getLocalDateKey(date: Date = new Date()): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function dedupeTrackIds(ids: number[]): number[] {
|
||||
const seen = new Set<number>();
|
||||
const out: number[] = [];
|
||||
for (const trackId of ids) {
|
||||
if (!Number.isFinite(trackId) || trackId <= 0) continue;
|
||||
if (seen.has(trackId)) continue;
|
||||
seen.add(trackId);
|
||||
out.push(trackId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickSpread(ids: number[], count: number): number[] {
|
||||
if (ids.length <= count) return ids;
|
||||
const picked: number[] = [];
|
||||
const step = (ids.length - 1) / (count - 1);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const position = Math.round(index * step);
|
||||
picked.push(ids[position]);
|
||||
}
|
||||
return dedupeTrackIds(picked).slice(0, count);
|
||||
}
|
||||
|
||||
function readDailyCache(localDate: string): DailyMixCacheEntry | null {
|
||||
const raw = getUserItem(DAILY_MIX_CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as DailyMixCacheEntry;
|
||||
if (!parsed || parsed.localDate !== localDate || !parsed.result) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeDailyCache(entry: DailyMixCacheEntry): void {
|
||||
setUserItem(DAILY_MIX_CACHE_KEY, JSON.stringify(entry));
|
||||
}
|
||||
|
||||
function qualityLabel(track: DynamicSuggestTrack): string {
|
||||
const bitDepth = track.maximum_bit_depth;
|
||||
const rate = track.maximum_sampling_rate;
|
||||
if (bitDepth && rate) {
|
||||
return `${bitDepth}bit/${rate}kHz`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function buildSeeds(): Promise<{
|
||||
listenedTrackIds: number[];
|
||||
tracksToAnalyse: DynamicTrackToAnalyseInput[];
|
||||
limit: number;
|
||||
}> {
|
||||
const [seedsRecent, seedsMl] = await Promise.all([
|
||||
getHomeSeeds({ continueTracks: 90, favorites: 25 }),
|
||||
getHomeSeedsML({ continueTracks: 90, favorites: 25 })
|
||||
]);
|
||||
|
||||
const merged = dedupeTrackIds([
|
||||
...seedsRecent.continueListeningTrackIds,
|
||||
...seedsMl.continueListeningTrackIds,
|
||||
...seedsRecent.favoriteTrackIds,
|
||||
...seedsMl.favoriteTrackIds
|
||||
]).slice(0, DAILY_SEED_POOL);
|
||||
|
||||
const analysedSeedIds = pickSpread(merged, DAILY_ANALYSE_TRACKS);
|
||||
|
||||
const analysed = await Promise.all(
|
||||
analysedSeedIds.map(async (trackId) => {
|
||||
const raw = await invoke<RawSuggestTrack>('v2_get_track', { trackId });
|
||||
const artistId = raw.performer?.id ?? raw.composer?.id ?? 0;
|
||||
return {
|
||||
trackId,
|
||||
artistId,
|
||||
genreId: raw.album?.genre?.id ?? 0,
|
||||
labelId: raw.album?.label?.id ?? 0
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const tracksToAnalyse = analysed
|
||||
.filter((item) => item.artistId > 0)
|
||||
.slice(0, DAILY_ANALYSE_TRACKS);
|
||||
|
||||
const limit = Math.max(1, DAILY_MIX_TARGET_SIZE - tracksToAnalyse.length);
|
||||
|
||||
return {
|
||||
listenedTrackIds: merged,
|
||||
tracksToAnalyse,
|
||||
limit
|
||||
};
|
||||
}
|
||||
|
||||
async function generateDailyQ(cacheMode: 'none' | 'read-write' = 'none'): Promise<void> {
|
||||
loading = true;
|
||||
error = null;
|
||||
if (cacheMode !== 'read-write') mixSource = null;
|
||||
|
||||
try {
|
||||
const localDate = getLocalDateKey();
|
||||
if (cacheMode === 'read-write') {
|
||||
const cached = readDailyCache(localDate);
|
||||
if (cached) {
|
||||
result = cached.result;
|
||||
mixSource = 'cache';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const seedPayload = await buildSeeds();
|
||||
let response = await getDynamicSuggest(seedPayload);
|
||||
|
||||
if (response.tracks.items.length === 0 && seedPayload.listenedTrackIds.length > 0) {
|
||||
response = await getDynamicSuggest({
|
||||
limit: DAILY_MIX_TARGET_SIZE,
|
||||
listenedTrackIds: seedPayload.listenedTrackIds,
|
||||
tracksToAnalyse: []
|
||||
});
|
||||
}
|
||||
|
||||
result = response;
|
||||
mixSource = 'live';
|
||||
|
||||
if (cacheMode === 'read-write') {
|
||||
writeDailyCache({
|
||||
localDate,
|
||||
result: response,
|
||||
cachedAtIso: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : $t('yourMixes.errors.fetchFailed');
|
||||
result = null;
|
||||
mixSource = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getResultTrackIds(): number[] {
|
||||
if (!result) return [];
|
||||
return result.tracks.items
|
||||
.map((track) => Number(track.id))
|
||||
.filter((trackId) => Number.isFinite(trackId) && trackId > 0);
|
||||
}
|
||||
|
||||
async function openSaveAsPlaylist(): Promise<void> {
|
||||
const trackIds = getResultTrackIds();
|
||||
if (trackIds.length === 0) {
|
||||
showToast($t('yourMixes.errors.emptyPlaylist'), 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
userPlaylists = await invoke<Playlist[]>('v2_get_user_playlists');
|
||||
playlistModalTrackIds = trackIds;
|
||||
showPlaylistModal = true;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : $t('yourMixes.errors.playlistsLoadFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function playTrack(track: DynamicSuggestTrack): Promise<void> {
|
||||
try {
|
||||
await invoke('v2_play_track', { trackId: track.id });
|
||||
} catch (err) {
|
||||
console.error('Failed to play DailyQ track:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlayAll(): Promise<void> {
|
||||
if (filteredTracks.length === 0) return;
|
||||
await playTrack(filteredTracks[0]);
|
||||
}
|
||||
|
||||
async function handleShuffle(): Promise<void> {
|
||||
if (filteredTracks.length === 0) return;
|
||||
const randomIndex = Math.floor(Math.random() * filteredTracks.length);
|
||||
const track = filteredTracks[randomIndex];
|
||||
if (!track) return;
|
||||
await playTrack(track);
|
||||
}
|
||||
|
||||
const filteredTracks = $derived.by(() => {
|
||||
const items = result?.tracks.items ?? [];
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) return items;
|
||||
|
||||
return items.filter((track) => {
|
||||
const title = (track.title ?? '').toLowerCase();
|
||||
const artist = (track.performer?.name ?? '').toLowerCase();
|
||||
const album = (track.album?.title ?? '').toLowerCase();
|
||||
return title.includes(query) || artist.includes(query) || album.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (autoRunDone) return;
|
||||
autoRunDone = true;
|
||||
void generateDailyQ('read-write');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dailyq-view">
|
||||
<div class="playlist-header">
|
||||
<div class="artwork-container">
|
||||
<div class="artwork">
|
||||
{#if result && result.tracks.items.length > 0}
|
||||
<img src={getQobuzImage(result.tracks.items[0].album?.image)} alt={$t('yourMixes.title')} />
|
||||
{:else}
|
||||
<div class="placeholder-art">
|
||||
<Sparkles size={34} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metadata">
|
||||
<span class="playlist-label">{$t('favorites.playlists')}</span>
|
||||
<h1 class="playlist-title">{$t('yourMixes.title')}</h1>
|
||||
<p class="playlist-description">{$t('yourMixes.subtitle')}</p>
|
||||
<div class="playlist-info">
|
||||
{#if mixSource === 'cache'}
|
||||
<span>{$t('yourMixes.result.sourceCached')}</span>
|
||||
<span class="separator">•</span>
|
||||
{:else if mixSource === 'live'}
|
||||
<span>{$t('yourMixes.result.sourceLive')}</span>
|
||||
<span class="separator">•</span>
|
||||
{/if}
|
||||
<span>{$t('yourMixes.result.count', { values: { count: filteredTracks.length } })}</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="action-btn-circle primary" onclick={() => generateDailyQ('none')} disabled={loading} title={$t('actions.refresh')}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<button class="action-btn-circle" onclick={openSaveAsPlaylist} disabled={loading || !result || result.tracks.items.length === 0} title={$t('yourMixes.actions.saveAsPlaylist')}>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
<button class="action-btn-circle" onclick={handlePlayAll} disabled={loading || filteredTracks.length === 0} title={$t('actions.playNow')}>
|
||||
<Play size={16} />
|
||||
</button>
|
||||
<button class="action-btn-circle" onclick={handleShuffle} disabled={loading || filteredTracks.length === 0} title={$t('actions.shuffle')}>
|
||||
<Shuffle size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="track-controls">
|
||||
<div class="search-container">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$t('placeholders.searchInPlaylist')}
|
||||
bind:value={searchQuery}
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !result && loading}
|
||||
<div class="empty">{$t('actions.loading')}</div>
|
||||
{:else if result && filteredTracks.length === 0}
|
||||
<div class="empty">{$t('yourMixes.result.empty')}</div>
|
||||
{:else if result}
|
||||
<div class="track-list">
|
||||
<div class="track-list-header">
|
||||
<span class="col-number">#</span>
|
||||
<span class="col-title">{$t('common.title')}</span>
|
||||
<span class="col-album">{$t('purchases.sort.album')}</span>
|
||||
<span class="col-duration">{$t('album.duration')}</span>
|
||||
<span class="col-quality">{$t('album.quality')}</span>
|
||||
</div>
|
||||
|
||||
{#each filteredTracks as track, index}
|
||||
<TrackRow
|
||||
trackId={track.id}
|
||||
number={index + 1}
|
||||
title={track.title}
|
||||
artist={track.performer?.name}
|
||||
album={track.album?.title}
|
||||
duration={formatDuration(track.duration || 0)}
|
||||
quality={qualityLabel(track)}
|
||||
showArtwork={true}
|
||||
artworkUrl={getQobuzImage(track.album?.image)}
|
||||
hideDownload={true}
|
||||
onPlay={() => playTrack(track)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PlaylistModal
|
||||
isOpen={showPlaylistModal}
|
||||
mode="addTrack"
|
||||
trackIds={playlistModalTrackIds}
|
||||
userPlaylists={userPlaylists}
|
||||
isLocalTracks={false}
|
||||
onClose={() => {
|
||||
showPlaylistModal = false;
|
||||
}}
|
||||
onSuccess={() => {
|
||||
showToast($t('toast.addedToPlaylist'), 'success');
|
||||
showPlaylistModal = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.dailyq-view {
|
||||
padding: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.playlist-header {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 18px;
|
||||
margin-bottom: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.artwork {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.artwork img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.placeholder-art {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.playlist-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
margin: 4px 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.playlist-description {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn-circle {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-primary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn-circle.primary {
|
||||
background: var(--accent-primary, #f5a524);
|
||||
border-color: var(--accent-primary, #f5a524);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.action-btn-circle:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0 0 10px;
|
||||
color: var(--error-color, #ef4444);
|
||||
}
|
||||
|
||||
.track-controls {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.track-list {
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.track-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr 1fr 92px 92px;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.col-number,
|
||||
.col-duration,
|
||||
.col-quality {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty {
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.playlist-header {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.track-list-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1678,6 +1678,40 @@
|
||||
},
|
||||
"regionNotice": "Purchases availability depends on your Qobuz account and region. If unavailable, this may be expected."
|
||||
},
|
||||
"yourMixes": {
|
||||
"title": "DailyQ",
|
||||
"subtitle": "Based on your recent listening.",
|
||||
"actions": {
|
||||
"saveAsPlaylist": "Save as playlist"
|
||||
},
|
||||
"form": {
|
||||
"limit": "Limit",
|
||||
"listenedTrackIds": "Listened track IDs (comma-separated)",
|
||||
"listenedTrackIdsPlaceholder": "123,456,789",
|
||||
"tracksToAnalyse": "Tracks to analyse (one row: trackId,artistId,genreId,labelId)",
|
||||
"tracksToAnalysePlaceholder": "123,45,78,90",
|
||||
"prefillFromRecents": "Use QBZ recent log",
|
||||
"run": "Fetch Suggestions",
|
||||
"probeRaw": "Probe Raw Response",
|
||||
"rawResponse": "Raw response"
|
||||
},
|
||||
"result": {
|
||||
"algorithm": "Algorithm",
|
||||
"count": "{count} tracks",
|
||||
"sourceCached": "Cached for today",
|
||||
"sourceLive": "Freshly generated",
|
||||
"empty": "No suggested tracks returned.",
|
||||
"emptyHint": "Tried fallback payload strategies. This account/seed set may currently return no mix candidates.",
|
||||
"unknownArtist": "Unknown artist"
|
||||
},
|
||||
"errors": {
|
||||
"fetchFailed": "Failed to fetch dynamic suggestions.",
|
||||
"prefillFailed": "Failed to load recent data from QBZ logs.",
|
||||
"rawProbeFailed": "Failed to fetch raw endpoint response.",
|
||||
"playlistsLoadFailed": "Failed to load your playlists.",
|
||||
"emptyPlaylist": "No tracks available to save as playlist."
|
||||
}
|
||||
},
|
||||
"linkResolver": {
|
||||
"title": "Open Qobuz Link",
|
||||
"placeholder": "Paste a Qobuz link (album, track, artist, playlist)...",
|
||||
|
||||
@@ -1678,6 +1678,40 @@
|
||||
},
|
||||
"regionNotice": "La disponibilidad de Compras depende de tu cuenta y region de Qobuz. Si no aparece disponible, puede ser esperado."
|
||||
},
|
||||
"yourMixes": {
|
||||
"title": "DailyQ",
|
||||
"subtitle": "Basado en tus escuchas recientes.",
|
||||
"actions": {
|
||||
"saveAsPlaylist": "Guardar como playlist"
|
||||
},
|
||||
"form": {
|
||||
"limit": "Limite",
|
||||
"listenedTrackIds": "IDs de pistas escuchadas (separadas por coma)",
|
||||
"listenedTrackIdsPlaceholder": "123,456,789",
|
||||
"tracksToAnalyse": "Pistas a analizar (una linea: trackId,artistId,genreId,labelId)",
|
||||
"tracksToAnalysePlaceholder": "123,45,78,90",
|
||||
"prefillFromRecents": "Usar log reciente de QBZ",
|
||||
"run": "Obtener sugerencias",
|
||||
"probeRaw": "Probar respuesta raw",
|
||||
"rawResponse": "Respuesta raw"
|
||||
},
|
||||
"result": {
|
||||
"algorithm": "Algoritmo",
|
||||
"count": "{count} pistas",
|
||||
"sourceCached": "Cacheado por hoy",
|
||||
"sourceLive": "Generado al momento",
|
||||
"empty": "No se devolvieron pistas sugeridas.",
|
||||
"emptyHint": "Se probaron estrategias de fallback del payload. Esta cuenta/semillas puede no estar devolviendo candidatos ahora.",
|
||||
"unknownArtist": "Artista desconocido"
|
||||
},
|
||||
"errors": {
|
||||
"fetchFailed": "No se pudieron obtener sugerencias dinamicas.",
|
||||
"prefillFailed": "No se pudieron cargar datos recientes del log de QBZ.",
|
||||
"rawProbeFailed": "No se pudo obtener la respuesta raw del endpoint.",
|
||||
"playlistsLoadFailed": "No se pudieron cargar tus playlists.",
|
||||
"emptyPlaylist": "No hay pistas para guardar como playlist."
|
||||
}
|
||||
},
|
||||
"linkResolver": {
|
||||
"title": "Abrir enlace de Qobuz",
|
||||
"placeholder": "Pega un enlace de Qobuz (album, track, artista, playlist)...",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type {
|
||||
DynamicSuggestRequest,
|
||||
DynamicSuggestResponse
|
||||
} from '$lib/types/dynamicSuggest';
|
||||
|
||||
export async function getDynamicSuggest(
|
||||
payload: DynamicSuggestRequest
|
||||
): Promise<DynamicSuggestResponse> {
|
||||
return invoke<DynamicSuggestResponse>('v2_dynamic_suggest', {
|
||||
limit: payload.limit ?? null,
|
||||
listenedTrackIds: payload.listenedTrackIds ?? [],
|
||||
tracksToAnalyse: payload.tracksToAnalyse ?? []
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDynamicSuggestRaw(
|
||||
payload: DynamicSuggestRequest
|
||||
): Promise<unknown> {
|
||||
return invoke<unknown>('v2_dynamic_suggest_raw', {
|
||||
limit: payload.limit ?? null,
|
||||
listenedTrackIds: payload.listenedTrackIds ?? [],
|
||||
tracksToAnalyse: payload.tracksToAnalyse ?? []
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface DynamicTrackToAnalyseInput {
|
||||
trackId: number;
|
||||
artistId: number;
|
||||
genreId: number;
|
||||
labelId: number;
|
||||
}
|
||||
|
||||
export interface DynamicSuggestTrack {
|
||||
id: number;
|
||||
title: string;
|
||||
duration?: number;
|
||||
performer?: { id?: number; name: string };
|
||||
album?: {
|
||||
id?: string;
|
||||
title: string;
|
||||
image?: { small?: string; thumbnail?: string; large?: string };
|
||||
};
|
||||
hires?: boolean;
|
||||
maximum_bit_depth?: number;
|
||||
maximum_sampling_rate?: number;
|
||||
streamable?: boolean;
|
||||
}
|
||||
|
||||
export interface DynamicSuggestResponse {
|
||||
algorithm: string;
|
||||
tracks: {
|
||||
offset: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
items: DynamicSuggestTrack[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DynamicSuggestRequest {
|
||||
limit?: number;
|
||||
listenedTrackIds?: number[];
|
||||
tracksToAnalyse?: DynamicTrackToAnalyseInput[];
|
||||
}
|
||||
Reference in new issue
Block a user