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."
|
"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": {
|
"linkResolver": {
|
||||||
"title": "Open Qobuz Link",
|
"title": "Open Qobuz Link",
|
||||||
"placeholder": "Paste a Qobuz link (album, track, artist, playlist)...",
|
"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."
|
"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": {
|
"linkResolver": {
|
||||||
"title": "Abrir enlace de Qobuz",
|
"title": "Abrir enlace de Qobuz",
|
||||||
"placeholder": "Pega un enlace de Qobuz (album, track, artista, playlist)...",
|
"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