mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +00:00
feat(dz): add local caching and UI for user favorites
This commit is contained in:
@@ -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())
|
||||
|
||||
242
src/lib/library/deezer-database.ts
Normal file
242
src/lib/library/deezer-database.ts
Normal 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');
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
const result = await deezerAPI.loginViaArl($deezerAuth.arl);
|
||||
|
||||
if (result.success && result.user) {
|
||||
await saveUser(result.user);
|
||||
userRefreshMessage = 'User info refreshed successfully!';
|
||||
} else {
|
||||
userRefreshMessage = 'Failed to refresh user info';
|
||||
}
|
||||
} catch (error) {
|
||||
authTestResult = `✗ Test failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
userRefreshMessage = 'Error refreshing user info: ' + (error instanceof Error ? error.message : 'Unknown error');
|
||||
} finally {
|
||||
testingAuth = false;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
trackInfo = trackData;
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
queueError = error instanceof Error ? error.message : 'Failed to fetch track';
|
||||
} finally {
|
||||
isFetchingTrack = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addTrackToQueue() {
|
||||
if (!trackInfo) {
|
||||
queueError = 'Please fetch track info first';
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
refreshingUser = false;
|
||||
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;
|
||||
userRefreshMessage = '';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(index: number) {
|
||||
selectedIndex = index;
|
||||
}
|
||||
|
||||
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>
|
||||
<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">
|
||||
<div class="user-info">
|
||||
{#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>Name:</span>
|
||||
<span class="field-label">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 class="field-label">Country:</span>
|
||||
<span>{$deezerAuth.user?.country || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span>HQ Streaming:</span>
|
||||
<span class="field-label">HQ Streaming:</span>
|
||||
<span>{$deezerAuth.user?.can_stream_hq ? '✓ Yes' : '✗ No'}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span>Lossless Streaming:</span>
|
||||
<span class="field-label">Lossless Streaming:</span>
|
||||
<span>{$deezerAuth.user?.can_stream_lossless ? '✓ Yes' : '✗ No'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if successMessage}
|
||||
<div class="success-message">
|
||||
✓ {successMessage}
|
||||
{#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>
|
||||
</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>
|
||||
</fieldset>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user