mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
feat(wip): add tauri-plugin-oauth and enable Spotify oauth
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -4,6 +4,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "shark",
|
"name": "shark",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fabianlars/tauri-plugin-oauth": "2",
|
||||||
"@noble/ciphers": "^2.0.1",
|
"@noble/ciphers": "^2.0.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
@@ -84,6 +85,8 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
|
||||||
|
|
||||||
|
"@fabianlars/tauri-plugin-oauth": ["@fabianlars/tauri-plugin-oauth@2.0.0", "", { "dependencies": { "@tauri-apps/api": "^2.0.3" } }, "sha512-I1s08ZXrsFuYfNWusAcpLyiCfr5TCvaBrRuKfTG+XQrcaqnAcwjdWH0U5J9QWuMDLwCUMnVxdobtMJzPR8raxQ=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fabianlars/tauri-plugin-oauth": "2",
|
||||||
"@noble/ciphers": "^2.0.1",
|
"@noble/ciphers": "^2.0.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "~2",
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
|
|||||||
16
src-tauri/Cargo.lock
generated
16
src-tauri/Cargo.lock
generated
@@ -20,6 +20,7 @@ dependencies = [
|
|||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-fs",
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-http",
|
"tauri-plugin-http",
|
||||||
|
"tauri-plugin-oauth",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-os",
|
"tauri-plugin-os",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
@@ -4931,6 +4932,21 @@ dependencies = [
|
|||||||
"urlpattern",
|
"urlpattern",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-oauth"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eda564acdb23185caf700f89dd6e5d4540225d6a991516b2cad0cbcf27e4dcd3"
|
||||||
|
dependencies = [
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
|
|||||||
@@ -39,4 +39,5 @@ futures-util = "0.3.31"
|
|||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
unicode-normalization = "0.1.24"
|
unicode-normalization = "0.1.24"
|
||||||
|
tauri-plugin-oauth = "2.0.0"
|
||||||
|
|
||||||
|
|||||||
@@ -72,12 +72,28 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "https://lrclib.net/**"
|
"url": "https://lrclib.net/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://accounts.spotify.com/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://api.spotify.com/**"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"sql:default",
|
"sql:default",
|
||||||
"sql:allow-execute",
|
"sql:allow-execute",
|
||||||
"process:default",
|
"process:default",
|
||||||
"os:default"
|
"os:default",
|
||||||
|
"oauth:allow-start",
|
||||||
|
"oauth:allow-cancel",
|
||||||
|
{
|
||||||
|
"identifier": "opener:allow-open-url",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"url": "https://accounts.spotify.com/*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -300,6 +300,7 @@ pub fn run() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_oauth::init())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(
|
.plugin(
|
||||||
|
|||||||
260
src/lib/services/spotify.ts
Normal file
260
src/lib/services/spotify.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
import type { SpotifyUser } from '$lib/stores/spotify';
|
||||||
|
import { isTokenExpired } from '$lib/stores/spotify';
|
||||||
|
|
||||||
|
const SPOTIFY_AUTH_URL = 'https://accounts.spotify.com/authorize';
|
||||||
|
const SPOTIFY_TOKEN_URL = 'https://accounts.spotify.com/api/token';
|
||||||
|
const SPOTIFY_API_BASE = 'https://api.spotify.com/v1';
|
||||||
|
|
||||||
|
// Required scopes for the app
|
||||||
|
const REQUIRED_SCOPES = [
|
||||||
|
'user-read-private',
|
||||||
|
'user-read-email',
|
||||||
|
'user-library-read',
|
||||||
|
'playlist-read-private',
|
||||||
|
'playlist-read-collaborative',
|
||||||
|
'user-follow-read'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotify API client with OAuth 2.0 PKCE flow
|
||||||
|
*/
|
||||||
|
export class SpotifyAPI {
|
||||||
|
private clientId: string | null = null;
|
||||||
|
private clientSecret: string | null = null;
|
||||||
|
private accessToken: string | null = null;
|
||||||
|
private refreshToken: string | null = null;
|
||||||
|
private expiresAt: number | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set client credentials (developer app credentials)
|
||||||
|
*/
|
||||||
|
setClientCredentials(clientId: string, clientSecret: string): void {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.clientSecret = clientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set OAuth tokens
|
||||||
|
*/
|
||||||
|
setTokens(accessToken: string, refreshToken: string, expiresAt: number): void {
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
this.refreshToken = refreshToken;
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random code verifier for PKCE
|
||||||
|
*/
|
||||||
|
generateCodeVerifier(): string {
|
||||||
|
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const values = crypto.getRandomValues(new Uint8Array(64));
|
||||||
|
return Array.from(values)
|
||||||
|
.map(x => possible[x % possible.length])
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate code challenge from verifier using SHA256
|
||||||
|
*/
|
||||||
|
async generateCodeChallenge(verifier: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(verifier);
|
||||||
|
const hashed = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
|
||||||
|
// Base64 URL encode
|
||||||
|
const base64 = btoa(String.fromCharCode(...new Uint8Array(hashed)))
|
||||||
|
.replace(/=/g, '')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_');
|
||||||
|
|
||||||
|
return base64;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the authorization URL for user to authenticate
|
||||||
|
* Returns the URL and the code verifier (must be stored for later)
|
||||||
|
*/
|
||||||
|
async getAuthorizationUrl(clientId: string, redirectUri: string): Promise<{ url: string; codeVerifier: string }> {
|
||||||
|
const codeVerifier = this.generateCodeVerifier();
|
||||||
|
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
scope: REQUIRED_SCOPES.join(' ')
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `${SPOTIFY_AUTH_URL}?${params.toString()}`;
|
||||||
|
|
||||||
|
return { url, codeVerifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange authorization code for access token
|
||||||
|
*/
|
||||||
|
async exchangeCodeForToken(
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
clientId: string,
|
||||||
|
redirectUri: string
|
||||||
|
): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code: code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: codeVerifier
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(SPOTIFY_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Token exchange error:', errorText);
|
||||||
|
throw new Error(`Token exchange failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Store tokens
|
||||||
|
this.accessToken = data.access_token;
|
||||||
|
this.refreshToken = data.refresh_token;
|
||||||
|
this.expiresAt = Date.now() + (data.expires_in * 1000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: data.access_token,
|
||||||
|
refresh_token: data.refresh_token,
|
||||||
|
expires_in: data.expires_in
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the access token using the refresh token
|
||||||
|
*/
|
||||||
|
async refreshAccessToken(): Promise<{ access_token: string; expires_in: number }> {
|
||||||
|
if (!this.refreshToken || !this.clientId || !this.clientSecret) {
|
||||||
|
throw new Error('Missing refresh token or client credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = btoa(`${this.clientId}:${this.clientSecret}`);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: this.refreshToken
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(SPOTIFY_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Authorization': `Basic ${credentials}`
|
||||||
|
},
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Token refresh error:', errorText);
|
||||||
|
throw new Error(`Token refresh failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Update tokens
|
||||||
|
this.accessToken = data.access_token;
|
||||||
|
this.expiresAt = Date.now() + (data.expires_in * 1000);
|
||||||
|
|
||||||
|
// Note: Spotify may or may not return a new refresh token
|
||||||
|
if (data.refresh_token) {
|
||||||
|
this.refreshToken = data.refresh_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: data.access_token,
|
||||||
|
expires_in: data.expires_in
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated API call to Spotify
|
||||||
|
* Automatically refreshes token if expired
|
||||||
|
*/
|
||||||
|
private async apiCall<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
// Check if token needs refresh
|
||||||
|
if (isTokenExpired(this.expiresAt)) {
|
||||||
|
console.log('[Spotify] Token expired, refreshing...');
|
||||||
|
await this.refreshAccessToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.accessToken) {
|
||||||
|
throw new Error('No access token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${SPOTIFY_API_BASE}${endpoint}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
'Authorization': `Bearer ${this.accessToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(`Spotify API error for ${endpoint}:`, errorText);
|
||||||
|
throw new Error(`API call failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user's profile
|
||||||
|
*/
|
||||||
|
async getCurrentUser(): Promise<SpotifyUser> {
|
||||||
|
return this.apiCall<SpotifyUser>('/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's playlists
|
||||||
|
*/
|
||||||
|
async getUserPlaylists(limit: number = 50, offset: number = 0): Promise<any> {
|
||||||
|
return this.apiCall(`/me/playlists?limit=${limit}&offset=${offset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's saved tracks
|
||||||
|
*/
|
||||||
|
async getUserTracks(limit: number = 50, offset: number = 0): Promise<any> {
|
||||||
|
return this.apiCall(`/me/tracks?limit=${limit}&offset=${offset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's saved albums
|
||||||
|
*/
|
||||||
|
async getUserAlbums(limit: number = 50, offset: number = 0): Promise<any> {
|
||||||
|
return this.apiCall(`/me/albums?limit=${limit}&offset=${offset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's followed artists
|
||||||
|
*/
|
||||||
|
async getUserArtists(limit: number = 50, after?: string): Promise<any> {
|
||||||
|
const afterParam = after ? `&after=${after}` : '';
|
||||||
|
return this.apiCall(`/me/following?type=artist&limit=${limit}${afterParam}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const spotifyAPI = new SpotifyAPI();
|
||||||
133
src/lib/stores/spotify.ts
Normal file
133
src/lib/stores/spotify.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { LazyStore } from '@tauri-apps/plugin-store';
|
||||||
|
import { writable, type Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
// Spotify User interface
|
||||||
|
export interface SpotifyUser {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
email?: string;
|
||||||
|
country?: string;
|
||||||
|
product?: string; // premium, free, etc.
|
||||||
|
images?: Array<{ url: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spotify auth state
|
||||||
|
export interface SpotifyAuthState {
|
||||||
|
// Developer credentials
|
||||||
|
clientId: string | null;
|
||||||
|
clientSecret: string | null;
|
||||||
|
// OAuth tokens
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
expiresAt: number | null; // Unix timestamp in milliseconds
|
||||||
|
// User data
|
||||||
|
user: SpotifyUser | null;
|
||||||
|
loggedIn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the store with spotify.json
|
||||||
|
const store = new LazyStore('spotify.json');
|
||||||
|
|
||||||
|
// Default state
|
||||||
|
const defaultState: SpotifyAuthState = {
|
||||||
|
clientId: null,
|
||||||
|
clientSecret: null,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
user: null,
|
||||||
|
loggedIn: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a writable store for reactive UI updates
|
||||||
|
export const spotifyAuth: Writable<SpotifyAuthState> = writable(defaultState);
|
||||||
|
|
||||||
|
// Load Spotify auth state from store
|
||||||
|
export async function loadSpotifyAuth(): Promise<void> {
|
||||||
|
const clientId = await store.get<string>('clientId');
|
||||||
|
const clientSecret = await store.get<string>('clientSecret');
|
||||||
|
const accessToken = await store.get<string>('accessToken');
|
||||||
|
const refreshToken = await store.get<string>('refreshToken');
|
||||||
|
const expiresAt = await store.get<number>('expiresAt');
|
||||||
|
const user = await store.get<SpotifyUser>('user');
|
||||||
|
|
||||||
|
spotifyAuth.set({
|
||||||
|
clientId: clientId ?? null,
|
||||||
|
clientSecret: clientSecret ?? null,
|
||||||
|
accessToken: accessToken ?? null,
|
||||||
|
refreshToken: refreshToken ?? null,
|
||||||
|
expiresAt: expiresAt ?? null,
|
||||||
|
user: user ?? null,
|
||||||
|
loggedIn: !!(accessToken && user)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save client credentials (developer app credentials)
|
||||||
|
export async function saveClientCredentials(clientId: string, clientSecret: string): Promise<void> {
|
||||||
|
await store.set('clientId', clientId);
|
||||||
|
await store.set('clientSecret', clientSecret);
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
spotifyAuth.update(s => ({
|
||||||
|
...s,
|
||||||
|
clientId,
|
||||||
|
clientSecret
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save OAuth tokens
|
||||||
|
export async function saveTokens(accessToken: string, refreshToken: string, expiresIn: number): Promise<void> {
|
||||||
|
const expiresAt = Date.now() + (expiresIn * 1000);
|
||||||
|
|
||||||
|
await store.set('accessToken', accessToken);
|
||||||
|
await store.set('refreshToken', refreshToken);
|
||||||
|
await store.set('expiresAt', expiresAt);
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
spotifyAuth.update(s => ({
|
||||||
|
...s,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiresAt
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save user data
|
||||||
|
export async function saveUser(user: SpotifyUser): Promise<void> {
|
||||||
|
await store.set('user', user);
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
spotifyAuth.update(s => ({
|
||||||
|
...s,
|
||||||
|
user,
|
||||||
|
loggedIn: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear auth (logout)
|
||||||
|
export async function clearSpotifyAuth(): Promise<void> {
|
||||||
|
await store.delete('accessToken');
|
||||||
|
await store.delete('refreshToken');
|
||||||
|
await store.delete('expiresAt');
|
||||||
|
await store.delete('user');
|
||||||
|
await store.save();
|
||||||
|
|
||||||
|
spotifyAuth.update(s => ({
|
||||||
|
...s,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
user: null,
|
||||||
|
loggedIn: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired or about to expire (within 5 minutes)
|
||||||
|
export function isTokenExpired(expiresAt: number | null): boolean {
|
||||||
|
if (!expiresAt) return true;
|
||||||
|
const bufferTime = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||||
|
return Date.now() >= (expiresAt - bufferTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on module load
|
||||||
|
loadSpotifyAuth();
|
||||||
@@ -132,10 +132,10 @@
|
|||||||
Services
|
Services
|
||||||
</summary>
|
</summary>
|
||||||
<div class="nav-submenu">
|
<div class="nav-submenu">
|
||||||
<!-- <a href="/services/spotify" class="nav-item nav-subitem">
|
<a href="/services/spotify" class="nav-item nav-subitem">
|
||||||
<img src="/icons/spotify.png" alt="" class="nav-icon" />
|
<img src="/icons/spotify.png" alt="" class="nav-icon" />
|
||||||
Spotify
|
Spotify
|
||||||
</a> -->
|
</a>
|
||||||
<a href="/services/deezer" class="nav-item nav-subitem">
|
<a href="/services/deezer" class="nav-item nav-subitem">
|
||||||
<img src="/icons/deezer.png" alt="" class="nav-icon" />
|
<img src="/icons/deezer.png" alt="" class="nav-icon" />
|
||||||
Deezer
|
Deezer
|
||||||
|
|||||||
436
src/routes/services/spotify/+page.svelte
Normal file
436
src/routes/services/spotify/+page.svelte
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { spotifyAuth, loadSpotifyAuth, saveClientCredentials, saveTokens, saveUser, clearSpotifyAuth } 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';
|
||||||
|
|
||||||
|
// Fixed port for OAuth callback - user must register this in Spotify Dashboard
|
||||||
|
const OAUTH_PORT = 8228;
|
||||||
|
const REDIRECT_URI = `http://127.0.0.1:${OAUTH_PORT}/callback`;
|
||||||
|
|
||||||
|
// Login form state
|
||||||
|
let clientIdInput = $state('');
|
||||||
|
let clientSecretInput = $state('');
|
||||||
|
let isAuthenticating = $state(false);
|
||||||
|
let loginError = $state('');
|
||||||
|
let loginSuccess = $state('');
|
||||||
|
|
||||||
|
// OAuth state
|
||||||
|
let isWaitingForCallback = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadSpotifyAuth();
|
||||||
|
|
||||||
|
// Check if we have client credentials stored
|
||||||
|
if ($spotifyAuth.clientId) {
|
||||||
|
clientIdInput = $spotifyAuth.clientId;
|
||||||
|
}
|
||||||
|
if ($spotifyAuth.clientSecret) {
|
||||||
|
clientSecretInput = $spotifyAuth.clientSecret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleAuthorize() {
|
||||||
|
if (!clientIdInput || !clientSecretInput) {
|
||||||
|
loginError = 'Please enter both Client ID and Client Secret';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientIdInput.trim().length === 0 || clientSecretInput.trim().length === 0) {
|
||||||
|
loginError = 'Client ID and Client Secret cannot be empty';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthenticating = true;
|
||||||
|
loginError = '';
|
||||||
|
loginSuccess = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save credentials
|
||||||
|
await saveClientCredentials(clientIdInput.trim(), clientSecretInput.trim());
|
||||||
|
|
||||||
|
// Set up OAuth callback listener first
|
||||||
|
onUrl((callbackUrl) => {
|
||||||
|
handleOAuthCallback(callbackUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start OAuth server on fixed port
|
||||||
|
const port = await start({ ports: [OAUTH_PORT] });
|
||||||
|
console.log(`[Spotify] OAuth server started on port ${port}`);
|
||||||
|
|
||||||
|
// Generate authorization URL
|
||||||
|
const { url, codeVerifier } = await spotifyAPI.getAuthorizationUrl(
|
||||||
|
clientIdInput.trim(),
|
||||||
|
REDIRECT_URI
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store code verifier for callback
|
||||||
|
localStorage.setItem('spotify_code_verifier', codeVerifier);
|
||||||
|
|
||||||
|
isWaitingForCallback = true;
|
||||||
|
|
||||||
|
// Open Spotify authorization in default browser
|
||||||
|
await openUrl(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Spotify] Authorization error:', error);
|
||||||
|
loginError = `Authorization error: ${error instanceof Error ? error.message : JSON.stringify(error)}`;
|
||||||
|
isAuthenticating = false;
|
||||||
|
isWaitingForCallback = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOAuthCallback(callbackUrl: string) {
|
||||||
|
try {
|
||||||
|
// Parse the callback URL
|
||||||
|
const url = new URL(callbackUrl);
|
||||||
|
const code = url.searchParams.get('code');
|
||||||
|
const error = url.searchParams.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Authorization failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('No authorization code received');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve code verifier from localStorage
|
||||||
|
const codeVerifier = localStorage.getItem('spotify_code_verifier');
|
||||||
|
|
||||||
|
if (!codeVerifier) {
|
||||||
|
throw new Error('OAuth state lost. Please try logging in again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
const tokenData = await spotifyAPI.exchangeCodeForToken(
|
||||||
|
code,
|
||||||
|
codeVerifier,
|
||||||
|
$spotifyAuth.clientId!,
|
||||||
|
REDIRECT_URI
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save tokens
|
||||||
|
await saveTokens(tokenData.access_token, tokenData.refresh_token, tokenData.expires_in);
|
||||||
|
|
||||||
|
// Set tokens in API client
|
||||||
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
|
||||||
|
spotifyAPI.setTokens(
|
||||||
|
tokenData.access_token,
|
||||||
|
tokenData.refresh_token,
|
||||||
|
Date.now() + (tokenData.expires_in * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch user info
|
||||||
|
const user = await spotifyAPI.getCurrentUser();
|
||||||
|
await saveUser(user);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
localStorage.removeItem('spotify_code_verifier');
|
||||||
|
} catch (error) {
|
||||||
|
loginError = `Authentication error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
localStorage.removeItem('spotify_code_verifier');
|
||||||
|
} finally {
|
||||||
|
isAuthenticating = false;
|
||||||
|
isWaitingForCallback = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await clearSpotifyAuth();
|
||||||
|
clientIdInput = '';
|
||||||
|
clientSecretInput = '';
|
||||||
|
loginSuccess = '';
|
||||||
|
loginError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshUser() {
|
||||||
|
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Set credentials in API client
|
||||||
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
|
||||||
|
spotifyAPI.setTokens(
|
||||||
|
$spotifyAuth.accessToken,
|
||||||
|
$spotifyAuth.refreshToken!,
|
||||||
|
$spotifyAuth.expiresAt!
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch updated user info
|
||||||
|
const user = await spotifyAPI.getCurrentUser();
|
||||||
|
await saveUser(user);
|
||||||
|
|
||||||
|
loginSuccess = 'User info refreshed successfully!';
|
||||||
|
setTimeout(() => {
|
||||||
|
loginSuccess = '';
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
loginError = 'Error refreshing user info: ' + (error instanceof Error ? error.message : 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="spotify-wrapper">
|
||||||
|
<h2 style="padding: 8px">Spotify</h2>
|
||||||
|
|
||||||
|
{#if !$spotifyAuth.loggedIn}
|
||||||
|
<!-- Login Form -->
|
||||||
|
<section class="window login-section" style="max-width: 600px; margin: 8px;">
|
||||||
|
<div class="title-bar">
|
||||||
|
<div class="title-bar-text">Login to Spotify</div>
|
||||||
|
</div>
|
||||||
|
<div class="window-body">
|
||||||
|
<p>Enter your Spotify Developer credentials and authorize access:</p>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<label for="client-id-input">Client ID</label>
|
||||||
|
<input
|
||||||
|
id="client-id-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={clientIdInput}
|
||||||
|
placeholder="Your Spotify App Client ID"
|
||||||
|
disabled={isAuthenticating || isWaitingForCallback}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row-stacked">
|
||||||
|
<label for="client-secret-input">Client Secret</label>
|
||||||
|
<input
|
||||||
|
id="client-secret-input"
|
||||||
|
type="password"
|
||||||
|
bind:value={clientSecretInput}
|
||||||
|
placeholder="Your Spotify App Client Secret"
|
||||||
|
disabled={isAuthenticating || isWaitingForCallback}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loginError}
|
||||||
|
<div class="error-message">
|
||||||
|
⚠ {loginError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isWaitingForCallback}
|
||||||
|
<div class="info-message">
|
||||||
|
Waiting for authorization in your browser... Please complete the login process.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button onclick={handleAuthorize} disabled={isAuthenticating || isWaitingForCallback}>
|
||||||
|
{isAuthenticating ? 'Authorizing...' : 'Authorize with Spotify'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 8px; font-size: 11px; opacity: 0.7;">
|
||||||
|
This will open Spotify's login page in your default browser.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<details class="instructions">
|
||||||
|
<summary>How to get your Spotify Developer credentials</summary>
|
||||||
|
<div class="instructions-content">
|
||||||
|
<ol>
|
||||||
|
<li>Go to <strong>developer.spotify.com/dashboard</strong></li>
|
||||||
|
<li>Log in with your Spotify account</li>
|
||||||
|
<li>Click <strong>"Create app"</strong></li>
|
||||||
|
<li>Fill in the app details:
|
||||||
|
<ul>
|
||||||
|
<li>App name: (any name you want, e.g., "Shark Music Player")</li>
|
||||||
|
<li>App description: (any description)</li>
|
||||||
|
<li>Redirect URI: <code>http://127.0.0.1:8228/callback</code></li>
|
||||||
|
<li>Check the Web API box</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Click <strong>"Save"</strong></li>
|
||||||
|
<li>Click <strong>"Settings"</strong> on your new app</li>
|
||||||
|
<li>Copy the <strong>Client ID</strong> (visible by default)</li>
|
||||||
|
<li>Click <strong>"View client secret"</strong> and copy the <strong>Client Secret</strong></li>
|
||||||
|
<li>Paste both values into the fields above</li>
|
||||||
|
</ol>
|
||||||
|
<p><strong>Note:</strong> The Client ID and Client Secret are used to authenticate your app with Spotify. Keep the Client Secret private and never share it publicly.</p>
|
||||||
|
<p><strong>Important:</strong> The Redirect URI must be exactly <code>http://127.0.0.1:8228/callback</code>. Port 8228 must be available when authorizing. If you get a port error, close any application using port 8228.</p>
|
||||||
|
<p><strong>Scopes used:</strong> This app requests access to your profile, email, saved library (tracks, albums), playlists (including private and collaborative), and followed artists.</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{: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>
|
||||||
|
</div>
|
||||||
|
<div class="window-body">
|
||||||
|
{#if loginError}
|
||||||
|
<div class="error-message">
|
||||||
|
{loginError}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.spotify-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-section,
|
||||||
|
.authenticated-content {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row-stacked {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: #ffcccc;
|
||||||
|
color: #cc0000;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 1px solid #cc0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-message {
|
||||||
|
background-color: #cce5ff;
|
||||||
|
color: #004085;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 1px solid #004085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--button-shadow, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content ol {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content ul {
|
||||||
|
margin: 4px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content li {
|
||||||
|
margin: 6px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions-content code {
|
||||||
|
background-color: var(--button-highlight, #505050);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user