feat(dz): add local caching and UI for user favorites

This commit is contained in:
2025-10-02 12:17:04 -04:00
parent 0d7361db4b
commit d8456ce912
5 changed files with 864 additions and 226 deletions

View File

@@ -26,7 +26,7 @@ fn tag_audio_file(
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let migrations = vec![
let library_migrations = vec![
Migration {
version: 1,
description: "create_library_tables",
@@ -65,10 +65,64 @@ pub fn run() {
}
];
let deezer_migrations = vec![
Migration {
version: 1,
description: "create_deezer_cache_tables",
sql: "
CREATE TABLE IF NOT EXISTS deezer_playlists (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
nb_tracks INTEGER DEFAULT 0,
creator_name TEXT,
picture_small TEXT,
picture_medium TEXT,
cached_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS deezer_albums (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
artist_name TEXT NOT NULL,
nb_tracks INTEGER DEFAULT 0,
release_date TEXT,
picture_small TEXT,
picture_medium TEXT,
cached_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS deezer_artists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
nb_album INTEGER DEFAULT 0,
picture_small TEXT,
picture_medium TEXT,
cached_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS deezer_tracks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_title TEXT,
duration INTEGER DEFAULT 0,
cached_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_deezer_playlists_title ON deezer_playlists(title);
CREATE INDEX IF NOT EXISTS idx_deezer_albums_artist ON deezer_albums(artist_name);
CREATE INDEX IF NOT EXISTS idx_deezer_artists_name ON deezer_artists(name);
CREATE INDEX IF NOT EXISTS idx_deezer_tracks_title ON deezer_tracks(title);
",
kind: MigrationKind::Up,
}
];
tauri::Builder::default()
.plugin(
tauri_plugin_sql::Builder::new()
.add_migrations("sqlite:library.db", migrations)
.add_migrations("sqlite:library.db", library_migrations)
.add_migrations("sqlite:deezer.db", deezer_migrations)
.build()
)
.plugin(tauri_plugin_http::init())

View File

@@ -0,0 +1,242 @@
import Database from '@tauri-apps/plugin-sql';
export interface DeezerPlaylist {
id: string;
title: string;
nb_tracks: number;
creator_name: string;
picture_small?: string;
picture_medium?: string;
cached_at: number;
}
export interface DeezerAlbum {
id: string;
title: string;
artist_name: string;
nb_tracks: number;
release_date?: string;
picture_small?: string;
picture_medium?: string;
cached_at: number;
}
export interface DeezerArtist {
id: string;
name: string;
nb_album: number;
picture_small?: string;
picture_medium?: string;
cached_at: number;
}
export interface DeezerTrack {
id: string;
title: string;
artist_name: string;
album_title: string;
duration: number;
cached_at: number;
}
let db: Database | null = null;
/**
* Initialize database connection
*/
export async function initDeezerDatabase(): Promise<Database> {
if (!db) {
db = await Database.load('sqlite:deezer.db');
}
return db;
}
/**
* Get cached playlists
*/
export async function getCachedPlaylists(): Promise<DeezerPlaylist[]> {
const database = await initDeezerDatabase();
const playlists = await database.select<DeezerPlaylist[]>(
'SELECT * FROM deezer_playlists ORDER BY title COLLATE NOCASE'
);
return playlists || [];
}
/**
* Get cached albums
*/
export async function getCachedAlbums(): Promise<DeezerAlbum[]> {
const database = await initDeezerDatabase();
const albums = await database.select<DeezerAlbum[]>(
'SELECT * FROM deezer_albums ORDER BY artist_name COLLATE NOCASE, title COLLATE NOCASE'
);
return albums || [];
}
/**
* Get cached artists
*/
export async function getCachedArtists(): Promise<DeezerArtist[]> {
const database = await initDeezerDatabase();
const artists = await database.select<DeezerArtist[]>(
'SELECT * FROM deezer_artists ORDER BY name COLLATE NOCASE'
);
return artists || [];
}
/**
* Get cached tracks
*/
export async function getCachedTracks(): Promise<DeezerTrack[]> {
const database = await initDeezerDatabase();
const tracks = await database.select<DeezerTrack[]>(
'SELECT * FROM deezer_tracks ORDER BY title COLLATE NOCASE'
);
return tracks || [];
}
/**
* Upsert playlists
*/
export async function upsertPlaylists(playlists: any[]): Promise<void> {
try {
console.log('[deezer-database] Upserting playlists, count:', playlists.length);
if (playlists.length > 0) {
console.log('[deezer-database] First playlist sample:', playlists[0]);
}
const database = await initDeezerDatabase();
const now = Math.floor(Date.now() / 1000);
// Clear existing playlists
await database.execute('DELETE FROM deezer_playlists');
console.log('[deezer-database] Cleared existing playlists');
// Insert new playlists
for (const playlist of playlists) {
await database.execute(
`INSERT INTO deezer_playlists (id, title, nb_tracks, creator_name, picture_small, picture_medium, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
String(playlist.PLAYLIST_ID),
playlist.TITLE || '',
playlist.NB_SONG || 0,
playlist.PARENT_USERNAME || 'Unknown',
playlist.PLAYLIST_PICTURE || null,
playlist.PICTURE_TYPE || null,
now
]
);
}
console.log('[deezer-database] Inserted', playlists.length, 'playlists');
} catch (err) {
console.error('[deezer-database] Error in upsertPlaylists:', err);
throw err;
}
}
/**
* Upsert albums
*/
export async function upsertAlbums(albums: any[]): Promise<void> {
const database = await initDeezerDatabase();
const now = Math.floor(Date.now() / 1000);
// Clear existing albums
await database.execute('DELETE FROM deezer_albums');
// Insert new albums
for (const album of albums) {
await database.execute(
`INSERT INTO deezer_albums (id, title, artist_name, nb_tracks, release_date, picture_small, picture_medium, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
String(album.ALB_ID),
album.ALB_TITLE || '',
album.ART_NAME || 'Unknown',
album.NB_SONG || 0,
album.PHYSICAL_RELEASE_DATE || null,
album.ALB_PICTURE || null,
album.PICTURE_TYPE || null,
now
]
);
}
}
/**
* Upsert artists
*/
export async function upsertArtists(artists: any[]): Promise<void> {
const database = await initDeezerDatabase();
const now = Math.floor(Date.now() / 1000);
// Clear existing artists
await database.execute('DELETE FROM deezer_artists');
// Insert new artists
for (const artist of artists) {
await database.execute(
`INSERT INTO deezer_artists (id, name, nb_album, picture_small, picture_medium, cached_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
String(artist.ART_ID),
artist.ART_NAME || '',
artist.NB_ALBUM || 0,
artist.ART_PICTURE || null,
artist.PICTURE_TYPE || null,
now
]
);
}
}
/**
* Upsert tracks
*/
export async function upsertTracks(tracks: any[]): Promise<void> {
const database = await initDeezerDatabase();
const now = Math.floor(Date.now() / 1000);
// Clear existing tracks
await database.execute('DELETE FROM deezer_tracks');
// Insert new tracks
for (const track of tracks) {
await database.execute(
`INSERT INTO deezer_tracks (id, title, artist_name, album_title, duration, cached_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
String(track.SNG_ID),
track.SNG_TITLE || '',
track.ART_NAME || 'Unknown',
track.ALB_TITLE || '',
track.DURATION || 0,
now
]
);
}
}
/**
* Get cache timestamp
*/
export async function getCacheTimestamp(): Promise<number | null> {
const database = await initDeezerDatabase();
const result = await database.select<{ cached_at: number }[]>(
'SELECT cached_at FROM deezer_playlists LIMIT 1'
);
return result[0]?.cached_at || null;
}
/**
* Clear all Deezer cache
*/
export async function clearDeezerCache(): Promise<void> {
const database = await initDeezerDatabase();
await database.execute('DELETE FROM deezer_playlists');
await database.execute('DELETE FROM deezer_albums');
await database.execute('DELETE FROM deezer_artists');
await database.execute('DELETE FROM deezer_tracks');
await database.execute('VACUUM');
}

View File

@@ -307,7 +307,7 @@ export class DeezerAPI {
const response = await this.apiCall('deezer.pageProfile', {
USER_ID: userId,
tab: 'playlists',
nb: 100
nb: -1
});
return response.TAB?.playlists?.data || [];
@@ -317,6 +317,86 @@ export class DeezerAPI {
}
}
// Get user albums
async getUserAlbums(): Promise<any[]> {
try {
const userData = await this.getUserData();
const userId = userData.USER.USER_ID;
const response = await this.apiCall('deezer.pageProfile', {
USER_ID: userId,
tab: 'albums',
nb: -1
});
return response.TAB?.albums?.data || [];
} catch (error) {
console.error('Error fetching albums:', error);
return [];
}
}
// Get user artists
async getUserArtists(): Promise<any[]> {
try {
const userData = await this.getUserData();
const userId = userData.USER.USER_ID;
const response = await this.apiCall('deezer.pageProfile', {
USER_ID: userId,
tab: 'artists',
nb: -1
});
return response.TAB?.artists?.data || [];
} catch (error) {
console.error('Error fetching artists:', error);
return [];
}
}
// Get user favorite tracks (uses the more reliable song.getFavoriteIds method)
async getUserTracks(): Promise<any[]> {
try {
// Get favorite track IDs
const idsResponse = await this.apiCall('song.getFavoriteIds', {
nb: -1,
start: 0,
checksum: null
});
const trackIds = idsResponse.data?.map((x: any) => x.SNG_ID) || [];
if (trackIds.length === 0) {
console.log('[Deezer] No favorite tracks found');
return [];
}
console.log(`[Deezer] Found ${trackIds.length} favorite track IDs, fetching details...`);
// Fetch track details in batches (Deezer API might have limits)
const batchSize = 100;
const tracks: any[] = [];
for (let i = 0; i < trackIds.length; i += batchSize) {
const batchIds = trackIds.slice(i, i + batchSize);
const batchResponse = await this.apiCall('song.getListData', {
SNG_IDS: batchIds
});
if (batchResponse.data) {
tracks.push(...batchResponse.data);
}
}
console.log(`[Deezer] Fetched ${tracks.length} track details`);
return tracks;
} catch (error) {
console.error('Error fetching favorite tracks:', error);
return [];
}
}
// Get album data
async getAlbumData(albumId: string): Promise<any> {
return this.apiCall('album.getData', { alb_id: albumId });

View File

@@ -18,6 +18,7 @@ export interface DeezerAuthState {
arl: string | null;
user: DeezerUser | null;
loggedIn: boolean;
cacheTimestamp: number | null;
}
// Initialize the store with deezer.json
@@ -27,7 +28,8 @@ const store = new LazyStore('deezer.json');
const defaultState: DeezerAuthState = {
arl: null,
user: null,
loggedIn: false
loggedIn: false,
cacheTimestamp: null
};
// Create a writable store for reactive UI updates
@@ -37,11 +39,13 @@ export const deezerAuth: Writable<DeezerAuthState> = writable(defaultState);
export async function loadDeezerAuth(): Promise<void> {
const arl = await store.get<string>('arl');
const user = await store.get<DeezerUser>('user');
const cacheTimestamp = await store.get<number>('cacheTimestamp');
deezerAuth.set({
arl: arl ?? null,
user: user ?? null,
loggedIn: !!(arl && user)
loggedIn: !!(arl && user),
cacheTimestamp: cacheTimestamp ?? null
});
}
@@ -82,5 +86,16 @@ export async function getArl(): Promise<string | null> {
return (await store.get<string>('arl')) ?? null;
}
// Save cache timestamp
export async function saveCacheTimestamp(timestamp: number): Promise<void> {
await store.set('cacheTimestamp', timestamp);
await store.save();
deezerAuth.update(s => ({
...s,
cacheTimestamp: timestamp
}));
}
// Initialize on module load
loadDeezerAuth();

View File

@@ -1,44 +1,167 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { deezerAuth, loadDeezerAuth, saveArl, saveUser, clearDeezerAuth } from '$lib/stores/deezer';
import { deezerAuth, loadDeezerAuth, saveArl, saveUser, clearDeezerAuth, saveCacheTimestamp } from '$lib/stores/deezer';
import { deezerAPI } from '$lib/services/deezer';
import { addDeezerTrackToQueue } from '$lib/services/deezer/addToQueue';
import { settings } from '$lib/stores/settings';
import {
getCachedPlaylists,
getCachedAlbums,
getCachedArtists,
getCachedTracks,
upsertPlaylists,
upsertAlbums,
upsertArtists,
upsertTracks,
type DeezerPlaylist,
type DeezerAlbum,
type DeezerArtist,
type DeezerTrack
} from '$lib/library/deezer-database';
type ViewMode = 'playlists' | 'tracks' | 'artists' | 'albums' | 'user';
let viewMode = $state<ViewMode>('playlists');
let playlists = $state<DeezerPlaylist[]>([]);
let albums = $state<DeezerAlbum[]>([]);
let artists = $state<DeezerArtist[]>([]);
let tracks = $state<DeezerTrack[]>([]);
let loading = $state(true);
let syncing = $state(false);
let error = $state<string | null>(null);
let selectedIndex = $state<number | null>(null);
// Login form state
let arlInput = $state('');
let isLoading = $state(false);
let errorMessage = $state('');
let successMessage = $state('');
let testingAuth = $state(false);
let authTestResult = $state<string | null>(null);
let isLogging = $state(false);
let loginError = $state('');
let loginSuccess = $state('');
// Track add to queue test
let trackIdInput = $state('3135556'); // Default: Daft Punk - One More Time
let isFetchingTrack = $state(false);
let isAddingToQueue = $state(false);
let trackInfo = $state<any>(null);
let queueStatus = $state('');
let queueError = $state('');
// User refresh state
let refreshingUser = $state(false);
let userRefreshMessage = $state('');
const CACHE_DURATION = 24 * 60 * 60; // 24 hours in seconds
onMount(async () => {
await loadDeezerAuth();
if ($deezerAuth.loggedIn) {
await loadFavorites();
} else {
loading = false;
}
});
async function loadFavorites() {
loading = true;
error = null;
try {
// Check if we need to refresh cache
const now = Math.floor(Date.now() / 1000);
const cacheAge = $deezerAuth.cacheTimestamp ? now - $deezerAuth.cacheTimestamp : Infinity;
const needsRefresh = cacheAge > CACHE_DURATION;
if (needsRefresh) {
await refreshFavorites();
} else {
// Load from cache
const [cachedPlaylists, cachedAlbums, cachedArtists, cachedTracks] = await Promise.all([
getCachedPlaylists(),
getCachedAlbums(),
getCachedArtists(),
getCachedTracks()
]);
playlists = cachedPlaylists;
albums = cachedAlbums;
artists = cachedArtists;
tracks = cachedTracks;
}
} catch (e) {
error = 'Error loading favorites: ' + (e instanceof Error ? e.message : String(e));
// Switch to user tab to show error
viewMode = 'user';
} finally {
loading = false;
}
}
async function refreshFavorites() {
if (!$deezerAuth.arl || syncing) {
return;
}
syncing = true;
error = null;
try {
deezerAPI.setArl($deezerAuth.arl);
// Fetch all favorites from API
console.log('[Deezer] Fetching favorites from API...');
const [apiPlaylists, apiAlbums, apiArtists, apiTracks] = await Promise.all([
deezerAPI.getUserPlaylists(),
deezerAPI.getUserAlbums(),
deezerAPI.getUserArtists(),
deezerAPI.getUserTracks()
]);
console.log('[Deezer] Fetched from API:', {
playlists: apiPlaylists.length,
albums: apiAlbums.length,
artists: apiArtists.length,
tracks: apiTracks.length
});
// Update database cache
console.log('[Deezer] Updating database cache...');
await upsertPlaylists(apiPlaylists);
await upsertAlbums(apiAlbums);
await upsertArtists(apiArtists);
await upsertTracks(apiTracks);
// Update cache timestamp
const now = Math.floor(Date.now() / 1000);
await saveCacheTimestamp(now);
console.log('[Deezer] Reloading from cache...');
// Reload from cache
const [cachedPlaylists, cachedAlbums, cachedArtists, cachedTracks] = await Promise.all([
getCachedPlaylists(),
getCachedAlbums(),
getCachedArtists(),
getCachedTracks()
]);
playlists = cachedPlaylists;
albums = cachedAlbums;
artists = cachedArtists;
tracks = cachedTracks;
console.log('[Deezer] Refresh complete!');
} catch (e) {
console.error('Error refreshing favorites:', e);
error = 'Error refreshing favorites: ' + (e instanceof Error ? e.message : String(e));
// Switch to user tab to show error
viewMode = 'user';
} finally {
syncing = false;
}
}
async function handleLogin() {
if (!arlInput || arlInput.trim().length === 0) {
errorMessage = 'Please enter an ARL token';
loginError = 'Please enter an ARL token';
return;
}
if (arlInput.trim().length !== 192) {
errorMessage = 'ARL token should be 192 characters long';
loginError = 'ARL token should be 192 characters long';
return;
}
isLoading = true;
errorMessage = '';
successMessage = '';
isLogging = true;
loginError = '';
loginSuccess = '';
try {
const result = await deezerAPI.loginViaArl(arlInput.trim());
@@ -46,114 +169,76 @@
if (result.success && result.user) {
await saveArl(arlInput.trim());
await saveUser(result.user);
successMessage = `Successfully logged in as ${result.user.name}!`;
loginSuccess = `Successfully logged in as ${result.user.name}!`;
arlInput = '';
// Load favorites after login
await loadFavorites();
} else {
errorMessage = result.error || 'Login failed. Please check your ARL token.';
loginError = result.error || 'Login failed. Please check your ARL token.';
}
} catch (error) {
errorMessage = `Login error: ${error instanceof Error ? error.message : 'Unknown error'}`;
loginError = `Login error: ${error instanceof Error ? error.message : 'Unknown error'}`;
} finally {
isLoading = false;
isLogging = false;
}
}
async function handleLogout() {
await clearDeezerAuth();
successMessage = 'Logged out successfully';
errorMessage = '';
authTestResult = null;
playlists = [];
albums = [];
artists = [];
tracks = [];
loginSuccess = '';
loginError = '';
}
async function testAuthentication() {
if (!$deezerAuth.arl) {
authTestResult = 'Not logged in';
async function handleRefreshUser() {
if (!$deezerAuth.arl || refreshingUser) {
return;
}
testingAuth = true;
authTestResult = null;
refreshingUser = true;
userRefreshMessage = '';
try {
deezerAPI.setArl($deezerAuth.arl);
const isValid = await deezerAPI.testAuth();
authTestResult = isValid ? '✓ Authentication is working!' : '✗ Authentication failed';
} catch (error) {
authTestResult = `✗ Test failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
} finally {
testingAuth = false;
}
}
const result = await deezerAPI.loginViaArl($deezerAuth.arl);
async function fetchTrackInfo() {
if (!$deezerAuth.arl || !$deezerAuth.user) {
queueError = 'Not logged in';
return;
}
isFetchingTrack = true;
queueError = '';
trackInfo = null;
try {
deezerAPI.setArl($deezerAuth.arl);
const trackData = await deezerAPI.getTrack(trackIdInput);
console.log('Track data:', trackData);
if (!trackData || !trackData.SNG_ID) {
throw new Error('Track not found or invalid track ID');
if (result.success && result.user) {
await saveUser(result.user);
userRefreshMessage = 'User info refreshed successfully!';
} else {
userRefreshMessage = 'Failed to refresh user info';
}
trackInfo = trackData;
} catch (error) {
console.error('Fetch error:', error);
queueError = error instanceof Error ? error.message : 'Failed to fetch track';
userRefreshMessage = 'Error refreshing user info: ' + (error instanceof Error ? error.message : 'Unknown error');
} finally {
isFetchingTrack = false;
refreshingUser = false;
setTimeout(() => {
userRefreshMessage = '';
}, 3000);
}
}
async function addTrackToQueue() {
if (!trackInfo) {
queueError = 'Please fetch track info first';
return;
}
function handleItemClick(index: number) {
selectedIndex = index;
}
if (!$settings.musicFolder) {
queueError = 'Please set a music folder in Settings first';
return;
}
isAddingToQueue = true;
queueStatus = '';
queueError = '';
try {
// Use shared utility to add track to queue
await addDeezerTrackToQueue(trackIdInput);
queueStatus = '✓ Added to download queue!';
// Navigate to downloads page after brief delay
setTimeout(() => {
goto('/downloads');
}, 1000);
} catch (error) {
console.error('Queue error:', error);
queueError = error instanceof Error ? error.message : 'Failed to add to queue';
} finally {
isAddingToQueue = false;
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
}
</script>
<div class="deezer-page">
<h2>Deezer Authentication</h2>
<div class="deezer-wrapper">
<h2 style="padding: 8px">Deezer</h2>
{#if !$deezerAuth.loggedIn}
<!-- Login Form -->
<section class="window login-section">
<section class="window login-section" style="max-width: 600px; margin: 8px;">
<div class="title-bar">
<div class="title-bar-text">Login to Deezer</div>
</div>
@@ -167,29 +252,28 @@
type="password"
bind:value={arlInput}
placeholder="192 character ARL token"
disabled={isLoading}
disabled={isLogging}
/>
</div>
{#if errorMessage}
{#if loginError}
<div class="error-message">
{errorMessage}
{loginError}
</div>
{/if}
{#if successMessage}
{#if loginSuccess}
<div class="success-message">
{successMessage}
{loginSuccess}
</div>
{/if}
<div class="button-row">
<button onclick={handleLogin} disabled={isLoading}>
{isLoading ? 'Logging in...' : 'Login'}
<button onclick={handleLogin} disabled={isLogging}>
{isLogging ? 'Logging in...' : 'Login'}
</button>
</div>
<!-- Instructions -->
<details class="instructions">
<summary>How to get your ARL token</summary>
<div class="instructions-content">
@@ -208,121 +292,233 @@
</details>
</div>
</section>
{:else if loading}
<p style="padding: 8px;">Loading favorites...</p>
{:else}
<!-- Logged In View -->
<section class="window user-section">
<div class="title-bar">
<div class="title-bar-text">User Info</div>
</div>
<div class="window-body">
<div class="user-info">
<div class="field-row">
<span>Name:</span>
<span>{$deezerAuth.user?.name || 'Unknown'}</span>
</div>
<div class="field-row">
<span>User ID:</span>
<span>{$deezerAuth.user?.id || 'N/A'}</span>
</div>
<div class="field-row">
<span>Country:</span>
<span>{$deezerAuth.user?.country || 'N/A'}</span>
</div>
<div class="field-row">
<span>HQ Streaming:</span>
<span>{$deezerAuth.user?.can_stream_hq ? '✓ Yes' : '✗ No'}</span>
</div>
<div class="field-row">
<span>Lossless Streaming:</span>
<span>{$deezerAuth.user?.can_stream_lossless ? '✓ Yes' : '✗ No'}</span>
</div>
<section class="favorites-content">
<!-- Tabs -->
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
-->
<menu role="tablist">
<li role="tab" aria-selected={viewMode === 'playlists'}>
<button onclick={() => viewMode = 'playlists'}>Playlists</button>
</li>
<li role="tab" aria-selected={viewMode === 'tracks'}>
<button onclick={() => viewMode = 'tracks'}>Tracks</button>
</li>
<li role="tab" aria-selected={viewMode === 'artists'}>
<button onclick={() => viewMode = 'artists'}>Artists</button>
</li>
<li role="tab" aria-selected={viewMode === 'albums'}>
<button onclick={() => viewMode = 'albums'}>Albums</button>
</li>
<li role="tab" aria-selected={viewMode === 'user'}>
<button onclick={() => viewMode = 'user'}>User</button>
</li>
</menu>
<!-- Tab Content -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if syncing}
<div class="sync-status">
<p>Refreshing favorites from Deezer...</p>
</div>
{:else if viewMode === 'playlists'}
<!-- Playlists View -->
<div class="tab-header">
<h4>Favorite Playlists</h4>
<button onclick={refreshFavorites} disabled={syncing}>
Refresh
</button>
</div>
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Playlist</th>
<th>Tracks</th>
<th>Creator</th>
</tr>
</thead>
<tbody>
{#each playlists as playlist, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleItemClick(i)}
>
<td>{playlist.title}</td>
<td>{playlist.nb_tracks}</td>
<td>{playlist.creator_name}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'tracks'}
<!-- Tracks View -->
<div class="tab-header">
<h4>Favorite Tracks</h4>
<button onclick={refreshFavorites} disabled={syncing}>
Refresh
</button>
</div>
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{#each tracks as track, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleItemClick(i)}
>
<td>{track.title}</td>
<td>{track.artist_name}</td>
<td>{track.album_title}</td>
<td class="duration">{formatDuration(track.duration)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'artists'}
<!-- Artists View -->
<div class="tab-header">
<h4>Favorite Artists</h4>
<button onclick={refreshFavorites} disabled={syncing}>
Refresh
</button>
</div>
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Artist</th>
<th>Albums</th>
</tr>
</thead>
<tbody>
{#each artists as artist, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleItemClick(i)}
>
<td>{artist.name}</td>
<td>{artist.nb_album}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'albums'}
<!-- Albums View -->
<div class="tab-header">
<h4>Favorite Albums</h4>
<button onclick={refreshFavorites} disabled={syncing}>
Refresh
</button>
</div>
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Album</th>
<th>Artist</th>
<th>Tracks</th>
<th>Year</th>
</tr>
</thead>
<tbody>
{#each albums as album, i}
<tr
class:highlighted={selectedIndex === i}
onclick={() => handleItemClick(i)}
>
<td>{album.title}</td>
<td>{album.artist_name}</td>
<td>{album.nb_tracks}</td>
<td>{album.release_date ? new Date(album.release_date).getFullYear() : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if viewMode === 'user'}
<!-- User Info View -->
<div class="user-container">
{#if error}
<fieldset>
<legend>Error</legend>
<div class="error-message">
{error}
</div>
</fieldset>
{/if}
<fieldset>
<legend>User Information</legend>
<div class="field-row">
<span class="field-label">Name:</span>
<span>{$deezerAuth.user?.name || 'Unknown'}</span>
</div>
<div class="field-row">
<span class="field-label">Country:</span>
<span>{$deezerAuth.user?.country || 'N/A'}</span>
</div>
<div class="field-row">
<span class="field-label">HQ Streaming:</span>
<span>{$deezerAuth.user?.can_stream_hq ? '✓ Yes' : '✗ No'}</span>
</div>
<div class="field-row">
<span class="field-label">Lossless Streaming:</span>
<span>{$deezerAuth.user?.can_stream_lossless ? '✓ Yes' : '✗ No'}</span>
</div>
</fieldset>
{#if userRefreshMessage}
<div class="message-box">
{userRefreshMessage}
</div>
{/if}
<fieldset style="margin-top: 16px;">
<legend>Actions</legend>
<div class="button-row">
<button onclick={handleRefreshUser} disabled={refreshingUser}>
{refreshingUser ? 'Refreshing...' : 'Refresh User Info'}
</button>
<button onclick={handleLogout}>Logout</button>
</div>
</fieldset>
</div>
{/if}
</div>
{#if successMessage}
<div class="success-message">
{successMessage}
</div>
{/if}
<div class="button-row">
<button onclick={handleLogout}>Logout</button>
</div>
</div>
</section>
<!-- Add Track to Queue -->
<section class="window test-section">
<div class="title-bar">
<div class="title-bar-text">Add Track to Download Queue</div>
</div>
<div class="window-body">
<p>Add a track to the download queue:</p>
<div class="field-row-stacked">
<label for="track-id">Track ID (from Deezer URL)</label>
<input
id="track-id"
type="text"
bind:value={trackIdInput}
placeholder="e.g., 3135556"
disabled={isFetchingTrack || isAddingToQueue}
/>
<small class="help-text">Default: 3135556 (Daft Punk - One More Time)</small>
</div>
{#if trackInfo}
<div class="track-info">
<strong>{trackInfo.SNG_TITLE}</strong> by {trackInfo.ART_NAME}
<br>
<small>Album: {trackInfo.ALB_TITLE} • Duration: {Math.floor(trackInfo.DURATION / 60)}:{String(trackInfo.DURATION % 60).padStart(2, '0')}</small>
</div>
{/if}
{#if queueStatus}
<div class="success-message">
{queueStatus}
</div>
{/if}
{#if queueError}
<div class="error-message">
{queueError}
</div>
{/if}
<div class="button-row">
<button onclick={fetchTrackInfo} disabled={isFetchingTrack || isAddingToQueue}>
{isFetchingTrack ? 'Fetching...' : 'Fetch Track Info'}
</button>
<button onclick={addTrackToQueue} disabled={!trackInfo || isAddingToQueue || !$settings.musicFolder}>
{isAddingToQueue ? 'Adding...' : 'Add to Queue'}
</button>
</div>
{#if !$settings.musicFolder}
<p class="help-text" style="margin-top: 8px;">
⚠ Please set a music folder in Settings before adding to queue.
</p>
{/if}
</div>
</section>
{/if}
</div>
<style>
.deezer-page {
max-width: 600px;
.deezer-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
h2 {
margin-top: 0;
margin-bottom: 16px;
margin: 0;
}
.login-section,
.user-section,
.test-section {
.login-section {
margin-bottom: 16px;
}
@@ -343,23 +539,16 @@
margin-bottom: 8px;
}
.field-row span:first-child {
.field-label {
font-weight: bold;
min-width: 140px;
}
input[type="password"],
input[type="text"] {
input[type="password"] {
width: 100%;
padding: 4px;
}
.help-text {
color: var(--text-color, #FFFFFF);
opacity: 0.7;
font-size: 0.85em;
}
.button-row {
margin-top: 12px;
display: flex;
@@ -413,18 +602,76 @@
font-weight: bold;
}
.user-info {
margin-bottom: 12px;
.favorites-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.track-info {
.tab-content {
margin-top: -2px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content .window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--button-shadow, #808080);
}
.tab-header h4 {
margin: 0;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
table {
width: 100%;
}
th {
text-align: left;
}
.duration {
font-family: monospace;
font-size: 0.9em;
text-align: center;
width: 80px;
}
.user-container {
padding: 16px;
}
.message-box {
padding: 8px;
margin: 8px 0;
background-color: var(--button-shadow, #2a2a2a);
border: 1px solid var(--button-highlight, #606060);
}
.track-info strong {
font-weight: bold;
.sync-status {
padding: 16px 8px;
text-align: center;
}
</style>