mirror of
https://github.com/markuryy/shark.git
synced 2026-06-19 02:51:02 +00:00
feat(spotify): library caching
This commit is contained in:
@@ -1,9 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { spotifyAuth, loadSpotifyAuth, saveClientCredentials, saveTokens, saveUser, clearSpotifyAuth } from '$lib/stores/spotify';
|
||||
import { goto } from '$app/navigation';
|
||||
import { spotifyAuth, loadSpotifyAuth, saveClientCredentials, saveTokens, saveUser, clearSpotifyAuth, saveCacheTimestamp } from '$lib/stores/spotify';
|
||||
import { spotifyAPI } from '$lib/services/spotify';
|
||||
import { start, onUrl } from '@fabianlars/tauri-plugin-oauth';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import {
|
||||
getCachedPlaylists,
|
||||
getCachedAlbums,
|
||||
getCachedArtists,
|
||||
getCachedTracks,
|
||||
upsertPlaylists,
|
||||
upsertAlbums,
|
||||
upsertArtists,
|
||||
upsertTracks,
|
||||
type SpotifyPlaylist,
|
||||
type SpotifyAlbum,
|
||||
type SpotifyArtist,
|
||||
type SpotifyTrack
|
||||
} from '$lib/library/spotify-database';
|
||||
|
||||
// Fixed port for OAuth callback - user must register this in Spotify Dashboard
|
||||
const OAUTH_PORT = 8228;
|
||||
@@ -20,6 +35,24 @@
|
||||
let isWaitingForCallback = $state(false);
|
||||
let oauthUnlisten: (() => void) | null = $state(null);
|
||||
|
||||
// Data state
|
||||
type ViewMode = 'playlists' | 'tracks' | 'artists' | 'albums' | 'info';
|
||||
let viewMode = $state<ViewMode>('playlists');
|
||||
let playlists = $state<SpotifyPlaylist[]>([]);
|
||||
let albums = $state<SpotifyAlbum[]>([]);
|
||||
let artists = $state<SpotifyArtist[]>([]);
|
||||
let tracks = $state<SpotifyTrack[]>([]);
|
||||
let loading = $state(true);
|
||||
let syncing = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedIndex = $state<number | null>(null);
|
||||
|
||||
// User refresh state
|
||||
let refreshingUser = $state(false);
|
||||
let userRefreshMessage = $state('');
|
||||
|
||||
const CACHE_DURATION = 24 * 60 * 60; // 24 hours in seconds
|
||||
|
||||
onMount(async () => {
|
||||
await loadSpotifyAuth();
|
||||
|
||||
@@ -30,8 +63,134 @@
|
||||
if ($spotifyAuth.clientSecret) {
|
||||
clientSecretInput = $spotifyAuth.clientSecret;
|
||||
}
|
||||
|
||||
if ($spotifyAuth.loggedIn) {
|
||||
await loadFavorites();
|
||||
} else {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadFavorites() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
|
||||
spotifyAPI.setTokens(
|
||||
$spotifyAuth.accessToken!,
|
||||
$spotifyAuth.refreshToken!,
|
||||
$spotifyAuth.expiresAt!
|
||||
);
|
||||
|
||||
// Check if we need to refresh cache
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const cacheAge = $spotifyAuth.cacheTimestamp ? now - $spotifyAuth.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) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
if (errorMessage === 'REFRESH_TOKEN_REVOKED') {
|
||||
await clearSpotifyAuth();
|
||||
error = 'Your Spotify session has expired. Please log in again.';
|
||||
} else {
|
||||
error = 'Error loading favorites: ' + errorMessage;
|
||||
}
|
||||
viewMode = 'info';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFavorites() {
|
||||
if (!$spotifyAuth.accessToken || syncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncing = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
|
||||
spotifyAPI.setTokens(
|
||||
$spotifyAuth.accessToken,
|
||||
$spotifyAuth.refreshToken!,
|
||||
$spotifyAuth.expiresAt!
|
||||
);
|
||||
|
||||
// Fetch all favorites from API
|
||||
console.log('[Spotify] Fetching favorites from API...');
|
||||
const [apiPlaylists, apiAlbums, apiArtists, apiTracks] = await Promise.all([
|
||||
spotifyAPI.getAllUserPlaylists(),
|
||||
spotifyAPI.getAllUserAlbums(),
|
||||
spotifyAPI.getAllUserArtists(),
|
||||
spotifyAPI.getAllUserTracks()
|
||||
]);
|
||||
|
||||
console.log('[Spotify] Fetched from API:', {
|
||||
playlists: apiPlaylists.length,
|
||||
albums: apiAlbums.length,
|
||||
artists: apiArtists.length,
|
||||
tracks: apiTracks.length
|
||||
});
|
||||
|
||||
// Update database cache
|
||||
console.log('[Spotify] 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('[Spotify] 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('[Spotify] Refresh complete!');
|
||||
} catch (e) {
|
||||
console.error('Error refreshing favorites:', e);
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
if (errorMessage === 'REFRESH_TOKEN_REVOKED') {
|
||||
await clearSpotifyAuth();
|
||||
error = 'Your Spotify session has expired. Please log in again.';
|
||||
} else {
|
||||
error = 'Error refreshing favorites: ' + errorMessage;
|
||||
}
|
||||
viewMode = 'info';
|
||||
} finally {
|
||||
syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthorize() {
|
||||
if (!clientIdInput || !clientSecretInput) {
|
||||
loginError = 'Please enter both Client ID and Client Secret';
|
||||
@@ -203,6 +362,9 @@
|
||||
// Fetch user info
|
||||
const user = await spotifyAPI.getCurrentUser();
|
||||
await saveUser(user);
|
||||
|
||||
// Load favorites after successful login
|
||||
await loadFavorites();
|
||||
|
||||
// Clean up
|
||||
localStorage.removeItem('spotify_code_verifier');
|
||||
@@ -220,6 +382,10 @@
|
||||
clientIdInput = '';
|
||||
clientSecretInput = '';
|
||||
loginSuccess = '';
|
||||
loginError = '';
|
||||
playlists = [];
|
||||
albums = [];
|
||||
artists = [];
|
||||
tracks = [];
|
||||
}
|
||||
|
||||
@@ -227,6 +393,9 @@
|
||||
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshingUser = true;
|
||||
userRefreshMessage = '';
|
||||
|
||||
try {
|
||||
// Set credentials in API client
|
||||
@@ -240,14 +409,33 @@
|
||||
// Fetch updated user info
|
||||
const user = await spotifyAPI.getCurrentUser();
|
||||
await saveUser(user);
|
||||
|
||||
|
||||
userRefreshMessage = 'User info refreshed successfully!';
|
||||
setTimeout(() => {
|
||||
setTimeout(() => {
|
||||
userRefreshMessage = '';
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
} catch (error) {
|
||||
userRefreshMessage = 'Error refreshing user info: ' + (error instanceof Error ? error.message : 'Unknown error');
|
||||
} finally {
|
||||
refreshingUser = false;
|
||||
setTimeout(() => {
|
||||
userRefreshMessage = '';
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(index: number) {
|
||||
selectedIndex = index;
|
||||
}
|
||||
|
||||
function handlePlaylistDoubleClick(playlistId: string) {
|
||||
goto(`/services/spotify/playlists/${playlistId}`);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const mins = Math.floor(ms / 60000);
|
||||
const secs = Math.floor((ms % 60000) / 1000);
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -334,51 +522,229 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
{:else if loading}
|
||||
<p style="padding: 8px;">Loading favorites...</p>
|
||||
{:else}
|
||||
<!-- Authenticated View -->
|
||||
<section class="authenticated-content">
|
||||
<div class="window" style="max-width: 600px; margin: 8px;">
|
||||
<div class="title-bar">
|
||||
<div class="title-bar-text">Connected to Spotify</div>
|
||||
{:else}
|
||||
<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 === 'info'}>
|
||||
<button onclick={() => viewMode = 'info'}>Info</button>
|
||||
</li>
|
||||
</menu>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="window tab-content" role="tabpanel">
|
||||
<div class="window-body">
|
||||
{#if loginError}
|
||||
<div class="error-message">
|
||||
<div class="window-body">
|
||||
{#if syncing}
|
||||
<div class="sync-status">
|
||||
<p>Refreshing favorites from Spotify...</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Virtual Spotify Likes Playlist (only show if we have favorite tracks) -->
|
||||
{#if tracks.length > 0}
|
||||
<tr
|
||||
class:highlighted={selectedIndex === -1}
|
||||
class="favorite-tracks-row"
|
||||
onclick={() => handleItemClick(-1)}
|
||||
ondblclick={() => handlePlaylistDoubleClick('spotify-likes')}
|
||||
>
|
||||
<td>Spotify Likes</td>
|
||||
<td>{tracks.length}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- User Playlists -->
|
||||
{#each playlists as playlist, i}
|
||||
<tr
|
||||
class:highlighted={selectedIndex === i}
|
||||
onclick={() => handleItemClick(i)}
|
||||
ondblclick={() => handlePlaylistDoubleClick(playlist.id)}
|
||||
>
|
||||
<td>{playlist.name}</td>
|
||||
<td>{playlist.track_count}</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.name}</td>
|
||||
<td>{track.artist_name}</td>
|
||||
<td>{track.album_name}</td>
|
||||
<td class="duration">{formatDuration(track.duration_ms)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if viewMode === 'artists'}
|
||||
<!-- Artists View -->
|
||||
<div class="tab-header">
|
||||
<h4>Followed 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>Followers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each artists as artist, i}
|
||||
<tr
|
||||
class:highlighted={selectedIndex === i}
|
||||
onclick={() => handleItemClick(i)}
|
||||
>
|
||||
<td>{artist.name}</td>
|
||||
<td>{artist.followers.toLocaleString()}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if viewMode === 'albums'}
|
||||
<!-- Albums View -->
|
||||
<div class="tab-header">
|
||||
<h4>Saved 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>Year</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each albums as album, i}
|
||||
<tr
|
||||
class:highlighted={selectedIndex === i}
|
||||
onclick={() => handleItemClick(i)}
|
||||
>
|
||||
<td>{album.name}</td>
|
||||
<td>{album.artist_name}</td>
|
||||
<td>{album.release_date ? new Date(album.release_date).getFullYear() : '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else if viewMode === 'info'}
|
||||
<!-- 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>{$spotifyAuth.user?.display_name || 'Unknown'}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">Email:</span>
|
||||
<span>{$spotifyAuth.user?.email || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">Country:</span>
|
||||
<span>{$spotifyAuth.user?.country || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">Subscription:</span>
|
||||
<span>{$spotifyAuth.user?.product ? $spotifyAuth.user.product.toUpperCase() : 'Unknown'}</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={refreshFavorites} disabled={syncing}>
|
||||
{syncing ? 'Refreshing...' : 'Refresh Cache'}
|
||||
</button>
|
||||
<button onclick={handleLogout}>Logout</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<fieldset>
|
||||
<legend>User Information</legend>
|
||||
<div class="field-row">
|
||||
<span class="field-label">Name:</span>
|
||||
<span>{$spotifyAuth.user?.display_name || 'Unknown'}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">Email:</span>
|
||||
<span>{$spotifyAuth.user?.email || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">Country:</span>
|
||||
<span>{$spotifyAuth.user?.country || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">Subscription:</span>
|
||||
<span>{$spotifyAuth.user?.product ? $spotifyAuth.user.product.toUpperCase() : 'Unknown'}</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style="margin-top: 16px;">
|
||||
<legend>Actions</legend>
|
||||
<div class="button-row">
|
||||
<button onclick={handleRefreshUser}>Refresh User Info</button>
|
||||
<button onclick={handleLogout}>Logout</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Note:</strong> Spotify integration is for library sync only. This app does not support playback or downloads from Spotify.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,11 +762,87 @@
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-section,
|
||||
|
||||
.login-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.favorites-content {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.sync-status {
|
||||
padding: 16px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.favorite-tracks-row {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.window-body {
|
||||
padding: 12px;
|
||||
@@ -499,16 +941,4 @@
|
||||
.instructions-content p {
|
||||
margin: 8px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
background-color: var(--button-shadow, #2a2a2a);
|
||||
border: 1px solid var(--button-highlight, #606060);
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user