initial state: muzick music player + recommendation engine

This commit is contained in:
kami
2026-07-14 01:35:52 +04:00
commit 737bf19fd1
196 changed files with 32431 additions and 0 deletions
+39
View File
@@ -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;
},
};
+7
View File
@@ -0,0 +1,7 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
});
export default api;
+39
View File
@@ -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;
},
};
+28
View File
@@ -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;
},
};
+31
View File
@@ -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;
},
};
+10
View File
@@ -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;
};
+36
View File
@@ -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;
},
};
+140
View File
@@ -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;
},
};
+8
View File
@@ -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}`);
},
};
+10
View File
@@ -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;
},
};
+48
View File
@@ -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) });
},
};
+54
View File
@@ -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;
},
};
+73
View File
@@ -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;
}