Add files via upload
This commit is contained in:
parent
d612a65f81
commit
5748d2d13c
31 changed files with 3197 additions and 2492 deletions
373
services/api.ts
373
services/api.ts
|
|
@ -1,178 +1,195 @@
|
|||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const BASE = 'https://music.nodeshift.space';
|
||||
const CLOUD = 'https://cloud.nodeshift.space';
|
||||
const CLIENT_ID = 'nsc_A5zi4cZ103aYJGcNTG1bERXV1YCAQW_U';
|
||||
const REDIRECT = 'burmalda://auth/callback';
|
||||
|
||||
// ── Token storage ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveTokens(access: string, refresh: string) {
|
||||
await SecureStore.setItemAsync('access_token', access);
|
||||
await SecureStore.setItemAsync('refresh_token', refresh);
|
||||
}
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync('access_token');
|
||||
}
|
||||
export async function clearTokens() {
|
||||
await SecureStore.deleteItemAsync('access_token');
|
||||
await SecureStore.deleteItemAsync('refresh_token');
|
||||
}
|
||||
|
||||
// ── Fetch helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
|
||||
const token = await getAccessToken();
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(opts.headers as Record<string, string> || {}),
|
||||
};
|
||||
return fetch(`${BASE}${path}`, { ...opts, headers });
|
||||
}
|
||||
|
||||
// ── OAuth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildOAuthUrl(state: string): string {
|
||||
const p = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT, state, scope: 'profile email' });
|
||||
return `${CLOUD}/api/cloud/oauth/authorize?${p}`;
|
||||
}
|
||||
|
||||
export async function exchangeCode(code: string): Promise<{ access_token: string; refresh_token: string; user: User }> {
|
||||
const res = await fetch(`${BASE}/api/auth/cloud-login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, redirect_uri: REDIRECT }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Auth failed');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Track {
|
||||
id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
duration: number;
|
||||
cover_url: string | null;
|
||||
cdn_url: string;
|
||||
cdn_url2: string;
|
||||
filename: string;
|
||||
album?: string;
|
||||
play_count?: number;
|
||||
source?: string;
|
||||
cdn_synced?: boolean;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
cover_url: string | null;
|
||||
track_count: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string | null;
|
||||
avatar_url: string | null;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
export interface Lyrics {
|
||||
synced: string | null;
|
||||
plain: string | null;
|
||||
}
|
||||
|
||||
// ── API calls ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTracks(): Promise<Track[]> {
|
||||
const r = await apiFetch('/api/music/tracks?limit=200');
|
||||
if (!r.ok) return [];
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function getAlbums(): Promise<Album[]> {
|
||||
const r = await apiFetch('/api/music/albums');
|
||||
if (!r.ok) return [];
|
||||
const data: Array<{ album: string; artist: string; count: number }> = await r.json();
|
||||
return data.map((a, i) => ({
|
||||
id: i,
|
||||
title: a.album,
|
||||
artist: a.artist,
|
||||
cover_url: null,
|
||||
track_count: a.count,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<User | null> {
|
||||
// Validate token
|
||||
const r = await apiFetch('/api/auth/me');
|
||||
if (!r.ok) return null;
|
||||
const auth = await r.json();
|
||||
|
||||
// Get music profile for username / avatar
|
||||
const pr = await apiFetch('/api/music/profile/me');
|
||||
const profile = pr.ok ? await pr.json() : {};
|
||||
|
||||
return {
|
||||
id: auth.id,
|
||||
email: auth.email,
|
||||
username: profile.username || null,
|
||||
avatar_url: profile.avatar_url || null,
|
||||
plan: 'free', // merged with cached plan in useBootAuth
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchTracks(q: string): Promise<Track[]> {
|
||||
const r = await apiFetch('/api/music/tracks?limit=200');
|
||||
if (!r.ok) return [];
|
||||
const all: Track[] = await r.json();
|
||||
if (!q.trim()) return all;
|
||||
const lq = q.toLowerCase();
|
||||
return all.filter(t =>
|
||||
t.title.toLowerCase().includes(lq) ||
|
||||
t.artist.toLowerCase().includes(lq) ||
|
||||
(t.album && t.album.toLowerCase().includes(lq))
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchLyrics(id: number): Promise<Lyrics | null> {
|
||||
const r = await apiFetch(`/api/music/lyrics/${id}`);
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// cover_url from the server is one of:
|
||||
// - "https://cdn.nodeshift.space/music/xxx.jpg" (full URL)
|
||||
// - "/api/music/cover/xxx.jpg" (relative path)
|
||||
// - "xxx.jpg" (bare filename, legacy)
|
||||
export function coverUrl(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith('http')) return raw;
|
||||
if (raw.startsWith('/')) return `${BASE}${raw}`;
|
||||
return `${BASE}/api/music/cover/${raw}`;
|
||||
}
|
||||
|
||||
// ── LRC parser ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LrcLine {
|
||||
time: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function parseLrc(lrc: string): LrcLine[] {
|
||||
const lines: LrcLine[] = [];
|
||||
for (const raw of lrc.split('\n')) {
|
||||
const m = raw.match(/^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$/);
|
||||
if (!m) continue;
|
||||
const time = parseInt(m[1]) * 60 + parseFloat(`${m[2]}.${m[3]}`);
|
||||
if (m[4].trim()) lines.push({ time, text: m[4].trim() });
|
||||
}
|
||||
return lines.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const BASE = 'https://music.nodeshift.space';
|
||||
const CLOUD = 'https://cloud.nodeshift.space';
|
||||
const CLIENT_ID = 'nsc_A5zi4cZ103aYJGcNTG1bERXV1YCAQW_U';
|
||||
const REDIRECT = 'burmalda://auth/callback';
|
||||
|
||||
// ── Token storage ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveTokens(access: string, refresh: string) {
|
||||
await SecureStore.setItemAsync('access_token', access);
|
||||
await SecureStore.setItemAsync('refresh_token', refresh);
|
||||
}
|
||||
export async function getAccessToken(): Promise<string | null> {
|
||||
return SecureStore.getItemAsync('access_token');
|
||||
}
|
||||
export async function clearTokens() {
|
||||
await SecureStore.deleteItemAsync('access_token');
|
||||
await SecureStore.deleteItemAsync('refresh_token');
|
||||
}
|
||||
|
||||
// ── Fetch helper ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function apiFetch(path: string, opts: RequestInit = {}): Promise<Response> {
|
||||
const token = await getAccessToken();
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(opts.headers as Record<string, string> || {}),
|
||||
};
|
||||
return fetch(`${BASE}${path}`, { ...opts, headers });
|
||||
}
|
||||
|
||||
// ── OAuth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildOAuthUrl(state: string): string {
|
||||
const p = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT, state, scope: 'profile email' });
|
||||
return `${CLOUD}/api/cloud/oauth/authorize?${p}`;
|
||||
}
|
||||
|
||||
export async function exchangeCode(code: string): Promise<{ access_token: string; refresh_token: string; user: User }> {
|
||||
const res = await fetch(`${BASE}/api/auth/cloud-login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, redirect_uri: REDIRECT }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Auth failed');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Track {
|
||||
id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
duration: number;
|
||||
cover_url: string | null;
|
||||
cdn_url: string;
|
||||
cdn_url2: string;
|
||||
filename: string;
|
||||
album?: string;
|
||||
play_count?: number;
|
||||
source?: string;
|
||||
cdn_synced?: boolean;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
cover_url: string | null;
|
||||
track_count: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string | null;
|
||||
avatar_url: string | null;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
export interface Lyrics {
|
||||
synced: string | null;
|
||||
plain: string | null;
|
||||
}
|
||||
|
||||
// ── API calls ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTracks(): Promise<Track[]> {
|
||||
const r = await apiFetch('/api/music/tracks?limit=200');
|
||||
if (!r.ok) return [];
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function getAlbums(): Promise<Album[]> {
|
||||
const r = await apiFetch('/api/music/albums');
|
||||
if (!r.ok) return [];
|
||||
const data: Array<{ album: string; artist: string; count: number }> = await r.json();
|
||||
return data.map((a, i) => ({
|
||||
id: i,
|
||||
title: a.album,
|
||||
artist: a.artist,
|
||||
cover_url: null,
|
||||
track_count: a.count,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<User | null> {
|
||||
// Validate token
|
||||
const r = await apiFetch('/api/auth/me');
|
||||
if (!r.ok) return null;
|
||||
const auth = await r.json();
|
||||
|
||||
// Get music profile for username / avatar
|
||||
const pr = await apiFetch('/api/music/profile/me');
|
||||
const profile = pr.ok ? await pr.json() : {};
|
||||
|
||||
return {
|
||||
id: auth.id,
|
||||
email: auth.email,
|
||||
username: profile.username || null,
|
||||
avatar_url: profile.avatar_url || null,
|
||||
plan: 'free', // merged with cached plan in useBootAuth
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchTracks(q: string): Promise<Track[]> {
|
||||
const r = await apiFetch('/api/music/tracks?limit=200');
|
||||
if (!r.ok) return [];
|
||||
const all: Track[] = await r.json();
|
||||
if (!q.trim()) return all;
|
||||
const lq = q.toLowerCase();
|
||||
return all.filter(t =>
|
||||
t.title.toLowerCase().includes(lq) ||
|
||||
t.artist.toLowerCase().includes(lq) ||
|
||||
(t.album && t.album.toLowerCase().includes(lq))
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchLyrics(id: number): Promise<Lyrics | null> {
|
||||
const r = await apiFetch(`/api/music/lyrics/${id}`);
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
}
|
||||
|
||||
// cover_url from the server is one of:
|
||||
// - "https://cdn.nodeshift.space/music/xxx.jpg" (full URL)
|
||||
// - "/api/music/cover/xxx.jpg" (relative path)
|
||||
// - "xxx.jpg" (bare filename, legacy)
|
||||
export function coverUrl(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith('http')) return raw;
|
||||
if (raw.startsWith('/')) return `${BASE}${raw}`;
|
||||
return `${BASE}/api/music/cover/${raw}`;
|
||||
}
|
||||
|
||||
// ── Offline/Local playback ────────────────────────────────────────────────────
|
||||
|
||||
export async function getTrackUrl(track: Track): Promise<string> {
|
||||
try {
|
||||
const { isTrackDownloaded, getTrackFilePath } = await import('./offline');
|
||||
const isDownloaded = await isTrackDownloaded(track.id);
|
||||
if (isDownloaded) {
|
||||
return getTrackFilePath(track.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to check downloaded track:', err);
|
||||
}
|
||||
|
||||
// Fallback to CDN URL
|
||||
return track.cdn_url || track.cdn_url2 || '';
|
||||
}
|
||||
|
||||
// ── LRC parser ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LrcLine {
|
||||
time: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function parseLrc(lrc: string): LrcLine[] {
|
||||
const lines: LrcLine[] = [];
|
||||
for (const raw of lrc.split('\n')) {
|
||||
const m = raw.match(/^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$/);
|
||||
if (!m) continue;
|
||||
const time = parseInt(m[1]) * 60 + parseFloat(`${m[2]}.${m[3]}`);
|
||||
if (m[4].trim()) lines.push({ time, text: m[4].trim() });
|
||||
}
|
||||
return lines.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
|
|
|||
193
services/offline.ts
Normal file
193
services/offline.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import * as FileSystem from 'expo-file-system';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Track, Album } from './api';
|
||||
|
||||
const OFFLINE_TRACKS_KEY = 'offline_tracks_cache';
|
||||
const OFFLINE_ALBUMS_KEY = 'offline_albums_cache';
|
||||
const DOWNLOADED_TRACKS_KEY = 'downloaded_tracks';
|
||||
|
||||
const DOCUMENTS_DIR = `${FileSystem.documentDirectory}music/`;
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function initOfflineStorage() {
|
||||
try {
|
||||
const dir = await FileSystem.getInfoAsync(DOCUMENTS_DIR);
|
||||
if (!dir.exists) {
|
||||
await FileSystem.makeDirectoryAsync(DOCUMENTS_DIR, { intermediates: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to create offline storage dir:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cache management ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function cacheTracksAndAlbums(tracks: Track[], albums: Album[]) {
|
||||
try {
|
||||
await Promise.all([
|
||||
AsyncStorage.setItem(OFFLINE_TRACKS_KEY, JSON.stringify(tracks)),
|
||||
AsyncStorage.setItem(OFFLINE_ALBUMS_KEY, JSON.stringify(albums)),
|
||||
]);
|
||||
} catch (err) {
|
||||
console.warn('Failed to cache tracks and albums:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedTracks(): Promise<Track[] | null> {
|
||||
try {
|
||||
const cached = await AsyncStorage.getItem(OFFLINE_TRACKS_KEY);
|
||||
return cached ? JSON.parse(cached) : null;
|
||||
} catch (err) {
|
||||
console.warn('Failed to get cached tracks:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCachedAlbums(): Promise<Album[] | null> {
|
||||
try {
|
||||
const cached = await AsyncStorage.getItem(OFFLINE_ALBUMS_KEY);
|
||||
return cached ? JSON.parse(cached) : null;
|
||||
} catch (err) {
|
||||
console.warn('Failed to get cached albums:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Downloaded tracks management ───────────────────────────────────────────────
|
||||
|
||||
export async function getDownloadedTracks(): Promise<number[]> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(DOWNLOADED_TRACKS_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function addDownloadedTrack(trackId: number) {
|
||||
try {
|
||||
const ids = await getDownloadedTracks();
|
||||
if (!ids.includes(trackId)) {
|
||||
ids.push(trackId);
|
||||
await AsyncStorage.setItem(DOWNLOADED_TRACKS_KEY, JSON.stringify(ids));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to add downloaded track:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDownloadedTrack(trackId: number) {
|
||||
try {
|
||||
let ids = await getDownloadedTracks();
|
||||
ids = ids.filter(id => id !== trackId);
|
||||
await AsyncStorage.setItem(DOWNLOADED_TRACKS_KEY, JSON.stringify(ids));
|
||||
} catch (err) {
|
||||
console.warn('Failed to remove downloaded track:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function getTrackFilePath(trackId: number): string {
|
||||
return `${DOCUMENTS_DIR}track_${trackId}.mp3`;
|
||||
}
|
||||
|
||||
export async function isTrackDownloaded(trackId: number): Promise<boolean> {
|
||||
try {
|
||||
const info = await FileSystem.getInfoAsync(getTrackFilePath(trackId));
|
||||
return info.exists;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Track download ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface DownloadProgress {
|
||||
trackId: number;
|
||||
progress: number;
|
||||
loaded: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function downloadTrack(
|
||||
track: Track,
|
||||
onProgress?: (progress: DownloadProgress) => void
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const filePath = getTrackFilePath(track.id);
|
||||
const uri = track.cdn_url || track.cdn_url2;
|
||||
|
||||
if (!uri) {
|
||||
console.warn('No download URL for track:', track.id);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if already downloaded
|
||||
if (await isTrackDownloaded(track.id)) {
|
||||
await addDownloadedTrack(track.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Download with progress tracking
|
||||
const downloadResumable = FileSystem.createDownloadResumable(
|
||||
uri,
|
||||
filePath,
|
||||
{},
|
||||
(progress) => {
|
||||
const { totalBytesWritten, totalBytesExpectedToDownload } = progress;
|
||||
onProgress?.({
|
||||
trackId: track.id,
|
||||
progress: totalBytesWritten / totalBytesExpectedToDownload,
|
||||
loaded: totalBytesWritten,
|
||||
total: totalBytesExpectedToDownload,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const result = await downloadResumable.downloadAsync();
|
||||
if (result?.uri) {
|
||||
await addDownloadedTrack(track.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.warn('Failed to download track:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTrack(trackId: number): Promise<boolean> {
|
||||
try {
|
||||
const filePath = getTrackFilePath(trackId);
|
||||
const info = await FileSystem.getInfoAsync(filePath);
|
||||
if (info.exists) {
|
||||
await FileSystem.deleteAsync(filePath);
|
||||
await removeDownloadedTrack(trackId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.warn('Failed to delete track:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStorageInfo(): Promise<{ used: number; total: number } | null> {
|
||||
try {
|
||||
const info = await FileSystem.getInfoAsync(DOCUMENTS_DIR);
|
||||
if (!info.exists) return null;
|
||||
|
||||
// Get all files
|
||||
const files = await FileSystem.readDirectoryAsync(DOCUMENTS_DIR);
|
||||
let used = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const fileInfo = await FileSystem.getInfoAsync(`${DOCUMENTS_DIR}${file}`);
|
||||
if (fileInfo.size) used += fileInfo.size;
|
||||
}
|
||||
|
||||
return { used, total: 5 * 1024 * 1024 * 1024 }; // 5GB limit
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue