initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import api from './api';
|
||||
import type { Album, AlbumWithTracks } from '../types';
|
||||
|
||||
export interface ListAlbumsParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export const albumService = {
|
||||
// GET /api/albums
|
||||
async listAlbums(params?: ListAlbumsParams): Promise<Album[]> {
|
||||
const res = await api.get<Album[]>('/albums', { params });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/albums/:id (returns album + tracks)
|
||||
async getAlbum(id: string): Promise<AlbumWithTracks> {
|
||||
const res = await api.get<AlbumWithTracks>(`/albums/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// POST /api/albums
|
||||
async createAlbum(data: Partial<Album>): Promise<Album> {
|
||||
const res = await api.post<Album>('/albums', data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// PUT /api/albums/:id
|
||||
async updateAlbum(id: string, data: Partial<Album>): Promise<Album> {
|
||||
const res = await api.put<Album>(`/albums/${id}`, data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// DELETE /api/albums/:id
|
||||
async deleteAlbum(id: string): Promise<{ status: string }> {
|
||||
const res = await api.delete<{ status: string }>(`/albums/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
||||
});
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,39 @@
|
||||
import api from './api';
|
||||
import type { Artist, ArtistWithAlbums } from '../types';
|
||||
|
||||
export interface ListArtistsParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export const artistService = {
|
||||
// GET /api/artists
|
||||
async listArtists(params?: ListArtistsParams): Promise<Artist[]> {
|
||||
const res = await api.get<Artist[]>('/artists', { params });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/artists/:id (returns artist + albums)
|
||||
async getArtist(id: string): Promise<ArtistWithAlbums> {
|
||||
const res = await api.get<ArtistWithAlbums>(`/artists/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// POST /api/artists
|
||||
async createArtist(data: Partial<Artist>): Promise<Artist> {
|
||||
const res = await api.post<Artist>('/artists', data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// PUT /api/artists/:id
|
||||
async updateArtist(id: string, data: Partial<Artist>): Promise<Artist> {
|
||||
const res = await api.put<Artist>(`/artists/${id}`, data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// DELETE /api/artists/:id
|
||||
async deleteArtist(id: string): Promise<{ status: string }> {
|
||||
const res = await api.delete<{ status: string }>(`/artists/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import api from './api';
|
||||
import type { Track } from '../types';
|
||||
|
||||
export const favoritesService = {
|
||||
// GET /api/favorites -> Track[]
|
||||
async list(): Promise<Track[]> {
|
||||
const res = await api.get<Track[]>('/favorites');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// POST /api/favorites/:trackId -> { status: 'added' }
|
||||
async add(trackId: string): Promise<{ status: string }> {
|
||||
const res = await api.post<{ status: string }>(`/favorites/${trackId}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// DELETE /api/favorites/:trackId -> { status: 'removed' }
|
||||
async remove(trackId: string): Promise<{ status: string }> {
|
||||
const res = await api.delete<{ status: string }>(`/favorites/${trackId}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// POST /api/tracks/:trackId/dislike -> { status: 'disliked' }
|
||||
async dislike(trackId: string): Promise<{ status: string }> {
|
||||
const res = await api.post<{ status: string }>(`/tracks/${trackId}/dislike`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import api from './api';
|
||||
import type { Genre, Track } from '../types';
|
||||
|
||||
export interface GetGenreTracksParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export const genreService = {
|
||||
// GET /api/genres — all genres with a track_count, ordered by popularity.
|
||||
async listGenres(): Promise<Genre[]> {
|
||||
const res = await api.get<Genre[]>('/genres');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/genres/:id — a single genre (with track_count), or null if missing.
|
||||
async getGenre(id: string): Promise<Genre | null> {
|
||||
try {
|
||||
const res = await api.get<Genre>(`/genres/${id}`);
|
||||
return res.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// GET /api/genres/:id/tracks — LIBRARY tracks in this genre, by weight DESC.
|
||||
async getGenreTracks(id: string, params?: GetGenreTracksParams): Promise<Track[]> {
|
||||
const res = await api.get<Track[]>(`/genres/${id}/tracks`, { params });
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import api from './api';
|
||||
import type { HealthResponse } from '../types';
|
||||
|
||||
export type { HealthResponse };
|
||||
|
||||
// GET /api/health
|
||||
export const fetchHealthStatus = async (): Promise<HealthResponse> => {
|
||||
const response = await api.get<HealthResponse>('/health');
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import api from './api';
|
||||
import type { FeedbackAction, HistoryEntry } from '../types';
|
||||
|
||||
export const historyService = {
|
||||
// POST /api/history { trackId, completed?, batchId? } -> { historyId }
|
||||
async recordPlay(
|
||||
trackId: string,
|
||||
completed?: boolean,
|
||||
batchId?: string
|
||||
): Promise<{ historyId: string }> {
|
||||
const res = await api.post<{ historyId: string }>('/history', {
|
||||
trackId,
|
||||
completed,
|
||||
batchId,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// POST /api/history/skip { trackId } -> { status }
|
||||
async skip(trackId: string): Promise<{ status: string }> {
|
||||
const res = await api.post<{ status: string }>('/history/skip', { trackId });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/history -> recent play history (Track + playback metadata)
|
||||
async list(): Promise<HistoryEntry[]> {
|
||||
const res = await api.get<HistoryEntry[]>('/history');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// POST /api/feedback { trackId, action } -> { status }
|
||||
async feedback(trackId: string, action: FeedbackAction): Promise<{ status: string }> {
|
||||
const res = await api.post<{ status: string }>('/feedback', { trackId, action });
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import api from './api';
|
||||
|
||||
export interface QueueStats {
|
||||
waiting: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
paused: number;
|
||||
}
|
||||
|
||||
export interface JobHistoryEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
finishedOn?: number;
|
||||
failedReason?: string;
|
||||
returnvalue?: unknown;
|
||||
progress?: number;
|
||||
attemptsMade?: number;
|
||||
}
|
||||
|
||||
export type JobStatus = 'completed' | 'failed' | 'running';
|
||||
|
||||
export const JOB_LABELS: Record<string, string> = {
|
||||
scan_library: 'Library Scan',
|
||||
metadata_refresh: 'Metadata Refresh',
|
||||
artist_similarity: 'Artist Similarity',
|
||||
artist_image: 'Artist Image',
|
||||
album_cover: 'Album Cover',
|
||||
audio_analysis: 'Audio Analysis',
|
||||
integrity_sweep: 'Integrity Sweep',
|
||||
cleanup_sweep: 'Cleanup Sweep',
|
||||
cleanup: 'Cleanup',
|
||||
reindex_tracks: 'Reindex Tracks',
|
||||
reprocess_artists: 'Reprocess Artists',
|
||||
};
|
||||
|
||||
/** Human-readable summary of job payload data for display in the list. */
|
||||
export function getJobDataSummary(name: string, data: Record<string, unknown>): string {
|
||||
switch (name) {
|
||||
case 'scan_library':
|
||||
return `📁 ${String(data.directory ?? '?')}`;
|
||||
case 'metadata_refresh':
|
||||
return `🎵 track: ${String(data.trackId ?? '?').slice(0, 12)} · ${String(data.refreshType ?? '?')}`;
|
||||
case 'audio_analysis':
|
||||
return `🎵 track: ${String(data.trackId ?? '?').slice(0, 12)} · features: ${Array.isArray(data.features) ? data.features.length : '?'}`;
|
||||
case 'artist_similarity':
|
||||
return `👤 artist: ${String(data.artistId ?? '?').slice(0, 12)}`;
|
||||
case 'artist_image':
|
||||
return `🖼️ artist: ${String(data.artistId ?? '?').slice(0, 12)}`;
|
||||
case 'album_cover':
|
||||
return `💿 album: ${String(data.albumId ?? '?').slice(0, 12)}`;
|
||||
case 'integrity_sweep':
|
||||
return `🔍 ${String(data.reason ?? 'scheduled')}`;
|
||||
case 'cleanup_sweep':
|
||||
return `🧹 ${String(data.reason ?? 'scheduled')}`;
|
||||
case 'cleanup':
|
||||
return `🧹 ${String(data.reason ?? '?')} · ${Array.isArray(data.targetFiles) ? `${data.targetFiles.length} files` : '?'}`;
|
||||
case 'reindex_tracks':
|
||||
return `🔄 all tracks → Typesense`;
|
||||
case 'reprocess_artists':
|
||||
return `👤 batch=${String(data.batchSize ?? '?')} offset=${String(data.offset ?? '?')}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable detail lines for the expanded view. */
|
||||
export function getJobDataDetails(name: string, data: Record<string, unknown>): { label: string; value: string }[] {
|
||||
const details: { label: string; value: string }[] = [];
|
||||
|
||||
switch (name) {
|
||||
case 'scan_library':
|
||||
details.push({ label: 'Directory', value: String(data.directory ?? '?') });
|
||||
break;
|
||||
case 'metadata_refresh':
|
||||
details.push({ label: 'Track ID', value: String(data.trackId ?? '?') });
|
||||
details.push({ label: 'Refresh Type', value: String(data.refreshType ?? '?') });
|
||||
break;
|
||||
case 'audio_analysis':
|
||||
details.push({ label: 'Track ID', value: String(data.trackId ?? '?') });
|
||||
details.push({ label: 'Features', value: Array.isArray(data.features) ? data.features.join(', ') : '?' });
|
||||
break;
|
||||
case 'artist_similarity':
|
||||
details.push({ label: 'Artist ID', value: String(data.artistId ?? '?') });
|
||||
break;
|
||||
case 'artist_image':
|
||||
details.push({ label: 'Artist ID', value: String(data.artistId ?? '?') });
|
||||
break;
|
||||
case 'album_cover':
|
||||
details.push({ label: 'Album ID', value: String(data.albumId ?? '?') });
|
||||
break;
|
||||
case 'integrity_sweep':
|
||||
details.push({ label: 'Reason', value: String(data.reason ?? 'scheduled') });
|
||||
break;
|
||||
case 'cleanup_sweep':
|
||||
details.push({ label: 'Reason', value: String(data.reason ?? 'scheduled') });
|
||||
break;
|
||||
case 'cleanup':
|
||||
details.push({ label: 'Reason', value: String(data.reason ?? '?') });
|
||||
details.push({ label: 'Target Files', value: Array.isArray(data.targetFiles) ? data.targetFiles.join(', ') : '?' });
|
||||
break;
|
||||
case 'reprocess_artists':
|
||||
details.push({ label: 'Batch Size', value: String(data.batchSize ?? '?') });
|
||||
details.push({ label: 'Offset', value: String(data.offset ?? '?') });
|
||||
break;
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
export function getJobStatus(job: JobHistoryEntry): JobStatus {
|
||||
if (!job.finishedOn && !job.failedReason) return 'running';
|
||||
if (job.failedReason) return 'failed';
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
export const jobsService = {
|
||||
async getQueueStats(): Promise<QueueStats> {
|
||||
const response = await api.get('/admin/queue-stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getJobHistory(limit = 100): Promise<JobHistoryEntry[]> {
|
||||
const response = await api.get('/admin/job-history', { params: { limit } });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async triggerScan(directory: string) {
|
||||
const response = await api.post('/admin/scan', { directory });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async triggerReindex() {
|
||||
const response = await api.post('/admin/reindex-tracks');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// Barrel re-export for the library-related services. The previous version of this
|
||||
// file pointed at `/library/*` paths, but the backend registers library routes at
|
||||
// the `/api` root (see backend/src/app.ts). Use the per-entity services instead.
|
||||
export { trackService } from './trackService';
|
||||
export { artistService } from './artistService';
|
||||
export { albumService } from './albumService';
|
||||
export { favoritesService } from './favoritesService';
|
||||
export type { Track, Artist, Album } from '../types';
|
||||
@@ -0,0 +1,17 @@
|
||||
import api from './api';
|
||||
import type { DislikeEntry } from '../types';
|
||||
|
||||
export const quarantineService = {
|
||||
async list(): Promise<DislikeEntry[]> {
|
||||
const res = await api.get<DislikeEntry[]>('/dislikes');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async restore(trackId: string): Promise<void> {
|
||||
await api.post(`/dislikes/${trackId}/restore`);
|
||||
},
|
||||
|
||||
async hardDelete(trackId: string): Promise<void> {
|
||||
await api.delete(`/dislikes/${trackId}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import api from './api';
|
||||
import type { SearchResponse } from '../types';
|
||||
|
||||
export const searchService = {
|
||||
// GET /api/search?q= — Typesense-backed search over tracks (title, artist).
|
||||
async search(q: string): Promise<SearchResponse> {
|
||||
const res = await api.get<SearchResponse>('/search', { params: { q } });
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import api from './api';
|
||||
|
||||
const SETTING_KEYS = [
|
||||
'enrich_metadata',
|
||||
'enrich_cover_art',
|
||||
'enrich_genres',
|
||||
'enrich_lyrics',
|
||||
'enrich_artist_similarity',
|
||||
'enrich_audio_analysis',
|
||||
] as const;
|
||||
|
||||
export type EnrichSettingKey = typeof SETTING_KEYS[number];
|
||||
|
||||
export interface EnrichSettings {
|
||||
enrich_metadata: boolean;
|
||||
enrich_cover_art: boolean;
|
||||
enrich_genres: boolean;
|
||||
enrich_lyrics: boolean;
|
||||
enrich_artist_similarity: boolean;
|
||||
enrich_audio_analysis: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: EnrichSettings = {
|
||||
enrich_metadata: true,
|
||||
enrich_cover_art: true,
|
||||
enrich_genres: true,
|
||||
enrich_lyrics: true,
|
||||
enrich_artist_similarity: true,
|
||||
enrich_audio_analysis: false,
|
||||
};
|
||||
|
||||
export const settingsService = {
|
||||
async load(): Promise<EnrichSettings> {
|
||||
const res = await api.get<Record<string, string>>('/settings');
|
||||
const raw = res.data;
|
||||
const out = { ...DEFAULTS };
|
||||
for (const key of SETTING_KEYS) {
|
||||
if (raw[key] !== undefined) {
|
||||
(out as any)[key] = raw[key] === 'true';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
async update(key: EnrichSettingKey, value: boolean): Promise<void> {
|
||||
await api.put(`/settings/${key}`, { value: String(value) });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import api from './api';
|
||||
import type { Track } from '../types';
|
||||
|
||||
export interface ListTracksParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort_by?: 'title' | 'artist' | 'album_id' | 'duration' | 'play_count';
|
||||
order?: 'ASC' | 'DESC';
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export const trackService = {
|
||||
// GET /api/tracks
|
||||
async listTracks(params?: ListTracksParams): Promise<Track[]> {
|
||||
const res = await api.get<Track[]>('/tracks', { params });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/tracks/:id
|
||||
async getTrack(id: string): Promise<Track> {
|
||||
const res = await api.get<Track>(`/tracks/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// Plain URL for an <audio> src. GET /api/tracks/:id/stream (supports Range).
|
||||
getStreamUrl(id: string): string {
|
||||
const base = api.defaults.baseURL ?? '/api';
|
||||
return `${base}/tracks/${id}/stream`;
|
||||
},
|
||||
|
||||
// POST /api/tracks
|
||||
async createTrack(data: Partial<Track>): Promise<Track> {
|
||||
const res = await api.post<Track>('/tracks', data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// PUT /api/tracks/:id
|
||||
async updateTrack(id: string, data: Partial<Track>): Promise<Track> {
|
||||
const res = await api.put<Track>(`/tracks/${id}`, data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// DELETE /api/tracks/:id
|
||||
async deleteTrack(id: string): Promise<{ status: string }> {
|
||||
const res = await api.delete<{ status: string }>(`/tracks/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/tracks/:id/lyrics
|
||||
async getLyrics(id: string): Promise<{ lyrics_text: string | null; synced_lyrics: unknown | null; provider: string | null }> {
|
||||
const res = await api.get(`/tracks/${id}/lyrics`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import api from './api';
|
||||
import type { Track } from '../types';
|
||||
|
||||
// A candidate from the v2 recommendation plan. The plan is stored server-side
|
||||
// in Redis; the frontend only needs trackId + explanation for display.
|
||||
export interface VibePlanItem {
|
||||
trackId: string;
|
||||
generatorId: string;
|
||||
explanation: unknown[];
|
||||
relevance: number;
|
||||
}
|
||||
|
||||
export interface VibeStartResponse {
|
||||
sessionId: string;
|
||||
plan: VibePlanItem[];
|
||||
}
|
||||
|
||||
export interface VibeNextResponse {
|
||||
track: Track;
|
||||
explanation: unknown[] | null;
|
||||
planRemaining: number;
|
||||
}
|
||||
|
||||
export type VibeFeedbackAction = 'completed' | 'skipped' | 'promoted' | 'disliked';
|
||||
|
||||
// V2 recommendation engine. The backend stores the plan in Redis (2h TTL) and
|
||||
// serves tracks one at a time via GET /next. Feedback triggers replanning.
|
||||
export const vibeService = {
|
||||
// POST /api/v2/vibe/start { seedTrackId? } -> { sessionId, plan }
|
||||
async start(seedTrackId?: string): Promise<VibeStartResponse> {
|
||||
const res = await api.post<VibeStartResponse>('/v2/vibe/start', { seedTrackId });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/v2/vibe/next -> { track, explanation, planRemaining }
|
||||
// Returns one track at a time, shifting the server-side plan.
|
||||
// 404 if no active plan — caller should handle gracefully.
|
||||
async next(): Promise<VibeNextResponse> {
|
||||
const res = await api.get<VibeNextResponse>('/v2/vibe/next');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// POST /api/v2/vibe/feedback { trackId, action } -> { status, planRemaining }
|
||||
// Action 'promoted' also calls addFavorite; 'disliked' also calls dislikeTrack.
|
||||
// Triggers replan of the remaining plan.
|
||||
async feedback(trackId: string, action: VibeFeedbackAction): Promise<{ status: string; planRemaining: number }> {
|
||||
const res = await api.post<{ status: string; planRemaining: number }>('/v2/vibe/feedback', { trackId, action });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/v2/vibe/plan -> { sessionId, planRemaining, plan }
|
||||
// Debug endpoint — returns the full remaining plan.
|
||||
async getPlan(): Promise<{ sessionId: string; planRemaining: number; plan: VibePlanItem[] }> {
|
||||
const res = await api.get('/v2/vibe/plan');
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Fetch N tracks from the v2 plan sequentially. Each call to /next shifts the
|
||||
// server-side plan, so calls must be sequential (not parallel). Stops early on
|
||||
// 404 (plan exhausted or expired).
|
||||
export async function fetchNextBatch(count: number): Promise<Track[]> {
|
||||
const tracks: Track[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
try {
|
||||
const { track } = await vibeService.next();
|
||||
tracks.push(track);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
Reference in New Issue
Block a user