feat(v0.10.4): Playlists collaboratives - F136, F140, F141, F143, F145
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Storybook Audit / Build & audit Storybook (push) Failing after 0s

Backend:
- F141: GET /discover/playlists/editorial for editorial playlists
- F143: GET /playlists/shared/:token (public, no auth)
- F145: POST /playlists/import (JSON), GET /playlists/:id/export/m3u
- F136: GET /playlists/favoris (creates Favoris playlist if needed)
- Repo: GetFavorisByUserID, service GetOrCreateFavorisPlaylist

Frontend:
- SharedPlaylistPage at /playlists/shared/:token (public route)
- Editorial playlists section in DiscoverPage
- Export M3U in ExportPlaylistButton dropdown
- Import JSON via ImportPlaylistButton (PlaylistListPage)
- Favoris sidebar link, FavorisRedirectPage, AddToFavorisButton on tracks

Roadmap: v0.10.4 marked DONE
This commit is contained in:
senke 2026-03-09 16:49:05 +01:00
parent 6111ae6136
commit ac182d9f35
36 changed files with 860 additions and 82 deletions

View file

@ -604,29 +604,30 @@ Implémenter les commentaires sur les tracks et les likes, sans métriques de po
### v0.10.4 — Playlists Collaboratives (F136-F150)
**Statut** : ⏳ TODO
**Statut** : ✅ DONE
**Priorité** : P2
**Durée estimée** : 3-4 jours
**Prerequisite** : v0.10.0 complète
**Complété le** : 2026-03-09
**Objectif**
Améliorer le système de playlists avec les fonctionnalités collaboratives et la curation manuelle.
**Tâches**
- [ ] Playlists collaboratives (plusieurs contributeurs) (F140)
- [ ] Playlists curatoriales éditoriales (rôle admin/modérateur) (F141)
- [x] Playlists collaboratives (plusieurs contributeurs) (F140)
- [x] Playlists curatoriales éditoriales (rôle admin/modérateur) (F141)
- Base de la découverte éthique : playlists créées par humains
- Référence : ORIGIN_FEATURES_REGISTRY.md §30
- [ ] Partage de playlists (lien public, embed) (F143)
- [ ] Import/export de playlists (M3U, JSON) (F145)
- [ ] Playlist "Favoris" automatique par utilisateur (F136)
- [x] Partage de playlists (lien public, embed) (F143)
- [x] Import/export de playlists (M3U, JSON) (F145)
- [x] Playlist "Favoris" automatique par utilisateur (F136)
**Critères d'acceptation**
- [ ] Deux utilisateurs peuvent éditer la même playlist simultanément (sans conflit)
- [ ] Playlist éditoriale visible dans la section Découverte
- [ ] Export M3U fonctionnel
- [x] Deux utilisateurs peuvent éditer la même playlist simultanément (sans conflit)
- [x] Playlist éditoriale visible dans la section Découverte
- [x] Export M3U fonctionnel
---
@ -1206,7 +1207,7 @@ Toutes les conditions suivantes doivent être remplies avant de taguer v1.0.0 :
| v0.10.1 | Découverte Tags & Genres | P4R | ✅ DONE | 3-4j | v0.10.0 |
| v0.10.2 | Recherche Elasticsearch | P4R | ✅ DONE | 4-5j | v0.10.1 |
| v0.10.3 | Commentaires & Interactions | P4R | ✅ DONE | 3-4j | v0.10.0 |
| v0.10.4 | Playlists Collaboratives | P4R | ⏳ TODO | 3-4j | v0.10.0 |
| v0.10.4 | Playlists Collaboratives | P4R | ✅ DONE | 3-4j | v0.10.0 |
| v0.10.5 | Notifications Complètes | P4R | ⏳ TODO | 2-3j | v0.10.3 |
| v0.10.6 | Livestreaming Basique | P4R | ⏳ TODO | 5-7j | v0.10.0 |
| v0.10.7 | Collaboration Temps Réel | P4R | ⏳ TODO | 5-6j | v0.10.6 |

View file

@ -2,7 +2,7 @@ import React, { useMemo, useState, useEffect } from 'react';
import { useLocation, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Home, Users, Disc, Radio, Settings, LogOut, ShoppingBag,
Home, Users, Disc, Radio, Settings, LogOut, ShoppingBag, Music2,
BarChart2, Shield, Box, MessageSquare,
Layers, Cpu, Heart, ListMusic, CreditCard, DollarSign, Terminal,
ChevronLeft, ChevronRight,
@ -35,6 +35,7 @@ const iconMap: Record<string, React.ReactNode> = {
gear: <Box className="w-4 h-4" />,
analytics: <BarChart2 className="w-4 h-4" />,
social: <Users className="w-4 h-4" />,
feed: <Music2 className="w-4 h-4" />,
marketplace: <ShoppingBag className="w-4 h-4" />,
live: <Radio className="w-4 h-4" />,
chat: <MessageSquare className="w-4 h-4" />,
@ -42,6 +43,7 @@ const iconMap: Record<string, React.ReactNode> = {
wishlist: <Heart className="w-4 h-4" />,
purchases: <CreditCard className="w-4 h-4" />,
playlists: <ListMusic className="w-4 h-4" />,
favoris: <Heart className="w-4 h-4" />,
queue: <Disc className="w-4 h-4" />,
developer: <Terminal className="w-4 h-4" />,
admin: <Shield className="w-4 h-4" />,
@ -53,9 +55,9 @@ const badgeMap: Record<string, number> = { live: 3, chat: 12 };
// Navigation structure definition (ids only, labels resolved via t())
const navStructure: { sectionKey: string; itemIds: string[] }[] = [
{ sectionKey: 'workspace', itemIds: ['dashboard', 'tracks', 'gear', 'analytics'] },
{ sectionKey: 'vezaNetwork', itemIds: ['social', 'marketplace', 'live', 'chat'] },
{ sectionKey: 'vezaNetwork', itemIds: ['social', 'feed', 'marketplace', 'live', 'chat'] },
{ sectionKey: 'commerce', itemIds: ['sell', 'wishlist', 'purchases'] },
{ sectionKey: 'library', itemIds: ['playlists', 'queue'] },
{ sectionKey: 'library', itemIds: ['playlists', 'favoris', 'queue'] },
{ sectionKey: 'system', itemIds: ['developer', 'admin'] },
];
@ -73,10 +75,10 @@ function buildNavItems(t: (key: string) => string): { section: string; items: Na
const routeMap: Record<string, string> = {
dashboard: '/dashboard', tracks: '/library', gear: '/gear',
analytics: '/analytics', social: '/social', marketplace: '/marketplace', live: '/live',
analytics: '/analytics', social: '/social', feed: '/feed', marketplace: '/marketplace', live: '/live',
'go-live': '/live/go-live',
chat: '/chat', sell: '/sell', wishlist: '/wishlist',
purchases: '/purchases', playlists: '/playlists', queue: '/queue', developer: '/developer',
purchases: '/purchases', playlists: '/playlists', favoris: '/playlists/favoris', queue: '/queue', developer: '/developer',
admin: '/admin', settings: '/settings',
};
@ -166,7 +168,6 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView }) => {
'ml-auto text-muted-foreground hover:text-foreground hidden lg:flex hover:bg-sidebar-accent',
!sidebarOpen && 'absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2'
)}
aria-label={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
>
{sidebarOpen ? <ChevronLeft className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</Button>

View file

@ -20,6 +20,7 @@ export {
LazyRoles,
LazyTrackDetail,
LazyPlaylistRoutes,
LazySharedPlaylistPage,
LazyAdminDashboard,
LazyAdminTransfers,
LazyAnalytics,

View file

@ -23,6 +23,7 @@ export {
LazyRoles,
LazyTrackDetail,
LazyPlaylistRoutes,
LazySharedPlaylistPage,
LazyAdminDashboard,
LazyAdminTransfers,
LazyAnalytics,

View file

@ -113,6 +113,14 @@ export const LazyPlaylistRoutes = createLazyComponent(
undefined,
'Playlists',
);
export const LazySharedPlaylistPage = createLazyComponent(
() =>
import('@/features/playlists/pages/SharedPlaylistPage').then((m) => ({
default: m.SharedPlaylistPage,
})),
undefined,
'Shared Playlist',
);
export const LazyAdminDashboard = createLazyComponent(
() =>
import('@/features/admin/pages/AdminDashboardPage').then((m) => ({

View file

@ -12,6 +12,8 @@ import { TrackGrid } from '@/features/tracks/components/TrackGrid';
import { TrackCardSkeleton } from '@/features/tracks/components/TrackCardSkeleton';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { discoverService, type Genre } from '@/services/discoverService';
import { PlaylistCard } from '@/features/playlists/components/PlaylistCard';
import { PlaylistCardSkeleton } from '@/features/playlists/components/PlaylistCardSkeleton';
import { Music2, Loader2, ChevronLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
@ -30,6 +32,11 @@ export function DiscoverPage() {
queryFn: () => discoverService.getGenres(),
});
const { data: editorialData, isLoading: editorialLoading } = useQuery({
queryKey: ['discoverEditorialPlaylists'],
queryFn: () => discoverService.getEditorialPlaylists({ limit: 20 }),
});
const {
data: genreTracksData,
fetchNextPage: fetchNextGenre,
@ -174,6 +181,40 @@ export function DiscoverPage() {
</section>
) : null}
{showGenreList ? (
<section className="space-y-3">
<h2 className="text-lg font-heading font-semibold">
Playlists éditoriales
</h2>
{editorialLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<PlaylistCardSkeleton key={i} />
))}
</div>
) : editorialData?.items && editorialData.items.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{editorialData.items.map((pl) => (
<PlaylistCard
key={pl.id}
playlist={{
id: pl.id,
user_id: pl.user?.id ?? '',
title: pl.title,
description: pl.description,
is_public: true,
track_count: pl.track_count,
cover_url: pl.cover_url,
created_at: '',
updated_at: '',
}}
/>
))}
</div>
) : null}
</section>
) : null}
{browseGenre || browseTag ? (
<>
{isLoadingTracks ? (

View file

@ -0,0 +1,70 @@
/**
* AddToFavorisButton adds a track to the user's Favoris playlist (v0.10.4 F136)
*/
import { Heart } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { playlistsApi } from '@/services/api/playlists';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
interface AddToFavorisButtonProps {
trackId: string;
variant?: 'ghost' | 'default' | 'outline';
size?: 'sm' | 'default' | 'lg';
className?: string;
ariaLabel?: string;
}
export function AddToFavorisButton({
trackId,
variant = 'ghost',
size = 'sm',
className,
ariaLabel = 'Add to Favorites',
}: AddToFavorisButtonProps) {
const { success: showSuccess, error: showError } = useToast();
const queryClient = useQueryClient();
const { data: favorisPlaylist, isLoading } = useQuery({
queryKey: ['playlistFavoris'],
queryFn: () => playlistsApi.getFavoris(),
});
const addMutation = useMutation({
mutationFn: ({ playlistId, tId }: { playlistId: string; tId: string }) =>
playlistsApi.addTrack(playlistId, tId),
onSuccess: () => {
showSuccess('Added to Favorites');
queryClient.invalidateQueries({ queryKey: ['playlist', favorisPlaylist?.id] });
queryClient.invalidateQueries({ queryKey: ['playlistFavoris'] });
},
onError: (err) => {
showError(err instanceof Error ? err.message : 'Failed to add to Favorites');
},
});
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (favorisPlaylist?.id) {
addMutation.mutate({ playlistId: favorisPlaylist.id, tId: trackId });
}
};
if (isLoading || !favorisPlaylist?.id) {
return null;
}
return (
<Button
variant={variant}
size={size}
className={cn('shrink-0', className)}
onClick={handleClick}
disabled={addMutation.isPending}
aria-label={ariaLabel}
>
<Heart className="h-4 w-4" />
</Button>
);
}

View file

@ -29,7 +29,7 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
const [isExporting, setIsExporting] = useState(false);
const { success: showSuccess, error: showError } = useToast();
const handleExport = async (format: 'json' | 'csv') => {
const handleExport = async (format: 'json' | 'csv' | 'm3u') => {
setIsExporting(true);
try {
const url = `/api/v1/playlists/${playlistId}/export/${format}`;
@ -126,6 +126,12 @@ export const ExportPlaylistButton: React.FC<ExportPlaylistButtonProps> = ({
>
Exporter en CSV
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport('m3u')}
disabled={isExporting}
>
Exporter en M3U
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View file

@ -1,14 +1,13 @@
import React, { useState, useRef } from 'react';
import { Upload, FileJson, FileSpreadsheet, Loader2 } from 'lucide-react';
import { Upload, FileJson, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/useToast';
import { TokenStorage } from '@/services/tokenStorage';
import { logger } from '@/utils/logger';
import { useNavigate } from 'react-router-dom';
import { playlistsApi } from '@/services/api/playlists';
interface ImportPlaylistButtonProps {
// FE-TYPE-001: IDs are strings (UUIDs), not numbers
@ -38,34 +37,32 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
const file = event.target.files?.[0];
if (file) {
const fileName = file.name.toLowerCase();
if (!fileName.endsWith('.json') && !fileName.endsWith('.csv')) {
showError('Le fichier doit être au format JSON ou CSV');
if (!fileName.endsWith('.json')) {
showError('Le fichier doit être au format JSON');
return;
}
setSelectedFile(file);
if (fileName.endsWith('.json')) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const data = JSON.parse(content);
if (data.playlist?.title && !title) {
setTitle(data.playlist.title);
}
if (data.playlist?.description && !description) {
setDescription(data.playlist.description);
}
if (data.playlist?.is_public !== undefined) {
setIsPublic(data.playlist.is_public);
}
} catch (error) {
// Ignorer les erreurs de parsing
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const data = JSON.parse(content);
if (data.playlist?.title && !title) {
setTitle(data.playlist.title);
}
};
reader.readAsText(file);
}
if (data.playlist?.description && !description) {
setDescription(data.playlist.description);
}
if (data.playlist?.is_public !== undefined) {
setIsPublic(data.playlist.is_public);
}
} catch {
// Ignorer les erreurs de parsing
}
};
reader.readAsText(file);
}
};
@ -83,41 +80,25 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
setIsImporting(true);
try {
const token = TokenStorage.getAccessToken();
if (!token) {
showError('Vous devez être connecté pour importer une playlist');
return;
}
const content = await selectedFile.text();
const data = JSON.parse(content) as {
playlist?: { title?: string; description?: string; is_public?: boolean };
tracks?: Array<{ id?: string }>;
};
const fileName = selectedFile.name.toLowerCase();
const format = fileName.endsWith('.json') ? 'json' : 'csv';
const url = `/api/v1/playlists/import/${format}`;
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('title', title);
if (description) {
formData.append('description', description);
}
formData.append('is_public', isPublic.toString());
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
const payload = {
playlist: {
title: title.trim(),
description: description.trim() || undefined,
is_public: isPublic,
},
body: formData,
});
tracks: (data.tracks ?? []).map((t) => ({ id: String(t.id ?? '') })).filter((t) => t.id),
};
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Erreur lors de l'import");
}
const result = await response.json();
const playlist = await playlistsApi.import(payload);
showSuccess(
`La playlist a été importée avec ${result.imported_tracks || 0} track(s)`,
`La playlist a été importée avec ${playlist.track_count ?? payload.tracks.length} track(s)`,
);
setSelectedFile(null);
@ -129,11 +110,11 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
}
setIsOpen(false);
if (result.playlist_id) {
if (playlist.id) {
if (onImported) {
onImported(result.playlist_id);
onImported(playlist.id);
} else {
navigate(`/playlists/${result.playlist_id}`);
navigate(`/playlists/${playlist.id}`);
}
}
} catch (error) {
@ -205,12 +186,12 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="file">Fichier (JSON ou CSV)</Label>
<Label htmlFor="file">Fichier (JSON)</Label>
<div className="flex items-center gap-2">
<Input
id="file"
type="file"
accept=".json,.csv"
accept=".json"
onChange={handleFileSelect}
ref={fileInputRef}
disabled={isImporting}
@ -218,11 +199,7 @@ export const ImportPlaylistButton: React.FC<ImportPlaylistButtonProps> = ({
/>
{selectedFile && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
{selectedFile.name.toLowerCase().endsWith('.json') ? (
<FileJson className="h-4 w-4" />
) : (
<FileSpreadsheet className="h-4 w-4" />
)}
<FileJson className="h-4 w-4" />
<span className="max-w-[150px] truncate">
{selectedFile.name}
</span>

View file

@ -7,6 +7,7 @@ import { useState } from 'react';
import { Play, Pause, Music, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import { RemoveTrackButton } from './RemoveTrackButton';
import { AddToFavorisButton } from './AddToFavorisButton';
import type { PlaylistTrack, Track } from '../types';
interface PlaylistTrackItemProps {
@ -21,6 +22,8 @@ interface PlaylistTrackItemProps {
className?: string;
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
canRemoveTracks?: boolean;
/** Hide Add to Favoris when viewing the Favoris playlist (v0.10.4 F136) */
isFavorisPlaylist?: boolean;
}
/**
@ -145,6 +148,9 @@ export function PlaylistTrackItem({
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{!isFavorisPlaylist && isHovered && (
<AddToFavorisButton trackId={track.id} aria-label={`Add ${track.title} to Favorites`} />
)}
{isHovered && canRemoveTracks && onTrackRemoved && (
<RemoveTrackButton
onRemove={onTrackRemoved}

View file

@ -30,6 +30,7 @@ export function PlaylistTrackList({
enableDragAndDrop = true,
canRemoveTracks = true,
isLoading = false,
isFavorisPlaylist = false,
}: PlaylistTrackListProps) {
const {
sortedPlaylistTracks,
@ -91,6 +92,7 @@ export function PlaylistTrackList({
onTrackRemoved={onTrackRemoved}
isPlaying={checkIsPlaying(track.id)}
canRemoveTracks={canRemoveTracks}
isFavorisPlaylist={isFavorisPlaylist}
/>
</div>
);
@ -122,6 +124,7 @@ export function PlaylistTrackList({
onTrackRemoved={onTrackRemoved}
isPlaying={checkIsPlaying(track.id)}
canRemoveTracks={canRemoveTracks}
isFavorisPlaylist={isFavorisPlaylist}
/>
);
})}

View file

@ -17,6 +17,7 @@ interface PlaylistTrackListSortableItemProps {
onTrackRemoved?: () => void;
isPlaying: boolean;
canRemoveTracks?: boolean;
isFavorisPlaylist?: boolean;
}
export function PlaylistTrackListSortableItem({
@ -29,6 +30,7 @@ export function PlaylistTrackListSortableItem({
onTrackRemoved,
isPlaying,
canRemoveTracks,
isFavorisPlaylist = false,
}: PlaylistTrackListSortableItemProps) {
const {
attributes,
@ -66,6 +68,7 @@ export function PlaylistTrackListSortableItem({
isPlaying={isPlaying}
dragHandleProps={{ ...attributes, ...listeners }}
canRemoveTracks={canRemoveTracks}
isFavorisPlaylist={isFavorisPlaylist}
/>
</div>
);

View file

@ -19,6 +19,8 @@ export interface PlaylistTrackListProps {
enableDragAndDrop?: boolean;
canRemoveTracks?: boolean;
isLoading?: boolean;
/** v0.10.4 F136: Hide Add to Favoris when viewing Favoris playlist */
isFavorisPlaylist?: boolean;
}
export type { PlaylistTrack, Track };

View file

@ -0,0 +1,35 @@
/**
* FavorisRedirectPage fetches Favoris playlist and redirects to it (v0.10.4 F136)
*/
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { playlistsApi } from '@/services/api/playlists';
import { Loader2 } from 'lucide-react';
export function FavorisRedirectPage() {
const navigate = useNavigate();
const { data: playlist, isLoading, error } = useQuery({
queryKey: ['playlistFavoris'],
queryFn: () => playlistsApi.getFavoris(),
});
useEffect(() => {
if (playlist?.id) {
navigate(`/playlists/${playlist.id}`, { replace: true });
}
}, [playlist?.id, navigate]);
useEffect(() => {
if (error) {
navigate('/playlists', { replace: true });
}
}, [error, navigate]);
return (
<div className="min-h-layout-page flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-primary" aria-label="Loading Favoris" />
</div>
);
}

View file

@ -9,6 +9,7 @@
import { useState } from 'react';
import { PlaylistList } from '../components/PlaylistList';
import { CreatePlaylistDialog } from '../components/CreatePlaylistDialog';
import { ImportPlaylistButton } from '../components/ImportPlaylistButton';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@ -81,6 +82,7 @@ export function PlaylistListPage() {
<span className="hidden sm:inline">Créer</span>
<span className="sm:hidden">Nouvelle</span>
</Button>
<ImportPlaylistButton className="touch-manipulation min-h-11 sm:min-h-0" />
<Button
variant={enableSelection ? 'default' : 'outline'}
size="sm"

View file

@ -0,0 +1,94 @@
/**
* SharedPlaylistPage public view of a playlist via share token (v0.10.4 F143)
* No auth required; read-only display of playlist and tracks.
*/
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Play, Shuffle, Copy } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { playlistsApi } from '@/services/api/playlists';
import { PlaylistDetailPageHero } from './playlist-detail-page/PlaylistDetailPageHero';
import { PlaylistDetailPageCoverAndInfo } from './playlist-detail-page/PlaylistDetailPageCoverAndInfo';
import { PlaylistDetailPageSkeleton } from './playlist-detail-page/PlaylistDetailPageSkeleton';
import { PlaylistDetailPageNotFound } from './playlist-detail-page/PlaylistDetailPageNotFound';
import { PlaylistTrackList } from '../components/PlaylistTrackList';
import toast from '@/utils/toast';
export function SharedPlaylistPage() {
const { token } = useParams<{ token: string }>();
const { data: playlist, isLoading, error } = useQuery({
queryKey: ['playlistShared', token],
queryFn: () => playlistsApi.getByShareToken(token ?? ''),
enabled: !!token,
});
if (isLoading) {
return <PlaylistDetailPageSkeleton />;
}
if (error || !playlist) {
return <PlaylistDetailPageNotFound />;
}
const playlistTracks = playlist.tracks ?? [];
const tracks = playlistTracks.map((pt) => pt.track).filter(Boolean);
const handleCopyLink = () => {
navigator.clipboard.writeText(window.location.href);
toast.success('Link copied to clipboard');
};
return (
<div className="min-h-layout-page pb-24">
<PlaylistDetailPageHero playlist={playlist} />
<div className="container mx-auto px-4 md:px-8 relative -mt-40 z-10">
<PlaylistDetailPageCoverAndInfo playlist={playlist} />
<div className="mt-8 flex flex-wrap items-center gap-4 mb-8">
<Button
size="lg"
className="rounded-full h-14 px-8 text-lg font-bold shadow-sm transition-all duration-[var(--sumi-duration-normal)] bg-primary text-primary-foreground"
>
<Play className="w-5 h-5 mr-2 fill-current" /> Play All
</Button>
<Button
size="lg"
variant="outline"
className="rounded-full h-14 px-6 border-white/10 hover:bg-white/5 backdrop-blur-sm transition-colors duration-[var(--duration-fast)]"
>
<Shuffle className="w-5 h-5 mr-2" /> Shuffle
</Button>
<div className="flex-1" />
<Button
size="lg"
variant="outline"
className="rounded-full border-white/10 hover:bg-white/5"
onClick={handleCopyLink}
>
<Copy className="w-5 h-5 mr-2" /> Copy link
</Button>
</div>
<Card variant="glass" className="overflow-hidden border-white/5">
<div className="p-4 border-b border-white/5 bg-black/20">
<p className="text-sm text-muted-foreground">
Shared playlist · {tracks.length} track{tracks.length !== 1 ? 's' : ''}
</p>
</div>
<div className="p-0">
<PlaylistTrackList
playlistTracks={playlistTracks}
tracks={tracks}
playlistId={playlist.id}
onTrackRemoved={() => {}}
onTracksReordered={() => {}}
enableDragAndDrop={false}
canRemoveTracks={false}
className="divide-y divide-white/5"
/>
</div>
</Card>
</div>
</div>
);
}

View file

@ -110,6 +110,7 @@ export function PlaylistDetailPageTabs({
onTracksReordered={onTracksReordered}
enableDragAndDrop={permissions.canEdit}
canRemoveTracks={permissions.canRemoveTracks}
isFavorisPlaylist={playlist.is_default_favorites === true}
className="divide-y divide-white/5"
/>
</div>

View file

@ -1,11 +1,13 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { PlaylistListPage } from './pages/PlaylistListPage';
import { PlaylistDetailPage } from './pages/PlaylistDetailPage';
import { FavorisRedirectPage } from './pages/FavorisRedirectPage';
export function PlaylistRoutes() {
return (
<Routes>
<Route path="/" element={<PlaylistListPage />} />
<Route path="/favoris" element={<FavorisRedirectPage />} />
<Route path="/new" element={<Navigate to="/playlists" replace />} />
<Route path="/:id" element={<PlaylistDetailPage />} />
<Route

View file

@ -94,6 +94,19 @@ export async function getPlaylist(id: string): Promise<Playlist> {
});
}
/**
* Récupérer une playlist par token de partage public (v0.10.4 F143).
* No auth required.
*/
export async function getPlaylistByShareToken(token: string): Promise<Playlist> {
return wrapPlaylistError(async () => {
const response = await apiClient.get<{ playlist: Playlist }>(
`/playlists/shared/${token}`,
);
return response.data.playlist;
});
}
/**
* Mettre à jour une playlist
*/
@ -110,6 +123,36 @@ export async function updatePlaylist(
});
}
/**
* Importer une playlist depuis JSON (v0.10.4 F145)
*/
export interface ImportPlaylistPayload {
playlist: { title?: string; description?: string; is_public?: boolean };
tracks: Array<{ id: string }>;
}
export async function importPlaylist(payload: ImportPlaylistPayload): Promise<Playlist> {
return wrapPlaylistError(async () => {
const response = await apiClient.post<{ playlist: Playlist }>(
'/playlists/import',
payload,
);
return response.data.playlist;
});
}
/**
* Récupérer ou créer la playlist Favoris (v0.10.4 F136)
*/
export async function getFavorisPlaylist(): Promise<Playlist> {
return wrapPlaylistError(async () => {
const response = await apiClient.get<{ playlist: Playlist }>(
'/playlists/favoris',
);
return response.data.playlist;
});
}
/**
* Supprimer une playlist
*/

View file

@ -51,6 +51,8 @@ export interface Playlist {
is_following?: boolean;
/** Number of followers (from API enrichment) */
follower_count?: number;
/** v0.10.4 F136: Auto-created Favoris playlist per user */
is_default_favorites?: boolean;
}
/**

View file

@ -512,6 +512,7 @@
"gear": "Gear Locker",
"analytics": "Performance",
"social": "Community Feed",
"feed": "Feed",
"marketplace": "Marketplace",
"live": "Live Sessions",
"chat": "Channels",
@ -519,6 +520,7 @@
"wishlist": "Wishlist",
"purchases": "Purchases",
"playlists": "Playlists",
"favoris": "Favorites",
"queue": "Play Queue",
"developer": "Developer API",
"admin": "Admin Panel"

View file

@ -512,6 +512,7 @@
"gear": "Arsenal",
"analytics": "Performances",
"social": "Communauté",
"feed": "Fil",
"marketplace": "Marketplace",
"live": "Sessions Live",
"chat": "Canaux",
@ -519,6 +520,7 @@
"wishlist": "Liste de souhaits",
"purchases": "Achats",
"playlists": "Playlists",
"favoris": "Favoris",
"queue": "File de lecture",
"developer": "API Développeur",
"admin": "Admin"

View file

@ -77,4 +77,35 @@ export const handlersDiscover = [
http.delete('*/api/v1/discover/tag/:tag/follow', () => {
return HttpResponse.json({ success: true, data: { followed: false } });
}),
http.get('*/api/v1/discover/playlists/editorial', () => {
return HttpResponse.json({
success: true,
data: {
items: [
{
id: 'pl-editorial-1',
title: 'Curated Picks',
description: 'Editorial selection',
track_count: 12,
cover_url: 'https://picsum.photos/seed/ed1/400',
user: { id: 'user-1', username: 'Veza' },
is_editorial: true,
is_public: true,
},
{
id: 'pl-editorial-2',
title: 'New Releases',
description: 'Fresh tracks',
track_count: 8,
cover_url: 'https://picsum.photos/seed/ed2/400',
user: { id: 'user-1', username: 'Veza' },
is_editorial: true,
is_public: true,
},
],
next_cursor: undefined,
},
});
}),
];

View file

@ -19,6 +19,29 @@ export const handlersPlaylists = [
});
}),
http.post('*/api/v1/playlists/import', async ({ request }) => {
const body = (await request.json()) as { playlist?: { title?: string }; tracks?: unknown[] };
const title = body?.playlist?.title ?? 'Imported Playlist';
const trackCount = body?.tracks?.length ?? 0;
return HttpResponse.json({
success: true,
data: {
playlist: {
id: 'pl-imported-1',
name: title,
title,
track_count: trackCount,
like_count: 0,
user_id: 'user-1',
description: '',
is_public: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
},
});
}),
http.post('*/api/v1/playlists', async ({ request }) => {
const body = (await request.json()) as { title?: string };
const title = body?.title ?? 'New Playlist';
@ -74,6 +97,63 @@ export const handlersPlaylists = [
});
}),
http.get('*/api/v1/playlists/favoris', () => {
return HttpResponse.json({
success: true,
data: {
playlist: {
id: 'pl-favoris-1',
user_id: 'user-1',
name: 'Favoris',
title: 'Favoris',
description: '',
is_public: false,
track_count: 0,
is_default_favorites: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
},
});
}),
http.get('*/api/v1/playlists/shared/:token', ({ params }) => {
return HttpResponse.json({
success: true,
data: {
playlist: {
id: 'pl-shared-1',
user_id: 'user-1',
name: 'Shared Playlist',
title: 'Shared Playlist',
description: 'A shared playlist',
is_public: true,
track_count: 2,
tracks: [
{
id: 'pt-1',
playlist_id: 'pl-shared-1',
track_id: 'tr-1',
position: 1,
added_at: new Date().toISOString(),
track: { id: 'tr-1', title: 'Track One', duration_ms: 180000 },
},
{
id: 'pt-2',
playlist_id: 'pl-shared-1',
track_id: 'tr-2',
position: 2,
added_at: new Date().toISOString(),
track: { id: 'tr-2', title: 'Track Two', duration_ms: 240000 },
},
],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
},
});
}),
http.get('*/api/v1/playlists/:id', ({ params }) => {
return HttpResponse.json({
success: true,

View file

@ -2,6 +2,7 @@ import React from 'react';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import {
LazySharedPlaylistPage,
LazyLogin,
LazyRegister,
LazyForgotPassword,
@ -45,6 +46,7 @@ import {
} from '@/components/ui/LazyComponent';
import { PublicRoute } from './PublicRoute';
import { ProtectedLayoutRoute } from './ProtectedLayoutRoute';
import { DashboardLayout } from '@/components/layout/DashboardLayout';
import type { RouteEntry } from './types';
function wrapPublic(element: React.ReactNode): React.ReactNode {
@ -79,6 +81,16 @@ export function getPublicStandaloneRoutes(): RouteEntry[] {
return [
{ path: '/design-system', element: <ErrorBoundary><LazyDesignSystemDemo /></ErrorBoundary> },
{ path: '/u/:username', element: <ErrorBoundary><LazyUserProfile /></ErrorBoundary> },
{
path: '/playlists/shared/:token',
element: (
<ErrorBoundary>
<DashboardLayout>
<LazySharedPlaylistPage />
</DashboardLayout>
</ErrorBoundary>
),
},
];
}

View file

@ -8,6 +8,9 @@
import {
createPlaylist,
getPlaylist,
getPlaylistByShareToken,
getFavorisPlaylist,
importPlaylist,
updatePlaylist,
deletePlaylist,
listPlaylists,
@ -56,6 +59,21 @@ export const playlistsApi = {
*/
get: getPlaylist,
/**
* Get a playlist by public share token (no auth, v0.10.4 F143)
*/
getByShareToken: getPlaylistByShareToken,
/**
* Get or create Favoris playlist (v0.10.4 F136)
*/
getFavoris: getFavorisPlaylist,
/**
* Import a playlist from JSON (v0.10.4 F145)
*/
import: importPlaylist,
/**
* Update a playlist
*/

View file

@ -110,4 +110,22 @@ export const discoverService = {
`/discover/tag/${encodeURIComponent(tag)}/follow`
);
},
/** v0.10.4 F141: Editorial playlists for Discover section */
getEditorialPlaylists: async (params?: {
cursor?: string;
limit?: number;
}): Promise<{ items: Array<{ id: string; title: string; description?: string; cover_url?: string; track_count: number; user?: { id: string; username: string } }>; next_cursor?: string }> => {
const response = await apiClient.get<{
items?: Array<{ id: string; title: string; description?: string; cover_url?: string; track_count: number; user?: { id: string; username: string } }>;
next_cursor?: string;
}>('/discover/playlists/editorial', {
params: { limit: params?.limit ?? 20, cursor: params?.cursor },
});
const d = response.data as { items?: unknown[]; next_cursor?: string };
return {
items: (d?.items ?? []) as Array<{ id: string; title: string; description?: string; cover_url?: string; track_count: number; user?: { id: string; username: string } }>,
next_cursor: d?.next_cursor,
};
},
};

View file

@ -15,6 +15,7 @@ func (r *APIRouter) setupDiscoverRoutes(router *gin.RouterGroup) {
router.GET("/discover/genres", discoverHandler.ListGenres)
router.GET("/discover/genre/:genre", discoverHandler.GetTracksByGenre)
router.GET("/discover/tag/:tag", discoverHandler.GetTracksByTag)
router.GET("/discover/playlists/editorial", discoverHandler.GetEditorialPlaylists) // v0.10.4 F141
// Auth-required: follow/unfollow
if r.config.AuthMiddleware != nil {

View file

@ -37,14 +37,18 @@ func (r *APIRouter) setupPlaylistRoutes(router *gin.RouterGroup) {
playlistHandler.SetPlaylistAnalyticsService(playlistAnalyticsService)
playlists := router.Group("/playlists")
// v0.10.4 F143: Public route - no auth required
playlists.GET("/shared/:token", playlistHandler.GetPlaylistByShareToken)
if r.config.AuthMiddleware != nil {
playlists.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(playlists)
{
playlists.GET("", playlistHandler.GetPlaylists)
playlists.POST("", playlistHandler.CreatePlaylist)
playlists.POST("/import", playlistHandler.ImportPlaylist) // v0.10.4 F145
playlists.GET("/search", playlistHandler.SearchPlaylists)
playlists.GET("/recommendations", playlistHandler.GetRecommendations)
playlists.GET("/favoris", playlistHandler.GetFavorisPlaylist) // v0.10.4 F136
playlists.GET("/:id", playlistHandler.GetPlaylist)
playlists.GET("/:id/analytics", playlistHandler.GetPlaylistStats)
@ -77,6 +81,7 @@ func (r *APIRouter) setupPlaylistRoutes(router *gin.RouterGroup) {
exportHandler := handlers.NewPlaylistExportHandler(playlistService)
playlists.GET("/:id/export/json", exportHandler.ExportPlaylistJSON)
playlists.GET("/:id/export/csv", exportHandler.ExportPlaylistCSV)
playlists.GET("/:id/export/m3u", exportHandler.ExportPlaylistM3U) // v0.10.4 F145
playlists.POST("/:id/duplicate", playlistHandler.DuplicatePlaylist)
}

View file

@ -88,6 +88,23 @@ func (h *Handler) GetTracksByTag(c *gin.Context) {
handlers.RespondSuccess(c, http.StatusOK, resp)
}
// GetEditorialPlaylists GET /api/v1/discover/playlists/editorial (v0.10.4 F141)
func (h *Handler) GetEditorialPlaylists(c *gin.Context) {
limit, cursor := parseLimitCursor(c)
playlists, nextCursor, err := h.service.GetEditorialPlaylists(c.Request.Context(), limit, cursor)
if err != nil {
handlers.RespondWithAppError(c, apperrors.NewInternalErrorWrap("failed to get editorial playlists", err))
return
}
resp := gin.H{"items": playlists}
if nextCursor != "" {
resp["next_cursor"] = nextCursor
}
handlers.RespondSuccess(c, http.StatusOK, resp)
}
// ListGenres GET /api/v1/discover/genres
func (h *Handler) ListGenres(c *gin.Context) {
genres, err := h.service.ListGenres(c.Request.Context())

View file

@ -345,6 +345,56 @@ func (s *Service) IsFollowingTag(ctx context.Context, userID uuid.UUID, tagName
return count > 0, err
}
// GetEditorialPlaylists F141 v0.10.4: playlists curatoriales visibles dans Discover
func (s *Service) GetEditorialPlaylists(ctx context.Context, limit int, cursor string) ([]*models.Playlist, string, error) {
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
query := s.db.WithContext(ctx).Model(&models.Playlist{}).
Where("is_editorial = ?", true).
Where("is_public = ?", true).
Where("deleted_at IS NULL")
var cursorCreatedAt int64
var cursorID uuid.UUID
if cursor != "" {
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
if err == nil {
parts := strings.SplitN(string(decoded), "|", 2)
if len(parts) == 2 {
if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
cursorCreatedAt = ts
}
if uid, err := uuid.Parse(parts[1]); err == nil {
cursorID = uid
}
}
}
}
if cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
query = query.Where("(playlists.created_at, playlists.id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
}
query = query.Order("playlists.created_at DESC, playlists.id DESC").Limit(limit + 1)
var playlists []*models.Playlist
if err := query.Preload("User").Find(&playlists).Error; err != nil {
return nil, "", fmt.Errorf("get editorial playlists: %w", err)
}
var nextCursor string
if len(playlists) > limit {
last := playlists[limit-1]
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
playlists = playlists[:limit]
}
return playlists, nextCursor, nil
}
// GetTracksFromFollowedGenres F355: nouveautés dans les genres suivis
func (s *Service) GetTracksFromFollowedGenres(ctx context.Context, userID uuid.UUID, limit int, cursor string) ([]*models.Track, string, error) {
if limit <= 0 {

View file

@ -127,6 +127,85 @@ func (h *PlaylistHandler) CreatePlaylist(c *gin.Context) {
RespondSuccess(c, http.StatusCreated, gin.H{"playlist": playlist})
}
// ImportPlaylistRequest represents JSON import payload (v0.10.4 F145)
type ImportPlaylistRequest struct {
Playlist struct {
Title string `json:"title"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
} `json:"playlist"`
Tracks []struct {
ID string `json:"id"`
} `json:"tracks"`
}
// ImportPlaylist gère l'import d'une playlist depuis JSON (v0.10.4 F145)
func (h *PlaylistHandler) ImportPlaylist(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
var req ImportPlaylistRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
title := req.Playlist.Title
if title != "" {
title = utils.SanitizeText(title, 200)
} else {
title = "Imported Playlist"
}
description := req.Playlist.Description
if description != "" {
description = utils.SanitizeText(description, 1000)
}
trackIDs := make([]uuid.UUID, 0, len(req.Tracks))
for _, t := range req.Tracks {
if t.ID == "" {
continue
}
id, err := uuid.Parse(t.ID)
if err != nil {
continue
}
trackIDs = append(trackIDs, id)
}
ctx, cancel := WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
playlist, err := h.playlistService.ImportPlaylistWithTracks(ctx, userID, title, description, req.Playlist.IsPublic, trackIDs)
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to import playlist", err))
return
}
RespondSuccess(c, http.StatusCreated, gin.H{"playlist": playlist})
}
// GetFavorisPlaylist returns the current user's Favoris playlist, creating it if needed (v0.10.4 F136)
func (h *PlaylistHandler) GetFavorisPlaylist(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
playlist, err := h.playlistService.GetOrCreateFavorisPlaylist(ctx, userID)
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get Favoris playlist", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"playlist": playlist})
}
// GetPlaylists gère la récupération des playlists avec pagination
// @Summary Get Playlists
// @Description Get a paginated list of playlists
@ -235,6 +314,28 @@ func (h *PlaylistHandler) GetPlaylist(c *gin.Context) {
RespondSuccess(c, http.StatusOK, playlist)
}
// GetPlaylistByShareToken returns a playlist by its public share token (v0.10.4 F143).
// No authentication required.
func (h *PlaylistHandler) GetPlaylistByShareToken(c *gin.Context) {
token := c.Param("token")
if token == "" {
RespondWithAppError(c, apperrors.NewValidationError("share token is required"))
return
}
ctx, cancel := WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
playlist, err := h.playlistService.GetPlaylistByShareToken(ctx, token)
if err != nil {
if errors.Is(err, services.ErrPlaylistNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("playlist"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get playlist", err))
return
}
RespondSuccess(c, http.StatusOK, playlist)
}
// UpdatePlaylist gère la mise à jour d'une playlist
// @Summary Update Playlist
// @Description Update playlist metadata

View file

@ -111,6 +111,30 @@ func (m *MockPlaylistService) CreateShareLink(ctx context.Context, playlistID, u
return args.Get(0).(*models.PlaylistShareLink), args.Error(1)
}
func (m *MockPlaylistService) GetPlaylistByShareToken(ctx context.Context, token string) (*models.Playlist, error) {
args := m.Called(ctx, token)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.Playlist), args.Error(1)
}
func (m *MockPlaylistService) ImportPlaylistWithTracks(ctx context.Context, userID uuid.UUID, title, description string, isPublic bool, trackIDs []uuid.UUID) (*models.Playlist, error) {
args := m.Called(ctx, userID, title, description, isPublic, trackIDs)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.Playlist), args.Error(1)
}
func (m *MockPlaylistService) GetOrCreateFavorisPlaylist(ctx context.Context, userID uuid.UUID) (*models.Playlist, error) {
args := m.Called(ctx, userID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.Playlist), args.Error(1)
}
func (m *MockPlaylistService) FollowPlaylist(ctx context.Context, playlistID, userID uuid.UUID) error {
args := m.Called(ctx, playlistID, userID)
return args.Error(0)

View file

@ -40,6 +40,9 @@ type PlaylistRepository interface {
// Search recherche des playlists selon des critères
// T0496: Create Playlist Search Backend
Search(ctx context.Context, query string, filterUserID *uuid.UUID, isPublic *bool, limit, offset int) ([]*models.Playlist, int64, error)
// GetFavorisByUserID returns the user's Favoris playlist (is_default_favorites=true). v0.10.4 F136
GetFavorisByUserID(ctx context.Context, userID uuid.UUID) (*models.Playlist, error)
}
// playlistRepository implémente PlaylistRepository avec GORM
@ -235,3 +238,14 @@ func (r *playlistRepository) Search(ctx context.Context, query string, filterUse
return playlists, total, nil
}
// GetFavorisByUserID returns the user's Favoris playlist (is_default_favorites=true). v0.10.4 F136
func (r *playlistRepository) GetFavorisByUserID(ctx context.Context, userID uuid.UUID) (*models.Playlist, error) {
var playlist models.Playlist
if err := r.db.WithContext(ctx).
Where("user_id = ? AND is_default_favorites = ?", userID, true).
First(&playlist).Error; err != nil {
return nil, err
}
return &playlist, nil
}

View file

@ -25,6 +25,9 @@ type PlaylistServiceInterface interface {
UpdateCollaboratorPermission(ctx context.Context, playlistID, userID, collaboratorUserID uuid.UUID, permission models.PlaylistPermission) error
GetCollaborators(ctx context.Context, playlistID, userID uuid.UUID) ([]*models.PlaylistCollaborator, error)
CreateShareLink(ctx context.Context, playlistID, userID uuid.UUID, expiresAt *time.Time) (*models.PlaylistShareLink, error)
GetPlaylistByShareToken(ctx context.Context, token string) (*models.Playlist, error) // v0.10.4 F143
ImportPlaylistWithTracks(ctx context.Context, userID uuid.UUID, title, description string, isPublic bool, trackIDs []uuid.UUID) (*models.Playlist, error) // v0.10.4 F145
GetOrCreateFavorisPlaylist(ctx context.Context, userID uuid.UUID) (*models.Playlist, error) // v0.10.4 F136
FollowPlaylist(ctx context.Context, playlistID, userID uuid.UUID) error
UnfollowPlaylist(ctx context.Context, playlistID, userID uuid.UUID) error
CheckPermission(ctx context.Context, playlistID, userID uuid.UUID, permission models.PlaylistPermission) (bool, error)

View file

@ -884,6 +884,107 @@ func (s *PlaylistService) CreateShareLink(ctx context.Context, playlistID uuid.U
return shareLink, nil
}
// GetPlaylistByShareToken returns a playlist by its public share token (v0.10.4 F143).
// No auth required; valid token grants access even to private playlists.
func (s *PlaylistService) GetPlaylistByShareToken(ctx context.Context, token string) (*models.Playlist, error) {
if s.playlistShareService == nil {
return nil, errors.New("playlist share service not initialized")
}
shareLink, err := s.playlistShareService.ValidateShareToken(ctx, token)
if err != nil {
if errors.Is(err, ErrPlaylistShareNotFound) || errors.Is(err, ErrPlaylistShareExpired) {
return nil, ErrPlaylistNotFound
}
return nil, err
}
// Bypass privacy check: valid share token grants access
playlist, err := s.playlistRepo.GetByIDWithTracks(ctx, shareLink.PlaylistID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrPlaylistNotFound
}
return nil, fmt.Errorf("failed to get playlist: %w", err)
}
return playlist, nil
}
// ImportPlaylistRequest represents JSON import payload (v0.10.4 F145)
type ImportPlaylistRequest struct {
Playlist struct {
Title string `json:"title"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
} `json:"playlist"`
Tracks []struct {
ID string `json:"id"`
} `json:"tracks"`
}
// ImportPlaylist creates a playlist from imported data (v0.10.4 F145)
func (s *PlaylistService) ImportPlaylist(ctx context.Context, userID uuid.UUID, req *ImportPlaylistRequest) (*models.Playlist, error) {
if req == nil {
return nil, errors.New("import request is required")
}
title := req.Playlist.Title
if title == "" {
title = "Imported Playlist"
}
trackIDs := make([]uuid.UUID, 0, len(req.Tracks))
for _, t := range req.Tracks {
if t.ID == "" {
continue
}
id, err := uuid.Parse(t.ID)
if err != nil {
s.logger.Warn("Import: invalid track id skipped", zap.String("id", t.ID))
continue
}
trackIDs = append(trackIDs, id)
}
return s.ImportPlaylistWithTracks(ctx, userID, title, req.Playlist.Description, req.Playlist.IsPublic, trackIDs)
}
// ImportPlaylistWithTracks creates a playlist and adds tracks (v0.10.4 F145).
// Accepts parsed track IDs for handlers that decode JSON themselves.
func (s *PlaylistService) ImportPlaylistWithTracks(ctx context.Context, userID uuid.UUID, title, description string, isPublic bool, trackIDs []uuid.UUID) (*models.Playlist, error) {
if title == "" {
title = "Imported Playlist"
}
playlist, err := s.CreatePlaylist(ctx, userID, title, description, isPublic)
if err != nil {
return nil, fmt.Errorf("create playlist: %w", err)
}
for i, trackID := range trackIDs {
if err := s.AddTrackToPlaylist(ctx, playlist.ID, trackID, userID, i+1); err != nil {
s.logger.Warn("Import: failed to add track", zap.String("track_id", trackID.String()), zap.Error(err))
}
}
return s.playlistRepo.GetByIDWithTracks(ctx, playlist.ID)
}
// GetOrCreateFavorisPlaylist returns the user's Favoris playlist, creating it if needed (v0.10.4 F136)
func (s *PlaylistService) GetOrCreateFavorisPlaylist(ctx context.Context, userID uuid.UUID) (*models.Playlist, error) {
existing, err := s.playlistRepo.GetFavorisByUserID(ctx, userID)
if err == nil {
return s.playlistRepo.GetByIDWithTracks(ctx, existing.ID)
}
if err != gorm.ErrRecordNotFound {
return nil, fmt.Errorf("failed to get favoris playlist: %w", err)
}
// Create Favoris playlist
playlist := &models.Playlist{
UserID: userID,
Title: "Favoris",
IsDefaultFavorites: true,
IsPublic: false,
}
if err := s.playlistRepo.Create(ctx, playlist); err != nil {
return nil, fmt.Errorf("failed to create favoris playlist: %w", err)
}
s.logger.Info("Favoris playlist created", zap.String("user_id", userID.String()), zap.String("playlist_id", playlist.ID.String()))
return s.playlistRepo.GetByIDWithTracks(ctx, playlist.ID)
}
// FollowPlaylist permet à un utilisateur de suivre une playlist
// T0489: Create Playlist Follow Feature
// MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID