501 lines
13 KiB
TypeScript
501 lines
13 KiB
TypeScript
import { apiClient } from '@/services/api/client';
|
|
import { requireFeature } from '@/config/features';
|
|
import type {
|
|
Playlist,
|
|
CreatePlaylistRequest,
|
|
UpdatePlaylistRequest,
|
|
} from '../types';
|
|
import type { PlaylistListResponse } from '../types';
|
|
|
|
// Re-export PlaylistListResponse for use in other modules
|
|
export type { PlaylistListResponse } from '../types';
|
|
|
|
/** PlaylistError - thrown when playlist API calls fail */
|
|
export class PlaylistError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly cause?: unknown,
|
|
) {
|
|
super(message);
|
|
this.name = 'PlaylistError';
|
|
}
|
|
}
|
|
|
|
function getErrorMessage(e: unknown): string {
|
|
if (e && typeof e === 'object' && 'response' in e) {
|
|
const res = (e as { response?: { data?: { error?: string; message?: string } } })
|
|
.response;
|
|
if (res?.data?.error) return res.data.error;
|
|
if (res?.data?.message) return res.data.message;
|
|
}
|
|
return e instanceof Error ? e.message : String(e);
|
|
}
|
|
|
|
async function wrapPlaylistError<T>(fn: () => Promise<T>): Promise<T> {
|
|
try {
|
|
return await fn();
|
|
} catch (e) {
|
|
throw new PlaylistError(getErrorMessage(e), e);
|
|
}
|
|
}
|
|
|
|
// Collaborator interfaces (assuming they might be in types/collaborator but defining here if needed or using any for now if not in types.ts)
|
|
// types.ts content I saw above did NOT include Collaborator types.
|
|
// I will define them here or assume they are returned as any/object for now, or add to types.ts later.
|
|
// usePlaylist.test.tsx used { id, playlist_id, user_id, permission, user: {...} }
|
|
export interface PlaylistCollaborator {
|
|
id: string; // or number? test used number. But if we move to string... I'll use string to be safe or number if DB uses number? Tracks used string.
|
|
playlist_id: string;
|
|
user_id: string;
|
|
permission: 'read' | 'write' | 'admin';
|
|
created_at: string;
|
|
updated_at: string;
|
|
user: {
|
|
id: string;
|
|
username: string;
|
|
email: string;
|
|
avatar_url?: string;
|
|
};
|
|
}
|
|
|
|
export interface AddCollaboratorRequest {
|
|
user_id: string;
|
|
permission: 'read' | 'write' | 'admin';
|
|
}
|
|
|
|
export interface UpdateCollaboratorPermissionRequest {
|
|
permission: 'read' | 'write' | 'admin';
|
|
}
|
|
|
|
/**
|
|
* Créer une nouvelle playlist
|
|
*/
|
|
export async function createPlaylist(
|
|
data: CreatePlaylistRequest,
|
|
): Promise<Playlist> {
|
|
return wrapPlaylistError(async () => {
|
|
const response = await apiClient.post<{ playlist: Playlist }>(
|
|
'/playlists',
|
|
data,
|
|
);
|
|
return response.data.playlist;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Récupérer une playlist par ID
|
|
*/
|
|
export async function getPlaylist(id: string): Promise<Playlist> {
|
|
return wrapPlaylistError(async () => {
|
|
const response = await apiClient.get<{ playlist: Playlist }>(
|
|
`/playlists/${id}`,
|
|
);
|
|
return response.data.playlist;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mettre à jour une playlist
|
|
*/
|
|
export async function updatePlaylist(
|
|
id: string,
|
|
data: UpdatePlaylistRequest,
|
|
): Promise<Playlist> {
|
|
return wrapPlaylistError(async () => {
|
|
const response = await apiClient.put<{ playlist: Playlist }>(
|
|
`/playlists/${id}`,
|
|
data,
|
|
);
|
|
return response.data.playlist;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Supprimer une playlist
|
|
*/
|
|
export async function deletePlaylist(id: string): Promise<void> {
|
|
return wrapPlaylistError(async () => {
|
|
await apiClient.delete(`/playlists/${id}`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Alias for listPlaylists - backward compatibility for tests
|
|
*/
|
|
export async function getPlaylists(
|
|
userId?: number | string,
|
|
page = 1,
|
|
limit = 20,
|
|
): Promise<PlaylistListResponse> {
|
|
return wrapPlaylistError(() =>
|
|
listPlaylists(page, limit, userId?.toString()),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Alias for addTrackToPlaylist
|
|
*/
|
|
export const addTrack = addTrackToPlaylist;
|
|
|
|
/**
|
|
* Alias for removeTrackFromPlaylist
|
|
*/
|
|
export const removeTrack = removeTrackFromPlaylist;
|
|
|
|
/**
|
|
* Reorder tracks by array of track IDs (converts to track_ids for API)
|
|
*/
|
|
export async function reorderTracks(
|
|
playlistId: string,
|
|
trackIds: (string | number)[],
|
|
): Promise<void> {
|
|
await reorderPlaylistTracks(playlistId, {
|
|
track_ids: trackIds.map((id) => String(id)),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Lister les playlists
|
|
*/
|
|
export async function listPlaylists(
|
|
page = 1,
|
|
limit = 20,
|
|
userId?: string,
|
|
sortBy?: 'created_at' | 'title' | 'track_count',
|
|
sortOrder?: 'asc' | 'desc',
|
|
): Promise<PlaylistListResponse> {
|
|
// S'assurer que limit n'est jamais 0 (corrige le bug GET /api/v1/playlists?page=20&limit=0: 401)
|
|
const safeLimit = Math.max(limit, 1);
|
|
const safePage = Math.max(page, 1);
|
|
|
|
const params: Record<string, string | number> = {
|
|
page: safePage,
|
|
limit: safeLimit,
|
|
};
|
|
if (userId) {
|
|
params.user_id = userId;
|
|
}
|
|
// CRITIQUE FIX #45: Ajouter les paramètres de tri pour préparer la migration vers le tri backend
|
|
// Le backend peut ignorer ces paramètres s'il ne les supporte pas encore
|
|
if (sortBy) {
|
|
params.sort_by = sortBy;
|
|
}
|
|
if (sortOrder) {
|
|
params.sort_order = sortOrder;
|
|
}
|
|
return wrapPlaylistError(async () => {
|
|
const response = await apiClient.get<PlaylistListResponse>('/playlists', {
|
|
params,
|
|
});
|
|
return response.data;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ajouter un collaborateur à une playlist
|
|
*
|
|
* Backend endpoint: POST /playlists/:id/collaborators
|
|
*
|
|
* @see FEATURES.PLAYLIST_COLLABORATION
|
|
*/
|
|
export async function addCollaborator(
|
|
playlistId: string,
|
|
data: AddCollaboratorRequest,
|
|
): Promise<PlaylistCollaborator> {
|
|
return wrapPlaylistError(async () => {
|
|
const response = await apiClient.post<PlaylistCollaborator>(
|
|
`/playlists/${playlistId}/collaborators`,
|
|
data,
|
|
);
|
|
return response.data;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Retirer un collaborateur
|
|
*
|
|
* Backend endpoint: DELETE /playlists/:id/collaborators/:userId
|
|
*
|
|
* @see FEATURES.PLAYLIST_COLLABORATION
|
|
*/
|
|
export async function removeCollaborator(
|
|
playlistId: string,
|
|
userId: string,
|
|
): Promise<void> {
|
|
return wrapPlaylistError(() =>
|
|
apiClient.delete(`/playlists/${playlistId}/collaborators/${userId}`),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Mettre à jour les permissions d'un collaborateur
|
|
*
|
|
* Backend endpoint: PUT /playlists/:id/collaborators/:userId
|
|
*
|
|
* @see FEATURES.PLAYLIST_COLLABORATION
|
|
*/
|
|
export async function updateCollaboratorPermission(
|
|
playlistId: string,
|
|
userId: string,
|
|
data: UpdateCollaboratorPermissionRequest,
|
|
): Promise<void> {
|
|
return wrapPlaylistError(() =>
|
|
apiClient.put(`/playlists/${playlistId}/collaborators/${userId}`, data),
|
|
);
|
|
}
|
|
|
|
export interface SearchPlaylistsParams {
|
|
q?: string;
|
|
page?: number;
|
|
limit?: number;
|
|
user_id?: string;
|
|
is_public?: boolean;
|
|
sort_by?: 'created_at' | 'title' | 'track_count';
|
|
sort_order?: 'asc' | 'desc';
|
|
}
|
|
|
|
export interface PlaylistShareLink {
|
|
share_token: string;
|
|
expires_at: string;
|
|
}
|
|
|
|
export interface ReorderTracksRequest {
|
|
track_ids: string[];
|
|
}
|
|
|
|
export interface PlaylistRecommendation {
|
|
playlist: Playlist;
|
|
score: number;
|
|
reason?: string;
|
|
}
|
|
|
|
export interface GetRecommendationsParams {
|
|
limit?: number;
|
|
min_score?: number;
|
|
include_own?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Rechercher des playlists
|
|
*
|
|
* ⚠️ MVP: This feature is disabled. Backend endpoint is not implemented.
|
|
* TODO: Enable when backend implements GET /api/v1/playlists/search
|
|
*
|
|
* @see FEATURES.PLAYLIST_SEARCH
|
|
*/
|
|
export async function searchPlaylists(
|
|
params: SearchPlaylistsParams,
|
|
): Promise<PlaylistListResponse> {
|
|
requireFeature('PLAYLIST_SEARCH');
|
|
const response = await apiClient.get<PlaylistListResponse>(
|
|
'/playlists/search',
|
|
{ params },
|
|
);
|
|
return response.data;
|
|
}
|
|
|
|
/**
|
|
* Créer un lien de partage
|
|
*
|
|
* ⚠️ MVP: This feature is disabled. Backend endpoint is not implemented.
|
|
* TODO: Enable when backend implements POST /api/v1/playlists/:id/share
|
|
*
|
|
* @see FEATURES.PLAYLIST_SHARE
|
|
*/
|
|
export async function createShareLink(id: string): Promise<PlaylistShareLink> {
|
|
requireFeature('PLAYLIST_SHARE');
|
|
const response = await apiClient.post<{ share_link: PlaylistShareLink }>(
|
|
`/playlists/${id}/share`,
|
|
);
|
|
return response.data.share_link;
|
|
}
|
|
|
|
/**
|
|
* Réorganiser les tracks d'une playlist
|
|
*/
|
|
export async function reorderPlaylistTracks(
|
|
id: string,
|
|
data: ReorderTracksRequest,
|
|
): Promise<void> {
|
|
return wrapPlaylistError(() =>
|
|
apiClient.put(`/playlists/${id}/tracks/reorder`, data),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Retirer un track d'une playlist
|
|
*/
|
|
export async function removeTrackFromPlaylist(
|
|
playlistId: string,
|
|
trackId: string,
|
|
): Promise<void> {
|
|
return wrapPlaylistError(() =>
|
|
apiClient.delete(`/playlists/${playlistId}/tracks/${trackId}`),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Obtenir des recommandations de playlists
|
|
*
|
|
* ⚠️ MVP: This feature is disabled. Backend endpoint is not implemented.
|
|
* TODO: Enable when backend implements GET /api/v1/playlists/recommendations
|
|
*
|
|
* @see FEATURES.PLAYLIST_RECOMMENDATIONS
|
|
*/
|
|
export async function getPlaylistRecommendations(
|
|
_params: GetRecommendationsParams,
|
|
): Promise<{ recommendations: PlaylistRecommendation[] }> {
|
|
requireFeature('PLAYLIST_RECOMMENDATIONS');
|
|
// TODO: Replace with actual API call when backend is ready
|
|
// const response = await apiClient.get<{ recommendations: PlaylistRecommendation[] }>('/playlists/recommendations', { params });
|
|
// return response.data;
|
|
|
|
// Mock response for now to satisfy type checker and frontend dev
|
|
return Promise.resolve({
|
|
recommendations: [],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Récupérer les collaborateurs
|
|
*/
|
|
/**
|
|
* Récupérer les collaborateurs d'une playlist
|
|
*
|
|
* Backend endpoint: GET /playlists/:id/collaborators
|
|
*/
|
|
export async function getCollaborators(
|
|
playlistId: string,
|
|
): Promise<PlaylistCollaborator[]> {
|
|
return wrapPlaylistError(async () => {
|
|
const response = await apiClient.get<{
|
|
collaborators: PlaylistCollaborator[];
|
|
}>(`/playlists/${playlistId}/collaborators`);
|
|
return response.data.collaborators || [];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ajouter un track à une playlist
|
|
*/
|
|
export async function addTrackToPlaylist(
|
|
playlistId: string,
|
|
trackId: string,
|
|
): Promise<void> {
|
|
return wrapPlaylistError(() =>
|
|
apiClient.post(`/playlists/${playlistId}/tracks`, {
|
|
track_id: trackId,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* FE-COMP-017: Follow/Unfollow playlist functions
|
|
*/
|
|
|
|
export interface FollowPlaylistResponse {
|
|
message: string;
|
|
is_following: boolean;
|
|
}
|
|
|
|
/**
|
|
* Suivre une playlist
|
|
*/
|
|
export async function followPlaylist(
|
|
playlistId: string,
|
|
): Promise<FollowPlaylistResponse> {
|
|
const response = await apiClient.post(`/playlists/${playlistId}/follow`);
|
|
return {
|
|
message: response.data.message || 'Playlist followed',
|
|
is_following: true,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ne plus suivre une playlist
|
|
*/
|
|
export async function unfollowPlaylist(
|
|
playlistId: string,
|
|
): Promise<FollowPlaylistResponse> {
|
|
const response = await apiClient.delete(`/playlists/${playlistId}/follow`);
|
|
return {
|
|
message: response.data.message || 'Playlist unfollowed',
|
|
is_following: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Vérifier si l'utilisateur suit une playlist
|
|
* Note: This uses the playlist data which may include is_following and follower_count
|
|
*/
|
|
export async function getPlaylistFollowStatus(
|
|
playlistId: string,
|
|
): Promise<{ is_following: boolean; follower_count: number }> {
|
|
// For now, we'll use the playlist data which may include follow status
|
|
// In the future, this could use a dedicated endpoint
|
|
const playlist = await getPlaylist(playlistId);
|
|
return {
|
|
is_following: (playlist as any).is_following ?? false,
|
|
follower_count: (playlist as any).follower_count ?? 0,
|
|
};
|
|
}
|
|
|
|
// ─── Backward-compatible object export ──────────────────────────────────────
|
|
// Allows consumers to use `playlistService.list(...)` syntax without changing call sites.
|
|
// Import: `import { playlistService } from '@/features/playlists/services/playlistService'`
|
|
|
|
export const playlistService = {
|
|
list: async (params?: {
|
|
page?: number;
|
|
limit?: number;
|
|
user_id?: string;
|
|
search?: string;
|
|
}) => {
|
|
const result = await listPlaylists(
|
|
params?.page ?? 1,
|
|
params?.limit ?? 20,
|
|
params?.user_id,
|
|
);
|
|
const items =
|
|
(result as any).list ||
|
|
(result as any).items ||
|
|
(result as any).playlists ||
|
|
[];
|
|
return {
|
|
playlists: items,
|
|
pagination: (result as any).pagination || {
|
|
total: (result as any).total || items.length,
|
|
page: params?.page || 1,
|
|
limit: params?.limit || 20,
|
|
total_pages: (result as any).total_pages || 1,
|
|
},
|
|
};
|
|
},
|
|
get: getPlaylist,
|
|
create: createPlaylist,
|
|
update: updatePlaylist,
|
|
delete: deletePlaylist,
|
|
addTrack: addTrackToPlaylist,
|
|
removeTrack: removeTrackFromPlaylist,
|
|
reorderTracks: async (playlistId: string, trackIds: string[]) => {
|
|
await reorderPlaylistTracks(playlistId, { track_ids: trackIds });
|
|
},
|
|
getRecommendations: async () => {
|
|
const result = await getPlaylistRecommendations({});
|
|
return { playlists: result.recommendations.map((r) => r.playlist) };
|
|
},
|
|
addCollaborator: async (
|
|
playlistId: string,
|
|
userId: string,
|
|
role: string = 'write',
|
|
) => {
|
|
return addCollaborator(playlistId, {
|
|
user_id: userId,
|
|
permission: role as 'read' | 'write' | 'admin',
|
|
});
|
|
},
|
|
getCollaborators,
|
|
removeCollaborator,
|
|
search: searchPlaylists,
|
|
createShareLink,
|
|
};
|