diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..5f2c087 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:pixabay.com)" + ] + } +} diff --git a/_bmad-output/planning-artifacts/prd.md b/_bmad-output/planning-artifacts/prd.md index 843ce0b..c5e7507 100644 --- a/_bmad-output/planning-artifacts/prd.md +++ b/_bmad-output/planning-artifacts/prd.md @@ -505,6 +505,230 @@ CREATE TABLE instagram_accounts ( --- +## Evolution REEL - Amélioration Qualité Vidéo & Musique + +### Contexte + +Suite à l'implémentation initiale des Reels Facebook, deux axes d'amélioration majeurs ont été identifiés : +1. **Qualité vidéo** : Stabilisation et optimisation pour Facebook +2. **Système musique** : Remplacement de FreeSound par un vrai catalogue musical avec favoris + +### Phase 1 : Amélioration Qualité Vidéo & Stabilisation + +#### Objectif +Produire des Reels de qualité professionnelle optimisés pour Facebook avec stabilisation avancée et texte style TikTok. + +#### Modifications Techniques Implémentées + +**1. Stabilisation Vidéo VidStab (2 passes)** + +Fichier : `ffmpeg-service/main.py` + +| Paramètre | Valeur | Justification | +|-----------|--------|---------------| +| **Pass 1 : Détection** | | | +| `shakiness` | 10 | Sensibilité maximale aux tremblements | +| `accuracy` | 15 | Haute précision d'analyse | +| `stepsize` | 32 | Grande fenêtre de recherche pour gros tremblements | +| **Pass 2 : Transformation** | | | +| `smoothing` | 30 | Lissage lourd pour effet professionnel | +| `relative` | 1 | Transformations relatives au frame précédent | +| `zoom` | 5 | Zoom fixe 5% pour éviter les bords noirs | +| `unsharp` | 5:5:1.0:5:5:0.0 | Netteté renforcée pour compenser le lissage | + +**2. Optimisation Encodage Facebook** + +| Paramètre | Avant | Après | Raison | +|-----------|-------|-------|--------| +| CRF | 17 | 18 | Sweet spot qualité/taille pour Facebook | +| Level H.264 | *(absent)* | 4.1 | Compatibilité profil High | +| Audio bitrate | 192k | 128k | Standard Facebook | +| Brightness/Contrast | *(absent)* | `eq=brightness=0.05:contrast=1.1` | Rehausse les vidéos ternes | + +**3. Texte Style TikTok Karaoke** + +Implémentation d'un effet karaoke avec surbrillance mot par mot : + +- **Style visuel** : + - Couleur avant : Blanc (`&H00FFFFFF`) + - Couleur highlight : Jaune (`&H0000FFFF`) + - Contour noir épais (3px) pour lisibilité + - Ombre portée pour profondeur + +- **Synchronisation** : + - Tags ASS `\kf` (karaoke fill) par mot + - Timing proportionnel à la longueur du mot + - Chunks de 3 mots max pour lisibilité mobile + +- **Résultat** : Chaque mot se remplit progressivement de blanc vers jaune au rythme de la voix TTS, exactement comme TikTok. + +**Code snippet (main.py:276-362)** : +```python +# ASS Karaoke Style +# PrimaryColour = Yellow (highlighted/spoken) +# SecondaryColour = White (before highlight) +Style: Default,Sans,{font_size},&H0000FFFF,&H00FFFFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,5,50,50,0,1 + +# Karaoke tags per word +for word in chunk_words: + word_dur_cs = int((len(word) / chunk_chars) * chunk_duration * 100) + karaoke_parts.append(f"{{\\kf{word_dur_cs}}}{sanitized}") +``` + +#### Résultat Final + +**Chaîne de traitement vidéo complète** : +``` +[Input Video] + → VidStab Pass 1 (Analyse tremblements) + → VidStab Pass 2 (Stabilisation + unsharp) + → Scale/Crop 1080x1920 + → Brightness/Contrast enhancement + → Texte Karaoke TikTok (ASS) + → Encodage H.264 High 4.1, CRF 18 + → Audio AAC 128k + → [Output Reel Facebook-ready] +``` + +### Phase 2 : Système Musique Jamendo & Favoris + +#### Objectif +Remplacer FreeSound (banque de sons) par Jamendo (vraie musique avec genres) et ajouter un système de favoris utilisateur. + +#### Architecture Implémentée + +**1. Remplacement FreeSound → Jamendo** + +Fichiers modifiés : +- `server/routes/reels.ts` : Import `jamendoService` au lieu de `freeSoundService` +- `server/index.ts` : Configuration Jamendo avec client ID + +**Service Jamendo** (`server/services/jamendo.ts`) : +- API : `https://api.jamendo.com/v3.0` +- Format audio : MP3 320kbps +- Genres supportés : pop, rock, electronic, hiphop, jazz, classical, ambient, chill, dance, indie +- License : Creative Commons +- Tri : `popularity_total` ou `popularity_week` + +**2. Base de Données : Table Favoris** + +Nouvelle table `music_favorites` : + +```sql +CREATE TABLE music_favorites ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + track_id TEXT NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL, + album_name TEXT, + duration INTEGER NOT NULL, + preview_url TEXT NOT NULL, + download_url TEXT NOT NULL, + image_url TEXT, + license TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +Fichiers modifiés : +- `shared/schema.ts` : Définition table + relations + types TypeScript +- `server/migrate.ts` : Migration automatique +- `server/storage.ts` : 4 nouvelles méthodes CRUD + +**3. API Endpoints Favoris** + +Routes dans `server/routes/reels.ts` : + +| Endpoint | Méthode | Description | +|----------|---------|-------------| +| `/api/music/favorites` | GET | Liste les favoris de l'utilisateur | +| `/api/music/favorites/check/:trackId` | GET | Vérifie si un track est en favori | +| `/api/music/favorites` | POST | Ajoute un track aux favoris | +| `/api/music/favorites/:trackId` | DELETE | Supprime un favori | + +**Ordre des routes** (critique pour le routing Express) : +1. `/api/music/search` +2. `/api/music/popular` +3. `/api/music/favorites` ← avant le wildcard +4. `/api/music/favorites/check/:trackId` +5. `/api/music/:trackId` ← wildcard en dernier + +**4. Méthodes Storage** + +Interface `IStorage` étendue : + +```typescript +interface IStorage { + // Music Favorites + getMusicFavorites(userId: string): Promise; + addMusicFavorite(favorite: InsertMusicFavorite): Promise; + removeMusicFavorite(userId: string, trackId: string): Promise; + isMusicFavorite(userId: string, trackId: string): Promise; +} +``` + +Implémentation utilise Drizzle ORM avec tri par `createdAt DESC`. + +#### Functional Requirements Ajoutés + +**Qualité Vidéo :** +- **FR-REEL-1** : Le système doit stabiliser automatiquement les vidéos tremblantes avec VidStab 2-pass +- **FR-REEL-2** : Le système doit optimiser l'encodage vidéo pour Facebook (H.264 High 4.1, CRF 18, 128k audio) +- **FR-REEL-3** : Le système doit rehausser légèrement la luminosité/contraste des vidéos ternes +- **FR-REEL-4** : Le texte doit s'afficher en style karaoke TikTok (highlight mot par mot synchronisé avec TTS) + +**Musique & Favoris :** +- **FR-REEL-5** : L'utilisateur peut rechercher de la musique par genre (pop, rock, chill, etc.) via Jamendo +- **FR-REEL-6** : L'utilisateur peut ajouter des tracks Jamendo à ses favoris +- **FR-REEL-7** : L'utilisateur peut voir la liste de ses musiques favorites +- **FR-REEL-8** : L'utilisateur peut supprimer un favori +- **FR-REEL-9** : Le système affiche visuellement si un track est en favori (icône cœur) +- **FR-REEL-10** : Les favoris sont persistés par utilisateur (isolation des données) + +#### Non-Functional Requirements + +**Performance :** +- **NFR-REEL-1** : La stabilisation 2-pass ne doit pas dépasser 3x la durée de la vidéo +- **NFR-REEL-2** : Les favoris doivent se charger en < 500ms + +**Qualité :** +- **NFR-REEL-3** : Les vidéos stabilisées doivent avoir un score de stabilité > 80% (métrique VidStab) +- **NFR-REEL-4** : Le texte karaoke doit être synchronisé avec une précision de ±100ms par mot + +**Sécurité :** +- **NFR-REEL-5** : Un utilisateur ne peut accéder qu'à ses propres favoris +- **NFR-REEL-6** : Les URLs de téléchargement Jamendo ne doivent jamais être exposées côté client + +#### Impact Utilisateur + +**Avant** : +- Vidéos tremblantes +- Musique limitée (sons FreeSound, pas de vraie musique) +- Pas de favoris → recherche répétitive +- Texte basique statique + +**Après** : +- Vidéos stables et professionnelles +- Catalogue musical riche (Jamendo) avec genres +- Système de favoris pour retrouver facilement les musiques aimées +- Texte dynamique style TikTok avec effet karaoke + +#### Technical Debt & Future Work + +**Optimisations futures** : +1. **Cache Jamendo** : Mettre en cache les résultats de recherche (30 min TTL) +2. **UI Favoris** : Implémenter l'interface client (bouton cœur, onglet favoris) +3. **Playlist** : Permettre de créer des playlists de favoris +4. **Recommandations** : Suggérer des musiques basées sur l'historique + +**Monitoring** : +- Logs FFmpeg : Durée de stabilisation, taux de succès +- API Jamendo : Rate limits, temps de réponse +- Favoris : Nombre moyen par utilisateur, tracks les plus favoris + +--- + ## Non-Functional Requirements ### Security & Data Protection diff --git a/ffmpeg-service/main.py b/ffmpeg-service/main.py index 3b7a857..9f7f874 100644 --- a/ffmpeg-service/main.py +++ b/ffmpeg-service/main.py @@ -276,24 +276,31 @@ async def generate_tts_with_subs( def generate_simple_ass( text: str, ass_path: Path, font_size: int = 65, total_duration: float = None ): - """Generate a high-quality ASS subtitle file with embedded styling.""" - # Split text into chunks - words = text.split() - chunks = [] + """Generate TikTok-style ASS subtitle file with karaoke highlight effect. + + Each word fills from white to yellow as it is spoken, with thick outline + for readability on any background. + """ + # Split text into word lists (3 words max per chunk for TikTok readability) + all_words = text.split() + chunks = [] # Each chunk is a list of words current_chunk = [] - for word in words: + for word in all_words: current_chunk.append(word) - if len(current_chunk) >= 5 or word.endswith((".", "!", "?", ":")): - chunks.append(" ".join(current_chunk)) + if len(current_chunk) >= 3 or word.endswith((".", "!", "?", ":")): + chunks.append(current_chunk) current_chunk = [] if current_chunk: - chunks.append(" ".join(current_chunk)) + chunks.append(current_chunk) - # ASS Header with explicit resolution and style - # Alignment 5 = Middle Center - # Font: Priority to Noto Color Emoji for emojis support + # ASS Header — TikTok Karaoke Style + # PrimaryColour = Yellow (highlighted/spoken) &H0000FFFF (ASS BGR: 00,FF,FF = RGB FF,FF,00) + # SecondaryColour = White (before highlight) &H00FFFFFF + # OutlineColour = Black &H00000000 + # BackColour = Semi-transparent black &H80000000 + # Bold=-1, Outline=3, Shadow=1, Alignment=5 (center middle) header = f"""[Script Info] ScriptType: v4.00+ PlayResX: 1080 @@ -302,7 +309,7 @@ ScaledBorderAndShadow: yes [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,Sans,{font_size},&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,2,0,5,50,50,0,1 +Style: Default,Sans,{font_size},&H0000FFFF,&H00FFFFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,5,50,50,0,1 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text @@ -311,47 +318,47 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text events = "" current_time = 0.0 - # Calculate timing strategy + # Calculate total characters across all chunks for proportional timing + total_chars = sum(len(w) for chunk in chunks for w in chunk) + if total_chars == 0: + total_chars = 1 + if total_duration: - # Dynamic timing based on characters count (more accurate than word count) - total_chars = sum(len(chunk) for chunk in chunks) - # Avoid division by zero - if total_chars == 0: - total_chars = 1 time_per_char = total_duration / total_chars else: - # Fallback static timing using word count - time_per_char = None + # Fallback: ~80ms per character + time_per_char = 0.08 - for i, chunk in enumerate(chunks): - if time_per_char: - # Duration proportional to character length including spaces - # Ensure minimum readability of 0.5s per chunk - chunk_len = len(chunk) - duration = chunk_len * time_per_char - # Optional: Enforce min duration, but we must respect total_duration - # So purely proportional is safer for sync - else: - # Fallback: 0.4s per word - word_count = len(chunk.split()) - duration = word_count * 0.4 + for chunk_words in chunks: + # Calculate chunk duration from its characters + chunk_chars = sum(len(w) for w in chunk_words) + chunk_duration = chunk_chars * time_per_char start_time = format_ass_time(current_time) - end_time = format_ass_time(current_time + duration) + end_time = format_ass_time(current_time + chunk_duration) - # Escape special characters if any - sanitized_chunk = chunk.replace("{", "(").replace("}", ")") + # Build karaoke text with \kf tags per word + # \kf = smooth fill from SecondaryColour (white) to PrimaryColour (yellow) + karaoke_parts = [] + for word in chunk_words: + # Word duration in centiseconds, proportional to character length + word_dur_cs = int((len(word) / chunk_chars) * chunk_duration * 100) + word_dur_cs = max(word_dur_cs, 10) # Min 0.1s per word + sanitized = word.replace("{", "(").replace("}", ")") + karaoke_parts.append(f"{{\\kf{word_dur_cs}}}{sanitized}") + + karaoke_text = " ".join(karaoke_parts) events += ( - f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{sanitized_chunk}\n" + f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{karaoke_text}\n" ) - current_time += duration + current_time += chunk_duration with open(ass_path, "w", encoding="utf-8") as f: f.write(header + events) print( - f"📄 Generated ASS file: {ass_path.stat().st_size} bytes with {len(chunks)} chunks" + f"📄 Generated ASS file (karaoke): {ass_path.stat().st_size} bytes, {len(chunks)} chunks, {len(all_words)} words" ) @@ -544,10 +551,10 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): "✅ Stabilization Pass 1 complete. Integrating Pass 2 into main filter chain." ) # We will add vidstabtransform to the video chain below - # optzoom=2 -> Adaptive zoom to avoid black borders - # zoom=0 -> Auto zoom # smoothing=30 -> Heavy smoothing (default is 10) for handheld feel - vidstab_filter = f"vidstabtransform=input={transforms_path}:smoothing=30:optzoom=2:zoom=0:interpol=bicubic,unsharp=5:5:0.8:3:3:0.4," + # relative=1 -> Transforms relative to previous frame + # zoom=5 -> Fixed 5% zoom to avoid black borders from stabilization + vidstab_filter = f"vidstabtransform=input={transforms_path}:smoothing=30:relative=1:zoom=5,unsharp=5:5:1.0:5:5:0.0," else: print( f"⚠️ Stabilization Pass 1 failed: {detect_proc.stderr.decode()[:500]}" @@ -613,7 +620,8 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): # Note: vidstabtransform output is same res as input # Scale & Crop to Fill 1080x1920 (Vertical Reel) - v_chain += "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920" + # Then enhance brightness/contrast slightly for Facebook optimization + v_chain += "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,eq=brightness=0.05:contrast=1.1" # 2. Text Overlay if request.text and request.draw_text: @@ -712,8 +720,10 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): "30", "-preset", "slow", + "-level", + "4.1", "-crf", - "17", + "18", "-b:v", "10M", "-maxrate", @@ -723,7 +733,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): "-c:a", "aac", "-b:a", - "192k", + "128k", "-pix_fmt", "yuv420p", "-movflags", diff --git a/server/index.ts b/server/index.ts index 7525384..fa5c47b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -183,10 +183,9 @@ app.use((req, res, next) => { // Initialiser l'utilisateur admin par défaut avant de démarrer le serveur await ensureAdminUserExists(); - // Configurer le service FreeSound pour les Reels - const freeSoundClientId = process.env.FREESOUND_CLIENT_ID || 'uTn5gEjjJRzPvyGP1eAN'; - const freeSoundClientSecret = process.env.FREESOUND_CLIENT_SECRET || 'QGUgFJFcDwXPfVOflMTXBBJ8C5l4UuKf4t9KAYBX'; - freeSoundService.configure(freeSoundClientId, freeSoundClientSecret); + // Configurer le service Jamendo pour les Reels + const jamendoClientId = process.env.JAMENDO_CLIENT_ID || '5b6bba49'; + jamendoService.configure(jamendoClientId); // Configurer le service FFmpeg pour le traitement vidéo (si configuré) if (process.env.FFMPEG_API_URL && process.env.FFMPEG_API_KEY) { diff --git a/server/migrate.ts b/server/migrate.ts index d2110d3..9bcaf99 100644 --- a/server/migrate.ts +++ b/server/migrate.ts @@ -63,6 +63,24 @@ export async function migrate() { ADD COLUMN IF NOT EXISTS "last_token_check" timestamp; `); + // music_favorites + await client.query(` + CREATE TABLE IF NOT EXISTS "music_favorites" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "track_id" text NOT NULL, + "title" text NOT NULL, + "artist" text NOT NULL, + "album_name" text, + "duration" integer NOT NULL, + "preview_url" text NOT NULL, + "download_url" text NOT NULL, + "image_url" text, + "license" text, + "created_at" timestamp DEFAULT now() + ); + `); + console.log("[Migration] Safe migration completed."); } catch (error) { console.error("[Migration] Error during migration:", error); diff --git a/server/routes/reels.ts b/server/routes/reels.ts index b9ada48..1873226 100644 --- a/server/routes/reels.ts +++ b/server/routes/reels.ts @@ -5,7 +5,7 @@ import { Router, Request, Response } from 'express'; import type { User, Media, SocialPage } from '@shared/schema'; import { storage } from '../storage'; -import { freeSoundService, type MusicTrack } from '../services/freesound'; +import { jamendoService, type MusicTrack } from '../services/jamendo'; import { ffmpegService } from '../services/ffmpeg'; import { facebookService } from '../services/facebook'; import { cloudinaryService } from '../services/cloudinary'; @@ -32,7 +32,7 @@ reelsRouter.get('/music/search', async (req: Request, res: Response) => { search, } = req.query; - const tracks = await freeSoundService.searchMusicByDuration({ + const tracks = await jamendoService.searchMusicByDuration({ minDuration: parseInt(minDuration as string), maxDuration: parseInt(maxDuration as string), genre: genre as string | undefined, @@ -70,7 +70,7 @@ reelsRouter.get('/music/more', async (req: Request, res: Response) => { search, } = req.query; - const tracks = await freeSoundService.searchMusicByDuration({ + const tracks = await jamendoService.searchMusicByDuration({ minDuration: parseInt(minDuration as string), maxDuration: parseInt(maxDuration as string), genre: genre as string | undefined, @@ -100,7 +100,7 @@ reelsRouter.get('/music/more', async (req: Request, res: Response) => { reelsRouter.get('/music/popular', async (req: Request, res: Response) => { try { const limit = parseInt(req.query.limit as string) || 10; - const tracks = await freeSoundService.getPopularTracks(limit); + const tracks = await jamendoService.getPopularTracks(limit); res.json({ tracks }); } catch (error) { console.error('❌ Error fetching popular music:', error); @@ -108,6 +108,116 @@ reelsRouter.get('/music/popular', async (req: Request, res: Response) => { } }); +// ============================================ +// ROUTES FAVORIS MUSIQUE +// ============================================ + +/** + * Obtenir les favoris de l'utilisateur + * GET /api/music/favorites + */ +reelsRouter.get('/music/favorites', async (req: Request, res: Response) => { + try { + const user = req.user as User; + const favorites = await storage.getMusicFavorites(user.id); + + // Convertir en format MusicTrack pour le client + const tracks: MusicTrack[] = favorites.map(f => ({ + id: f.trackId, + title: f.title, + artist: f.artist, + albumName: f.albumName || '', + duration: f.duration, + previewUrl: f.previewUrl, + downloadUrl: f.downloadUrl, + imageUrl: f.imageUrl || '', + license: f.license || '', + })); + + res.json({ tracks, isFavorites: true }); + } catch (error) { + console.error('❌ Error fetching music favorites:', error); + res.status(500).json({ error: 'Erreur lors de la récupération des favoris' }); + } +}); + +/** + * Vérifier si un track est en favori + * GET /api/music/favorites/check/:trackId + */ +reelsRouter.get('/music/favorites/check/:trackId', async (req: Request, res: Response) => { + try { + const user = req.user as User; + const { trackId } = req.params; + + const isFavorite = await storage.isMusicFavorite(user.id, trackId); + res.json({ isFavorite }); + } catch (error) { + console.error('❌ Error checking music favorite:', error); + res.status(500).json({ error: 'Erreur lors de la vérification du favori' }); + } +}); + +/** + * Ajouter un favori + * POST /api/music/favorites + */ +reelsRouter.post('/music/favorites', async (req: Request, res: Response) => { + try { + const user = req.user as User; + const { trackId, title, artist, albumName, duration, previewUrl, downloadUrl, imageUrl, license } = req.body; + + if (!trackId || !title) { + return res.status(400).json({ error: 'trackId et title requis' }); + } + + // Vérifier si déjà en favori + const alreadyFavorite = await storage.isMusicFavorite(user.id, trackId); + if (alreadyFavorite) { + return res.json({ success: true, message: 'Déjà en favori' }); + } + + await storage.addMusicFavorite({ + userId: user.id, + trackId, + title, + artist: artist || '', + albumName: albumName || null, + duration: duration || 0, + previewUrl: previewUrl || '', + downloadUrl: downloadUrl || '', + imageUrl: imageUrl || null, + license: license || null, + }); + + res.json({ success: true }); + } catch (error) { + console.error('❌ Error adding music favorite:', error); + res.status(500).json({ error: 'Erreur lors de l\'ajout du favori' }); + } +}); + +/** + * Supprimer un favori + * DELETE /api/music/favorites/:trackId + */ +reelsRouter.delete('/music/favorites/:trackId', async (req: Request, res: Response) => { + try { + const user = req.user as User; + const { trackId } = req.params; + + await storage.removeMusicFavorite(user.id, trackId); + res.json({ success: true }); + } catch (error) { + console.error('❌ Error removing music favorite:', error); + res.status(500).json({ error: 'Erreur lors de la suppression du favori' }); + } +}); + +// ============================================ +// ROUTE DÉTAILS TRACK (après les routes /music/favorites et /music/popular) +// ============================================ + /** * Détails d'un track * GET /api/music/:trackId @@ -115,7 +225,7 @@ reelsRouter.get('/music/popular', async (req: Request, res: Response) => { reelsRouter.get('/music/:trackId', async (req: Request, res: Response) => { try { const { trackId } = req.params; - const track = await freeSoundService.getMusicDetails(trackId); + const track = await jamendoService.getMusicDetails(trackId); if (!track) { return res.status(404).json({ error: 'Musique non trouvée' }); @@ -204,7 +314,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => { // Récupérer l'URL de la musique si trackId fourni let finalMusicUrl = musicUrl; if (musicTrackId && !musicUrl) { - const track = await freeSoundService.getMusicDetails(musicTrackId); + const track = await jamendoService.getMusicDetails(musicTrackId); if (track) { finalMusicUrl = track.downloadUrl; } @@ -309,7 +419,7 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => { // Récupérer l'URL de la musique si trackId fourni let finalMusicUrl = musicUrl; if (musicTrackId && !musicUrl) { - const track = await freeSoundService.getMusicDetails(musicTrackId); + const track = await jamendoService.getMusicDetails(musicTrackId); if (track) { finalMusicUrl = track.downloadUrl; } @@ -460,7 +570,7 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => { */ reelsRouter.get('/reels/config', async (req: Request, res: Response) => { try { - const jamendoConfigured = await freeSoundService.testConnection().catch(() => false); + const jamendoConfigured = await jamendoService.testConnection().catch(() => false); const ffmpegConfigured = await ffmpegService.healthCheck().catch(() => false); res.json({ diff --git a/server/storage.ts b/server/storage.ts index 5713c56..e51af0e 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -28,6 +28,9 @@ import { type InsertOpenrouterConfig, type UserPagePermission, type InsertUserPagePermission, + musicFavorites, + type MusicFavorite, + type InsertMusicFavorite, } from "@shared/schema"; import { db } from "./db"; import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm"; @@ -98,6 +101,12 @@ export interface IStorage { deletePagePermission(userId: string, pageId: string): Promise; deleteAllUserPagePermissions(userId: string): Promise; getUserAccessiblePages(userId: string): Promise; + + // Music Favorites + getMusicFavorites(userId: string): Promise; + addMusicFavorite(favorite: InsertMusicFavorite): Promise; + removeMusicFavorite(userId: string, trackId: string): Promise; + isMusicFavorite(userId: string, trackId: string): Promise; } export class DatabaseStorage implements IStorage { @@ -449,6 +458,37 @@ export class DatabaseStorage implements IStorage { return permissions.map(p => p.social_pages); } + + // Music Favorites + async getMusicFavorites(userId: string): Promise { + return db.select().from(musicFavorites) + .where(eq(musicFavorites.userId, userId)) + .orderBy(desc(musicFavorites.createdAt)); + } + + async addMusicFavorite(favorite: InsertMusicFavorite): Promise { + const [created] = await db.insert(musicFavorites).values(favorite).returning(); + return created; + } + + async removeMusicFavorite(userId: string, trackId: string): Promise { + await db.delete(musicFavorites).where( + and( + eq(musicFavorites.userId, userId), + eq(musicFavorites.trackId, trackId) + ) + ); + } + + async isMusicFavorite(userId: string, trackId: string): Promise { + const [fav] = await db.select().from(musicFavorites).where( + and( + eq(musicFavorites.userId, userId), + eq(musicFavorites.trackId, trackId) + ) + ); + return !!fav; + } } export const storage = new DatabaseStorage(); diff --git a/shared/schema.ts b/shared/schema.ts index e810209..d93a9f0 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -185,6 +185,21 @@ export const aiGenerations = pgTable("ai_generations", { createdAt: timestamp("created_at").defaultNow(), }); +export const musicFavorites = pgTable("music_favorites", { + id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), + userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + trackId: text("track_id").notNull(), + title: text("title").notNull(), + artist: text("artist").notNull(), + albumName: text("album_name"), + duration: integer("duration").notNull(), + previewUrl: text("preview_url").notNull(), + downloadUrl: text("download_url").notNull(), + imageUrl: text("image_url"), + license: text("license"), + createdAt: timestamp("created_at").defaultNow(), +}); + export const userPagePermissions = pgTable("user_page_permissions", { id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), @@ -292,6 +307,13 @@ export const aiGenerationsRelations = relations(aiGenerations, ({ one }) => ({ }), })); +export const musicFavoritesRelations = relations(musicFavorites, ({ one }) => ({ + user: one(users, { + fields: [musicFavorites.userId], + references: [users.id], + }), +})); + export const userPagePermissionsRelations = relations(userPagePermissions, ({ one }) => ({ user: one(users, { fields: [userPagePermissions.userId], @@ -365,6 +387,11 @@ export const insertPostMediaSchema = createInsertSchema(postMedia).omit({ id: true, }); +export const insertMusicFavoriteSchema = createInsertSchema(musicFavorites).omit({ + id: true, + createdAt: true, +}); + // Types export type User = typeof users.$inferSelect; export type InsertUser = z.infer; @@ -395,4 +422,7 @@ export type UpdateOpenrouterConfig = z.infer; +export type MusicFavorite = typeof musicFavorites.$inferSelect; +export type InsertMusicFavorite = z.infer; +