veza/apps/web/src/features/tracks/api/trackApi.ts
senke 94b363ebac [FE-PAGE-002] fe-page: Complete Library page implementation
- Added filtering by genre and format with dropdown selects
- Added sorting by date, title, and popularity with order toggle
- Added bulk operations: select multiple tracks, bulk delete, bulk update
- Added bulk mode toggle with selection checkboxes
- Added batch delete and batch update API functions
- Added pagination controls
- Improved UI with filter bar and sort dropdown
- Added toast notifications for operations
- Added select all/deselect all functionality
2025-12-24 12:38:25 +01:00

842 lines
22 KiB
TypeScript

import { apiClient } from '@/services/api/client';
import { Track, TrackStatus } from '../types/track';
import { AxiosError, AxiosProgressEvent } from 'axios';
/**
* Track API
* API layer pour l'upload et la récupération de tracks
* Utilise FormData pour l'upload et gère la pagination
*/
export interface TrackMetadata {
title?: string;
artist?: string;
album?: string;
genre?: string;
year?: number;
is_public?: boolean;
}
export interface UpdateTrackRequest {
title?: string;
artist?: string;
album?: string;
genre?: string;
year?: number;
is_public?: boolean;
}
export interface TrackStats {
// Structure à définir selon l'implémentation backend
[key: string]: unknown;
}
export interface TrackHistory {
// Structure à définir selon l'implémentation backend
[key: string]: unknown;
}
export interface CreateShareRequest {
permissions: 'read' | 'write' | 'admin';
expires_at?: string;
}
export interface Share {
id: string;
track_id: string;
token: string;
permissions: string;
expires_at?: string;
created_at: string;
}
export interface TrackLikesResponse {
count: number;
is_liked: boolean;
}
export interface GetTracksParams {
page?: number;
limit?: number;
userId?: string;
genre?: string;
format?: string;
sortBy?: 'created_at' | 'title' | 'popularity';
sortOrder?: 'asc' | 'desc';
}
export interface GetTracksResponse {
tracks: Track[];
pagination: {
page: number;
limit: number;
total: number;
total_pages: number;
};
}
/**
* Interface pour la réponse 202 Accepted lors d'un upload async
*/
interface AsyncUploadResponse {
track_id: string;
status: TrackStatus;
status_url: string;
message: string;
}
/**
* Poll le statut d'un track jusqu'à ce qu'il soit completed ou failed
* @param trackId ID du track à poller
* @param onProgress Callback optionnel pour suivre la progression
* @returns Le track complet une fois terminé
* @throws Error si le track échoue ou si le polling timeout
*/
async function pollTrackStatus(
trackId: string,
onProgress?: (progress: number) => void,
): Promise<Track> {
const maxAttempts = 120; // 2 minutes max (1 seconde par tentative)
const pollInterval = 1000; // 1 seconde entre chaque tentative
let attempts = 0;
while (attempts < maxAttempts) {
try {
// Récupérer le track pour vérifier son status
const response = await apiClient.get<Track>(`/tracks/${trackId}`);
const track = response.data;
// Mettre à jour la progression si callback fourni
if (onProgress) {
// Estimer la progression basée sur le status
let progress = 0;
if (track.status === 'uploading') {
progress = 30; // Upload en cours
} else if (track.status === 'processing') {
progress = 70; // Traitement en cours
} else if (track.status === 'completed') {
progress = 100; // Terminé
}
onProgress(progress);
}
// Si le track est terminé (completed ou failed), le retourner
if (track.status === 'completed') {
return track;
}
if (track.status === 'failed') {
throw new Error(
track.status_message ||
'Le traitement du fichier a échoué',
);
}
// Attendre avant le prochain poll
await new Promise((resolve) => setTimeout(resolve, pollInterval));
attempts++;
} catch (error) {
// Si c'est une erreur 404, le track n'existe pas encore, continuer à poller
if (error instanceof AxiosError && error.response?.status === 404) {
await new Promise((resolve) => setTimeout(resolve, pollInterval));
attempts++;
continue;
}
// Sinon, propager l'erreur
throw error;
}
}
// Timeout après maxAttempts
throw new Error(
'Le traitement du fichier prend plus de temps que prévu. Veuillez réessayer plus tard.',
);
}
/**
* Upload un fichier audio avec métadonnées
* @param file Fichier audio à uploader
* @param metadata Métadonnées optionnelles du track
* @param onProgress Callback optionnel pour suivre la progression
* @returns Le track créé
* @throws Error si la requête échoue
*/
export async function uploadTrack(
file: File,
metadata: TrackMetadata = {},
onProgress?: (progress: number) => void,
): Promise<Track> {
try {
const formData = new FormData();
// Le backend attend un champ nommé 'file'
formData.append('file', file);
// Ajouter les métadonnées au FormData
if (metadata.title) {
formData.append('title', metadata.title);
}
if (metadata.artist) {
formData.append('artist', metadata.artist);
}
if (metadata.album) {
formData.append('album', metadata.album);
}
if (metadata.genre) {
formData.append('genre', metadata.genre);
}
if (metadata.year) {
formData.append('year', metadata.year.toString());
}
if (metadata.is_public !== undefined) {
formData.append('is_public', metadata.is_public.toString());
}
// Construction de la config pour l'upload
// NOTE: Le token est automatiquement ajouté par l'interceptor de apiClient
// (voir apps/web/src/services/api/client.ts)
// CRITIQUE: Ne pas définir Content-Type - Axios le calcule automatiquement
// avec le boundary correct pour multipart/form-data
const config = {
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
if (progressEvent.total && onProgress) {
// Pour l'upload initial, on limite à 20% (le reste sera géré par le polling)
const uploadProgress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total,
);
onProgress(Math.min(uploadProgress * 0.2, 20));
}
},
timeout: 300000, // 5 minutes timeout for large files
};
const response = await apiClient.post<Track | AsyncUploadResponse>(
'/tracks',
formData,
config,
);
// Vérifier si c'est une réponse 202 Accepted (upload async)
if (response.status === 202) {
// Extraire le track_id de la réponse
const asyncResponse = response.data as AsyncUploadResponse;
const trackId = asyncResponse.track_id;
// Mettre à jour la progression (upload terminé, traitement en cours)
if (onProgress) {
onProgress(25);
}
// Poller le statut jusqu'à completion
return await pollTrackStatus(trackId, onProgress);
}
// Sinon, c'est une réponse 201 Created avec le Track complet
return response.data as Track;
} catch (error) {
if (error instanceof AxiosError) {
// Gérer les erreurs spécifiques selon le code de statut HTTP
if (error.response?.status === 400) {
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
'Format ou taille invalide';
throw new Error(errorMessage);
}
if (error.response?.status === 413) {
throw new Error('Fichier trop volumineux');
}
if (error.response?.status === 415) {
throw new Error('Format de fichier non supporté');
}
if (error.response?.status === 500) {
throw new Error(
'Erreur serveur: Impossible de traiter le fichier. Veuillez réessayer plus tard.',
);
}
// Erreurs réseau
if (
error.code === 'ECONNABORTED' ||
error.code === 'ETIMEDOUT' ||
!error.response
) {
throw new Error(
'Erreur réseau: Impossible de se connecter au serveur.',
);
}
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
"Erreur lors de l'upload";
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Récupère la liste paginée des tracks
* @param page Numéro de page (défaut: 1)
* @param limit Nombre d'éléments par page (défaut: 20)
* @param params Paramètres optionnels supplémentaires
* @returns La liste des tracks avec les métadonnées de pagination
* @throws Error si la requête échoue
*/
export async function getTracks(
page: number = 1,
limit: number = 20,
params?: Omit<GetTracksParams, 'page' | 'limit'>,
): Promise<GetTracksResponse> {
try {
const queryParams = new URLSearchParams();
queryParams.append('page', page.toString());
queryParams.append('limit', limit.toString());
if (params?.userId !== undefined) {
queryParams.append('user_id', params.userId.toString());
}
if (params?.genre) {
queryParams.append('genre', params.genre);
}
if (params?.format) {
queryParams.append('format', params.format);
}
if (params?.sortBy) {
queryParams.append('sort_by', params.sortBy);
}
if (params?.sortOrder) {
queryParams.append('sort_order', params.sortOrder);
}
const queryString = queryParams.toString();
const url = `/tracks${queryString ? `?${queryString}` : ''}`;
const response = await apiClient.get<GetTracksResponse>(url);
return response.data;
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 500) {
throw new Error(
'Erreur serveur: Impossible de récupérer les tracks. Veuillez réessayer plus tard.',
);
}
// Erreurs réseau
if (
error.code === 'ECONNABORTED' ||
error.code === 'ETIMEDOUT' ||
!error.response
) {
throw new Error(
'Erreur réseau: Impossible de se connecter au serveur.',
);
}
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Échec de la récupération des tracks';
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Met à jour les métadonnées d'un track
* @param trackId ID du track à mettre à jour
* @param updates Métadonnées à mettre à jour
* @returns Le track mis à jour
* @throws Error si la requête échoue
*/
export async function updateTrack(
trackId: string,
updates: UpdateTrackRequest,
): Promise<Track> {
try {
const { data } = await apiClient.put<{ track: Track }>(
`/tracks/${trackId}`,
updates,
);
return data.track;
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 404) {
throw new Error('Track introuvable');
}
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Échec de la mise à jour du track';
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Récupère les statistiques d'un track
* @param trackId ID du track
* @returns Les statistiques du track
* @throws Error si la requête échoue
*/
export async function getTrackStats(
trackId: string,
): Promise<TrackStats> {
try {
const { data } = await apiClient.get<{ stats: TrackStats }>(
`/tracks/${trackId}/stats`,
);
return data.stats;
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 404) {
throw new Error('Track introuvable');
}
if (error.response?.status === 501) {
throw new Error('Statistiques non disponibles pour le moment');
}
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Échec de la récupération des statistiques';
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Récupère l'historique d'un track
* @param trackId ID du track
* @returns L'historique du track
* @throws Error si la requête échoue
*/
export async function getTrackHistory(
trackId: string,
): Promise<TrackHistory> {
try {
const { data } = await apiClient.get<{ history: TrackHistory }>(
`/tracks/${trackId}/history`,
);
return data.history;
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 404) {
throw new Error('Track introuvable');
}
if (error.response?.status === 501) {
throw new Error('Historique non disponible pour le moment');
}
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Échec de la récupération de l\'historique';
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Télécharge un track
* @param trackId ID du track à télécharger
* @param shareToken Token de partage optionnel
* @returns Le blob du fichier audio
* @throws Error si la requête échoue
*/
export async function downloadTrack(
trackId: string,
shareToken?: string,
): Promise<Blob> {
try {
const config = shareToken
? { params: { share_token: shareToken }, responseType: 'blob' as const }
: { responseType: 'blob' as const };
const response = await apiClient.get(`/tracks/${trackId}/download`, config);
return response.data;
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 404) {
throw new Error('Track introuvable');
}
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Échec du téléchargement';
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Like un track
* @param trackId ID du track à liker
* @throws Error si la requête échoue
*/
export async function likeTrack(trackId: string): Promise<void> {
try {
await apiClient.post(`/tracks/${trackId}/like`);
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 404) {
throw new Error('Track introuvable');
}
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Échec du like';
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Unlike un track
* @param trackId ID du track à unliker
* @throws Error si la requête échoue
*/
export async function unlikeTrack(trackId: string): Promise<void> {
try {
await apiClient.delete(`/tracks/${trackId}/like`);
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 404) {
throw new Error('Track introuvable');
}
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Échec du unlike';
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Supprime un track
* @param trackId ID du track à supprimer
* @throws Error si la requête échoue
*/
export async function deleteTrack(trackId: string): Promise<void> {
try {
await apiClient.delete(`/tracks/${trackId}`);
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 404) {
throw new Error('Track introuvable');
}
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Échec de la suppression du track';
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Récupère les likes d'un track
* @param trackId ID du track
* @returns Le nombre de likes et si l'utilisateur actuel a liké
* @throws Error si la requête échoue
*/
export async function getTrackLikes(
trackId: string,
): Promise<TrackLikesResponse> {
try {
const { data } = await apiClient.get<TrackLikesResponse>(
`/tracks/${trackId}/likes`,
);
return data;
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 404) {
throw new Error('Track introuvable');
}
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Échec de la récupération des likes';
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Crée un lien de partage pour un track
* @param trackId ID du track à partager
* @param request Paramètres de partage (permissions, expires_at)
* @returns Le lien de partage créé
* @throws Error si la requête échoue
*/
export async function createTrackShare(
trackId: string,
request: CreateShareRequest,
): Promise<Share> {
try {
const { data } = await apiClient.post<{ share: Share }>(
`/tracks/${trackId}/share`,
request,
);
return data.share;
} catch (error) {
if (error instanceof AxiosError) {
if (error.response?.status === 404) {
throw new Error('Track introuvable');
}
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Échec de la création du lien de partage';
throw new Error(errorMessage);
}
throw error;
}
}
// Interfaces pour le chunked upload
interface UploadChunkResponse {
message: string;
upload_id: string;
received_chunks: number;
total_chunks: number;
progress: number;
}
interface InitiateUploadResponse {
upload_id: string;
message: string;
}
interface CompleteUploadResponse {
message: string;
track: Track;
md5: string;
}
/**
* Initialise un upload par chunks
* @param totalChunks Nombre total de chunks
* @param totalSize Taille totale du fichier
* @param filename Nom du fichier
* @returns ID de l'upload
*/
export async function initiateChunkedUpload(
totalChunks: number,
totalSize: number,
filename: string,
): Promise<string> {
try {
const { data } = await apiClient.post<InitiateUploadResponse>(
'/tracks/initiate',
{
total_chunks: totalChunks,
total_size: totalSize,
filename,
},
);
return data.upload_id;
} catch (error) {
if (error instanceof AxiosError) {
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
"Échec de l'initialisation de l'upload";
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Upload un chunk
* @param uploadId ID de l'upload
* @param chunkNumber Numéro du chunk (1-based)
* @param totalChunks Nombre total de chunks
* @param totalSize Taille totale du fichier
* @param filename Nom du fichier
* @param chunk Données du chunk (Blob)
* @param onProgress Callback de progression
*/
export async function uploadChunk(
uploadId: string,
chunkNumber: number,
totalChunks: number,
totalSize: number,
filename: string,
chunk: Blob,
onProgress?: (progress: number) => void,
): Promise<UploadChunkResponse> {
try {
const formData = new FormData();
formData.append('upload_id', uploadId);
formData.append('chunk_number', chunkNumber.toString());
formData.append('total_chunks', totalChunks.toString());
formData.append('total_size', totalSize.toString());
formData.append('filename', filename);
formData.append('chunk', chunk);
// CRITIQUE: Ne pas définir Content-Type pour multipart/form-data avec Axios
const config = onProgress
? {
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
if (progressEvent.total) {
const progress = Math.round(
(progressEvent.loaded * 100) / progressEvent.total,
);
onProgress(progress);
}
},
}
: {};
const { data } = await apiClient.post<UploadChunkResponse>(
'/tracks/chunk',
formData,
config,
);
return data;
} catch (error) {
if (error instanceof AxiosError) {
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
`Échec de l'upload du chunk ${chunkNumber}`;
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Termine un upload par chunks
* @param uploadId ID de l'upload
* @returns Le track créé
*/
export async function completeChunkedUpload(
uploadId: string,
): Promise<Track> {
try {
const { data } = await apiClient.post<CompleteUploadResponse>(
'/tracks/complete',
{ upload_id: uploadId },
);
return data.track;
} catch (error) {
if (error instanceof AxiosError) {
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
"Échec de la finalisation de l'upload";
throw new Error(errorMessage);
}
throw error;
}
}
/**
* FE-PAGE-002: Batch operations interfaces
*/
export interface BatchDeleteRequest {
track_ids: string[];
}
export interface BatchDeleteResponse {
deleted_count: number;
message: string;
}
export interface BatchUpdateRequest {
track_ids: string[];
updates: UpdateTrackRequest;
}
export interface BatchUpdateResponse {
updated_count: number;
message: string;
}
/**
* Supprime plusieurs tracks en une seule requête
* @param trackIds Liste des IDs de tracks à supprimer
* @returns Nombre de tracks supprimés
* @throws Error si la requête échoue
*/
export async function batchDeleteTracks(
trackIds: string[],
): Promise<BatchDeleteResponse> {
try {
const { data } = await apiClient.post<BatchDeleteResponse>(
'/tracks/batch/delete',
{ track_ids: trackIds },
);
return data;
} catch (error) {
if (error instanceof AxiosError) {
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Échec de la suppression en masse';
throw new Error(errorMessage);
}
throw error;
}
}
/**
* Met à jour plusieurs tracks en une seule requête
* @param trackIds Liste des IDs de tracks à mettre à jour
* @param updates Métadonnées à appliquer à tous les tracks
* @returns Nombre de tracks mis à jour
* @throws Error si la requête échoue
*/
export async function batchUpdateTracks(
trackIds: string[],
updates: UpdateTrackRequest,
): Promise<BatchUpdateResponse> {
try {
const { data } = await apiClient.post<BatchUpdateResponse>(
'/tracks/batch/update',
{
track_ids: trackIds,
updates,
},
);
return data;
} catch (error) {
if (error instanceof AxiosError) {
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Échec de la mise à jour en masse';
throw new Error(errorMessage);
}
throw error;
}
}