diff --git a/VEZA_VERSIONS_ROADMAP.md b/VEZA_VERSIONS_ROADMAP.md index 9d35edd58..262638c73 100644 --- a/VEZA_VERSIONS_ROADMAP.md +++ b/VEZA_VERSIONS_ROADMAP.md @@ -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 | diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index a2a1223f7..a720f4ca6 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -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 = { gear: , analytics: , social: , + feed: , marketplace: , live: , chat: , @@ -42,6 +43,7 @@ const iconMap: Record = { wishlist: , purchases: , playlists: , + favoris: , queue: , developer: , admin: , @@ -53,9 +55,9 @@ const badgeMap: Record = { 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 = { 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 = ({ 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 ? : } diff --git a/apps/web/src/components/ui/LazyComponent.tsx b/apps/web/src/components/ui/LazyComponent.tsx index 2e5e19aff..14d80c27b 100644 --- a/apps/web/src/components/ui/LazyComponent.tsx +++ b/apps/web/src/components/ui/LazyComponent.tsx @@ -20,6 +20,7 @@ export { LazyRoles, LazyTrackDetail, LazyPlaylistRoutes, + LazySharedPlaylistPage, LazyAdminDashboard, LazyAdminTransfers, LazyAnalytics, diff --git a/apps/web/src/components/ui/lazy-component/index.ts b/apps/web/src/components/ui/lazy-component/index.ts index ff9066cfc..cf930a29f 100644 --- a/apps/web/src/components/ui/lazy-component/index.ts +++ b/apps/web/src/components/ui/lazy-component/index.ts @@ -23,6 +23,7 @@ export { LazyRoles, LazyTrackDetail, LazyPlaylistRoutes, + LazySharedPlaylistPage, LazyAdminDashboard, LazyAdminTransfers, LazyAnalytics, diff --git a/apps/web/src/components/ui/lazy-component/lazyExports.ts b/apps/web/src/components/ui/lazy-component/lazyExports.ts index 190b50965..a273a1287 100644 --- a/apps/web/src/components/ui/lazy-component/lazyExports.ts +++ b/apps/web/src/components/ui/lazy-component/lazyExports.ts @@ -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) => ({ diff --git a/apps/web/src/features/discover/pages/DiscoverPage.tsx b/apps/web/src/features/discover/pages/DiscoverPage.tsx index 64e5d960a..b694e96ec 100644 --- a/apps/web/src/features/discover/pages/DiscoverPage.tsx +++ b/apps/web/src/features/discover/pages/DiscoverPage.tsx @@ -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() { ) : null} + {showGenreList ? ( +
+

+ Playlists éditoriales +

+ {editorialLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : editorialData?.items && editorialData.items.length > 0 ? ( +
+ {editorialData.items.map((pl) => ( + + ))} +
+ ) : null} +
+ ) : null} + {browseGenre || browseTag ? ( <> {isLoadingTracks ? ( diff --git a/apps/web/src/features/playlists/components/AddToFavorisButton.tsx b/apps/web/src/features/playlists/components/AddToFavorisButton.tsx new file mode 100644 index 000000000..38e4b33fe --- /dev/null +++ b/apps/web/src/features/playlists/components/AddToFavorisButton.tsx @@ -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 ( + + ); +} diff --git a/apps/web/src/features/playlists/components/ExportPlaylistButton.tsx b/apps/web/src/features/playlists/components/ExportPlaylistButton.tsx index 2aba729da..6b5711710 100644 --- a/apps/web/src/features/playlists/components/ExportPlaylistButton.tsx +++ b/apps/web/src/features/playlists/components/ExportPlaylistButton.tsx @@ -29,7 +29,7 @@ export const ExportPlaylistButton: React.FC = ({ 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 = ({ > Exporter en CSV + handleExport('m3u')} + disabled={isExporting} + > + Exporter en M3U + ); diff --git a/apps/web/src/features/playlists/components/ImportPlaylistButton.tsx b/apps/web/src/features/playlists/components/ImportPlaylistButton.tsx index 745b0f09d..039223e3a 100644 --- a/apps/web/src/features/playlists/components/ImportPlaylistButton.tsx +++ b/apps/web/src/features/playlists/components/ImportPlaylistButton.tsx @@ -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 = ({ 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 = ({ 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 = ({ } 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 = ({ >
- +
= ({ /> {selectedFile && (
- {selectedFile.name.toLowerCase().endsWith('.json') ? ( - - ) : ( - - )} + {selectedFile.name} diff --git a/apps/web/src/features/playlists/components/PlaylistTrackItem.tsx b/apps/web/src/features/playlists/components/PlaylistTrackItem.tsx index 3a0c5fa76..5ac9e91ab 100644 --- a/apps/web/src/features/playlists/components/PlaylistTrackItem.tsx +++ b/apps/web/src/features/playlists/components/PlaylistTrackItem.tsx @@ -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; 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 */}
+ {!isFavorisPlaylist && isHovered && ( + + )} {isHovered && canRemoveTracks && onTrackRemoved && (
); @@ -122,6 +124,7 @@ export function PlaylistTrackList({ onTrackRemoved={onTrackRemoved} isPlaying={checkIsPlaying(track.id)} canRemoveTracks={canRemoveTracks} + isFavorisPlaylist={isFavorisPlaylist} /> ); })} diff --git a/apps/web/src/features/playlists/components/playlist-track-list/PlaylistTrackListSortableItem.tsx b/apps/web/src/features/playlists/components/playlist-track-list/PlaylistTrackListSortableItem.tsx index 74d7ef7d5..ae3949f30 100644 --- a/apps/web/src/features/playlists/components/playlist-track-list/PlaylistTrackListSortableItem.tsx +++ b/apps/web/src/features/playlists/components/playlist-track-list/PlaylistTrackListSortableItem.tsx @@ -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} />
); diff --git a/apps/web/src/features/playlists/components/playlist-track-list/types.ts b/apps/web/src/features/playlists/components/playlist-track-list/types.ts index 7c38723d8..9e109cacc 100644 --- a/apps/web/src/features/playlists/components/playlist-track-list/types.ts +++ b/apps/web/src/features/playlists/components/playlist-track-list/types.ts @@ -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 }; diff --git a/apps/web/src/features/playlists/pages/FavorisRedirectPage.tsx b/apps/web/src/features/playlists/pages/FavorisRedirectPage.tsx new file mode 100644 index 000000000..baf7ba5bf --- /dev/null +++ b/apps/web/src/features/playlists/pages/FavorisRedirectPage.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/apps/web/src/features/playlists/pages/PlaylistListPage.tsx b/apps/web/src/features/playlists/pages/PlaylistListPage.tsx index a51638afa..ddda99b33 100644 --- a/apps/web/src/features/playlists/pages/PlaylistListPage.tsx +++ b/apps/web/src/features/playlists/pages/PlaylistListPage.tsx @@ -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() { Créer Nouvelle + + +
+ +
+ +
+

+ Shared playlist · {tracks.length} track{tracks.length !== 1 ? 's' : ''} +

+
+
+ {}} + onTracksReordered={() => {}} + enableDragAndDrop={false} + canRemoveTracks={false} + className="divide-y divide-white/5" + /> +
+
+
+
+ ); +} diff --git a/apps/web/src/features/playlists/pages/playlist-detail-page/PlaylistDetailPageTabs.tsx b/apps/web/src/features/playlists/pages/playlist-detail-page/PlaylistDetailPageTabs.tsx index c8af1b990..137807683 100644 --- a/apps/web/src/features/playlists/pages/playlist-detail-page/PlaylistDetailPageTabs.tsx +++ b/apps/web/src/features/playlists/pages/playlist-detail-page/PlaylistDetailPageTabs.tsx @@ -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" />
diff --git a/apps/web/src/features/playlists/routes.tsx b/apps/web/src/features/playlists/routes.tsx index 771b4d370..e6f383e74 100644 --- a/apps/web/src/features/playlists/routes.tsx +++ b/apps/web/src/features/playlists/routes.tsx @@ -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 ( } /> + } /> } /> } /> { }); } +/** + * Récupérer une playlist par token de partage public (v0.10.4 F143). + * No auth required. + */ +export async function getPlaylistByShareToken(token: string): Promise { + 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 { + 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 { + return wrapPlaylistError(async () => { + const response = await apiClient.get<{ playlist: Playlist }>( + '/playlists/favoris', + ); + return response.data.playlist; + }); +} + /** * Supprimer une playlist */ diff --git a/apps/web/src/features/playlists/types.ts b/apps/web/src/features/playlists/types.ts index 3ae0fabb1..0a7209f9a 100644 --- a/apps/web/src/features/playlists/types.ts +++ b/apps/web/src/features/playlists/types.ts @@ -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; } /** diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index 24128f29f..d5437aee0 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -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" diff --git a/apps/web/src/locales/fr.json b/apps/web/src/locales/fr.json index c5920bb3c..a6ef4494f 100644 --- a/apps/web/src/locales/fr.json +++ b/apps/web/src/locales/fr.json @@ -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" diff --git a/apps/web/src/mocks/handlers-discover.ts b/apps/web/src/mocks/handlers-discover.ts index 8ab245e57..6c7f803cb 100644 --- a/apps/web/src/mocks/handlers-discover.ts +++ b/apps/web/src/mocks/handlers-discover.ts @@ -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, + }, + }); + }), ]; diff --git a/apps/web/src/mocks/handlers-playlists.ts b/apps/web/src/mocks/handlers-playlists.ts index a10a2b838..12009a795 100644 --- a/apps/web/src/mocks/handlers-playlists.ts +++ b/apps/web/src/mocks/handlers-playlists.ts @@ -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, diff --git a/apps/web/src/router/routeConfig.tsx b/apps/web/src/router/routeConfig.tsx index ed0b011d6..db23832d6 100644 --- a/apps/web/src/router/routeConfig.tsx +++ b/apps/web/src/router/routeConfig.tsx @@ -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: }, { path: '/u/:username', element: }, + { + path: '/playlists/shared/:token', + element: ( + + + + + + ), + }, ]; } diff --git a/apps/web/src/services/api/playlists.ts b/apps/web/src/services/api/playlists.ts index 71cb2fe97..eda6ff2ef 100644 --- a/apps/web/src/services/api/playlists.ts +++ b/apps/web/src/services/api/playlists.ts @@ -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 */ diff --git a/apps/web/src/services/discoverService.ts b/apps/web/src/services/discoverService.ts index c170267b3..745799f85 100644 --- a/apps/web/src/services/discoverService.ts +++ b/apps/web/src/services/discoverService.ts @@ -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, + }; + }, }; diff --git a/veza-backend-api/internal/api/routes_discover.go b/veza-backend-api/internal/api/routes_discover.go index 03e9a320c..17efeca48 100644 --- a/veza-backend-api/internal/api/routes_discover.go +++ b/veza-backend-api/internal/api/routes_discover.go @@ -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 { diff --git a/veza-backend-api/internal/api/routes_playlists.go b/veza-backend-api/internal/api/routes_playlists.go index c94c3106f..9e0b50ca1 100644 --- a/veza-backend-api/internal/api/routes_playlists.go +++ b/veza-backend-api/internal/api/routes_playlists.go @@ -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) } diff --git a/veza-backend-api/internal/core/discover/handler.go b/veza-backend-api/internal/core/discover/handler.go index 0c61432ab..9f3d8f084 100644 --- a/veza-backend-api/internal/core/discover/handler.go +++ b/veza-backend-api/internal/core/discover/handler.go @@ -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()) diff --git a/veza-backend-api/internal/core/discover/service.go b/veza-backend-api/internal/core/discover/service.go index 1c9395ad5..eb3a2e493 100644 --- a/veza-backend-api/internal/core/discover/service.go +++ b/veza-backend-api/internal/core/discover/service.go @@ -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 { diff --git a/veza-backend-api/internal/handlers/playlist_handler.go b/veza-backend-api/internal/handlers/playlist_handler.go index 6c0b8f578..2267cccc8 100644 --- a/veza-backend-api/internal/handlers/playlist_handler.go +++ b/veza-backend-api/internal/handlers/playlist_handler.go @@ -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 diff --git a/veza-backend-api/internal/handlers/playlist_handler_test.go b/veza-backend-api/internal/handlers/playlist_handler_test.go index 8ace6d40a..a20b37929 100644 --- a/veza-backend-api/internal/handlers/playlist_handler_test.go +++ b/veza-backend-api/internal/handlers/playlist_handler_test.go @@ -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) diff --git a/veza-backend-api/internal/repositories/playlist_repository.go b/veza-backend-api/internal/repositories/playlist_repository.go index 0c4a15f58..56ca80352 100644 --- a/veza-backend-api/internal/repositories/playlist_repository.go +++ b/veza-backend-api/internal/repositories/playlist_repository.go @@ -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 +} diff --git a/veza-backend-api/internal/services/interfaces.go b/veza-backend-api/internal/services/interfaces.go index a0adf3bb1..75bc50ad3 100644 --- a/veza-backend-api/internal/services/interfaces.go +++ b/veza-backend-api/internal/services/interfaces.go @@ -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) diff --git a/veza-backend-api/internal/services/playlist_service.go b/veza-backend-api/internal/services/playlist_service.go index d13d3204b..d4663c703 100644 --- a/veza-backend-api/internal/services/playlist_service.go +++ b/veza-backend-api/internal/services/playlist_service.go @@ -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