refactor(web): migrate trackService to orval-generated track client (v1.0.8 B5)

Third feature-service migration after B3 (profile) / B4 (playlist).
Replaces raw apiClient calls in @/features/tracks/services/trackService.ts
with orval-generated functions from services/generated/track/track.ts.
All public function signatures preserved — none of the 10 consumers
(useMyTracks, ListenTogetherPage, ExploreView, TrackList, TrackDetailPage,
TrackLyricsSection, TrackMetadataEditModal, etc.) need to change.

Functions migrated (10):
- getTracks         → orval getTracks (with AbortSignal via RequestInit)
- getTrack          → orval getTracksId
- getLyrics         → orval getTracksIdLyrics
- updateLyrics      → orval putTracksIdLyrics
- getSuggestedTags  → orval getTracksSuggestedTags
- updateTrack       → orval putTracksId
- deleteTrack       → orval deleteTracksId
- searchTracks      → orval getTracksSearch
- likeTrack         → orval postTracksIdLike
- unlikeTrack       → orval deleteTracksIdLike
- recordPlay        → orval postTracksIdPlay

Functions still on raw apiClient:
- downloadTrack     → orval getTracksIdDownload doesn't preserve
                      responseType: 'blob'; per-call responseType
                      override needs B9 cleanup pass.
- uploadTrack /     → delegate to uploadService (chunked transport
  getTrackStatus      lives there, separate concern from CRUD).

Two helpers (unwrapPayload, pickTrack) normalise the {data: ...} APIResponse
envelope and the {track: ...} single-resource shape, mirroring the B4
playlist pattern.

getTracks keeps its sortOrder param in the public signature for
forward-compat, but the orval call drops it — the backend swaggo
annotation on GET /tracks (track_crud_handler.go) declares only
sort_by, and the handler ignores any sort_order arg silently. Same
deferral pattern as B4. Re-enable when the backend annotation is
extended (v1.0.9).

Error handling preserved verbatim — AxiosError still propagates from
the orval mutator (Axios under the hood), so the existing status-code
→ TrackUploadError mapping (401 / 403 / 404 / 400 / 500 / network)
continues to apply unchanged.

Tests: trackService has no dedicated test file (trackService.test.ts
doesn't exist). Adjacent feature suites all green:
- src/features/tracks/  → 553/553
- src/features/player/, library/, components/dashboard, social →
  400/400

npm run typecheck: clean.

Bisectable: revert this commit → service returns to apiClient pattern.
No interceptor changes, no data-shape drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-25 23:17:29 +02:00
parent 8a4681643c
commit feb5fc02be

View file

@ -1,4 +1,30 @@
/**
* Track service orval-backed.
* v1.0.8 B5 migration: replaced raw apiClient calls with the generated
* functions in services/generated/track/track.ts. Public function
* signatures unchanged so callers (hooks, components, tests) stay put.
*
* Error handling structure preserved verbatim AxiosError still
* propagates from the orval mutator (apiClient under the hood), so the
* existing status-code TrackUploadError mapping continues to apply.
*
* Upload paths still delegate to the dedicated uploadService (chunked
* transport + progress tracking lives there, not in track CRUD).
*/
import { apiClient } from '@/services/api/client';
import {
getTracks as orvalGetTracks,
getTracksId as orvalGetTracksId,
putTracksId as orvalPutTracksId,
deleteTracksId as orvalDeleteTracksId,
getTracksIdLyrics as orvalGetLyrics,
putTracksIdLyrics as orvalPutLyrics,
getTracksSuggestedTags as orvalGetSuggestedTags,
getTracksSearch as orvalSearchTracks,
postTracksIdLike as orvalLike,
deleteTracksIdLike as orvalUnlike,
postTracksIdPlay as orvalRecordPlay,
} from '@/services/generated/track/track';
import { Track } from '../types/track';
import { AxiosError } from 'axios';
import { TrackServiceError as TrackUploadError } from '../errors/trackErrors';
@ -35,6 +61,24 @@ export interface TrackListResponse {
total_pages: number;
}
// Backend wraps responses in { data: { ... } } via APIResponse envelope.
// orval's mutator already strips the Axios layer, leaving the envelope.
const unwrapPayload = <T>(raw: unknown): T => {
const env = raw as { data?: unknown } | undefined;
if (env && typeof env === 'object' && 'data' in env && env.data !== undefined) {
return env.data as T;
}
return raw as T;
};
const pickTrack = (raw: unknown): Track => {
const p = unwrapPayload<{ track?: Track } | Track>(raw);
if (p && typeof p === 'object' && 'track' in p && p.track) {
return p.track as Track;
}
return p as Track;
};
/**
* Récupère la liste des tracks
* @param params Paramètres de filtrage et pagination
@ -45,21 +89,25 @@ export async function getTracks(
params: TrackListParams = {},
signal?: AbortSignal,
): Promise<TrackListResponse> {
// Backend GET /tracks ne déclare pas sort_order dans son annotation
// swaggo (cf. track_crud_handler.go) — paramètre conservé dans la
// signature pour forward-compat mais ignoré à l'appel. À ré-activer
// quand l'annotation est étendue (v1.0.9).
void params.sortOrder;
try {
const queryParams = new URLSearchParams();
if (params.page) queryParams.append('page', params.page.toString());
if (params.limit) queryParams.append('limit', params.limit.toString());
if (params.userId) queryParams.append('user_id', params.userId);
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<TrackListResponse>(url, { signal });
return response.data;
const response = await orvalGetTracks(
{
page: params.page,
limit: params.limit,
user_id: params.userId,
genre: params.genre,
format: params.format,
sort_by: params.sortBy,
},
signal ? { signal } : undefined,
);
return unwrapPayload<TrackListResponse>(response);
} catch (error: unknown) {
if (error instanceof AxiosError) {
if (error.response?.status === 401) {
@ -78,7 +126,6 @@ export async function getTracks(
error,
);
}
// Erreurs réseau
if (
error.code === 'ECONNABORTED' ||
error.code === 'ETIMEDOUT' ||
@ -111,14 +158,11 @@ export async function getTracks(
/**
* Récupère les détails d'un track
* @param id ID du track
* @returns Le track
* @throws Error si la requête échoue
*/
export async function getTrack(id: string): Promise<Track> {
try {
const response = await apiClient.get<{ track: Track }>(`/tracks/${id}`);
return response.data.track;
const response = await orvalGetTracksId(id);
return pickTrack(response);
} catch (error: unknown) {
if (error instanceof AxiosError) {
if (error.response?.status === 401) {
@ -145,7 +189,6 @@ export async function getTrack(id: string): Promise<Track> {
error,
);
}
// Erreurs réseau
if (
error.code === 'ECONNABORTED' ||
error.code === 'ETIMEDOUT' ||
@ -177,7 +220,7 @@ export async function getTrack(id: string): Promise<Track> {
}
/**
* Interface pour les paramètres de mise à jour d'un track
* Interface pour les paroles d'un track
*/
export interface TrackLyrics {
id: string;
@ -192,10 +235,9 @@ export interface TrackLyrics {
*/
export async function getLyrics(trackId: string): Promise<TrackLyrics | null> {
try {
const response = await apiClient.get<{ lyrics: TrackLyrics | null }>(
`/tracks/${trackId}/lyrics`,
);
return response.data.lyrics;
const response = await orvalGetLyrics(trackId);
const payload = unwrapPayload<{ lyrics?: TrackLyrics | null } | null>(response);
return payload?.lyrics ?? null;
} catch (error: unknown) {
if (error instanceof AxiosError && error.response?.status === 404) {
return null;
@ -211,11 +253,12 @@ export async function updateLyrics(
trackId: string,
content: string,
): Promise<TrackLyrics> {
const response = await apiClient.put<{ lyrics: TrackLyrics }>(
`/tracks/${trackId}/lyrics`,
{ content },
const response = await orvalPutLyrics(
trackId,
{ content } as Parameters<typeof orvalPutLyrics>[1],
);
return response.data.lyrics;
const payload = unwrapPayload<{ lyrics: TrackLyrics }>(response);
return payload.lyrics;
}
/**
@ -225,14 +268,12 @@ export async function getSuggestedTags(params?: {
genre?: string;
bpm?: number;
}): Promise<string[]> {
const searchParams = new URLSearchParams();
if (params?.genre) searchParams.set('genre', params.genre);
if (params?.bpm != null) searchParams.set('bpm', String(params.bpm));
const qs = searchParams.toString();
const response = await apiClient.get<{ tags: string[] }>(
`/tracks/suggested-tags${qs ? `?${qs}` : ''}`,
);
return response.data.tags;
const response = await orvalGetSuggestedTags({
genre: params?.genre,
bpm: params?.bpm,
} as Parameters<typeof orvalGetSuggestedTags>[0]);
const payload = unwrapPayload<{ tags?: string[] }>(response);
return payload.tags ?? [];
}
export interface UpdateTrackParams {
@ -252,21 +293,17 @@ export interface UpdateTrackParams {
/**
* Met à jour les métadonnées d'un track
* @param id ID du track
* @param params Paramètres de mise à jour
* @returns Le track mis à jour
* @throws Error si la requête échoue
*/
export async function updateTrack(
id: string,
params: UpdateTrackParams,
): Promise<Track> {
try {
const response = await apiClient.put<{ track: Track }>(
`/tracks/${id}`,
params,
const response = await orvalPutTracksId(
id,
params as Parameters<typeof orvalPutTracksId>[1],
);
return response.data.track;
return pickTrack(response);
} catch (error: unknown) {
if (error instanceof AxiosError) {
if (error.response?.status === 401) {
@ -305,7 +342,6 @@ export async function updateTrack(
error,
);
}
// Erreurs réseau
if (
error.code === 'ECONNABORTED' ||
error.code === 'ETIMEDOUT' ||
@ -338,12 +374,10 @@ export async function updateTrack(
/**
* Supprime un track
* @param id ID du track à supprimer
* @throws Error si la requête échoue
*/
export async function deleteTrack(id: string): Promise<void> {
try {
await apiClient.delete(`/tracks/${id}`);
await orvalDeleteTracksId(id);
} catch (error: unknown) {
if (error instanceof AxiosError) {
if (error.response?.status === 401) {
@ -378,7 +412,6 @@ export async function deleteTrack(id: string): Promise<void> {
error,
);
}
// Erreurs réseau
if (
error.code === 'ECONNABORTED' ||
error.code === 'ETIMEDOUT' ||
@ -415,35 +448,44 @@ export async function deleteTrack(id: string): Promise<void> {
* Search for tracks by query string
*/
export async function searchTracks(query: string) {
const response = await apiClient.get<Track[]>('/tracks/search', {
params: { query },
});
return { tracks: response.data };
const response = await orvalSearchTracks({ q: query });
const payload = unwrapPayload<{ tracks?: Track[] } | Track[]>(response);
if (Array.isArray(payload)) {
return { tracks: payload };
}
return { tracks: payload.tracks ?? [] };
}
/**
* Like a track
*/
export async function likeTrack(id: string) {
return apiClient.post(`/tracks/${id}/like`);
return orvalLike(id);
}
/**
* Unlike a track
*/
export async function unlikeTrack(id: string) {
return apiClient.delete(`/tracks/${id}/like`);
return orvalUnlike(id);
}
/**
* Record a play event for a track
*/
export async function recordPlay(id: string) {
return apiClient.post(`/tracks/${id}/play`);
return orvalRecordPlay(
id,
{} as Parameters<typeof orvalRecordPlay>[1],
);
}
/**
* Download a track as a blob
* Download a track as a blob.
* Kept on raw apiClient because orval's getTracksIdDownload doesn't
* preserve the responseType: 'blob' contract callers depend on (the
* mutator-typed response would deserialize binary as JSON). Re-evaluate
* once orval supports per-call responseType overrides (B9 cleanup).
*/
export async function downloadTrack(id: string) {
const response = await apiClient.get(`/tracks/${id}/download`, {