diff --git a/octo-fiesta/Models/MusicModels.cs b/octo-fiesta/Models/MusicModels.cs index da0f4cb..9055b2d 100644 --- a/octo-fiesta/Models/MusicModels.cs +++ b/octo-fiesta/Models/MusicModels.cs @@ -18,10 +18,57 @@ public class Song public string? AlbumId { get; set; } public int? Duration { get; set; } // En secondes public int? Track { get; set; } + public int? DiscNumber { get; set; } + public int? TotalTracks { get; set; } public int? Year { get; set; } public string? Genre { get; set; } public string? CoverArtUrl { get; set; } + /// + /// URL de la cover en haute résolution (pour embedding) + /// + public string? CoverArtUrlLarge { get; set; } + + /// + /// BPM (beats per minute) si disponible + /// + public int? Bpm { get; set; } + + /// + /// ISRC (International Standard Recording Code) + /// + public string? Isrc { get; set; } + + /// + /// Date de sortie complète (format: YYYY-MM-DD) + /// + public string? ReleaseDate { get; set; } + + /// + /// Nom de l'album artiste (peut différer de l'artiste du track) + /// + public string? AlbumArtist { get; set; } + + /// + /// Compositeur(s) + /// + public string? Composer { get; set; } + + /// + /// Label de l'album + /// + public string? Label { get; set; } + + /// + /// Copyright + /// + public string? Copyright { get; set; } + + /// + /// Artistes contributeurs (featurings, etc.) + /// + public List Contributors { get; set; } = new(); + /// /// Indique si la chanson est disponible localement ou doit être téléchargée /// diff --git a/octo-fiesta/Services/DeezerDownloadService.cs b/octo-fiesta/Services/DeezerDownloadService.cs index bbb679c..e0d7f16 100644 --- a/octo-fiesta/Services/DeezerDownloadService.cs +++ b/octo-fiesta/Services/DeezerDownloadService.cs @@ -5,6 +5,8 @@ using Org.BouncyCastle.Crypto.Engines; using Org.BouncyCastle.Crypto.Modes; using Org.BouncyCastle.Crypto.Parameters; using octo_fiesta.Models; +using TagLib; +using IOFile = System.IO.File; namespace octo_fiesta.Services; @@ -84,7 +86,7 @@ public class DeezerDownloadService : IDownloadService // Vérifier si déjà téléchargé var existingPath = await _localLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); - if (existingPath != null && File.Exists(existingPath)) + if (existingPath != null && IOFile.Exists(existingPath)) { _logger.LogInformation("Song already downloaded: {Path}", existingPath); return existingPath; @@ -161,7 +163,7 @@ public class DeezerDownloadService : IDownloadService public async Task DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { var localPath = await DownloadSongAsync(externalProvider, externalId, cancellationToken); - return File.OpenRead(localPath); + return IOFile.OpenRead(localPath); } public DownloadInfo? GetDownloadStatus(string songId) @@ -391,13 +393,163 @@ public class DeezerDownloadService : IDownloadService // Télécharger et déchiffrer await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); - await using var outputFile = File.Create(outputPath); + await using var outputFile = IOFile.Create(outputPath); await DecryptAndWriteStreamAsync(responseStream, outputFile, trackId, cancellationToken); + + // Fermer le fichier avant d'écrire les métadonnées + await outputFile.DisposeAsync(); + + // Écrire les métadonnées et la cover art + await WriteMetadataAsync(outputPath, song, cancellationToken); return outputPath; } + /// + /// Écrit les métadonnées ID3/Vorbis et la cover art dans le fichier audio + /// + private async Task WriteMetadataAsync(string filePath, Song song, CancellationToken cancellationToken) + { + try + { + _logger.LogInformation("Writing metadata to: {Path}", filePath); + + using var tagFile = TagLib.File.Create(filePath); + + // Métadonnées de base + tagFile.Tag.Title = song.Title; + tagFile.Tag.Performers = new[] { song.Artist }; + tagFile.Tag.Album = song.Album; + + // Album artist (peut différer de l'artiste du track pour les compilations) + if (!string.IsNullOrEmpty(song.AlbumArtist)) + { + tagFile.Tag.AlbumArtists = new[] { song.AlbumArtist }; + } + else + { + tagFile.Tag.AlbumArtists = new[] { song.Artist }; + } + + // Numéro de piste + if (song.Track.HasValue) + { + tagFile.Tag.Track = (uint)song.Track.Value; + } + + // Nombre total de pistes + if (song.TotalTracks.HasValue) + { + tagFile.Tag.TrackCount = (uint)song.TotalTracks.Value; + } + + // Numéro de disque + if (song.DiscNumber.HasValue) + { + tagFile.Tag.Disc = (uint)song.DiscNumber.Value; + } + + // Année + if (song.Year.HasValue) + { + tagFile.Tag.Year = (uint)song.Year.Value; + } + + // Genre + if (!string.IsNullOrEmpty(song.Genre)) + { + tagFile.Tag.Genres = new[] { song.Genre }; + } + + // BPM + if (song.Bpm.HasValue) + { + tagFile.Tag.BeatsPerMinute = (uint)song.Bpm.Value; + } + + // ISRC (stocké dans le commentaire si pas de champ dédié, ou via MusicBrainz ID) + // TagLib ne supporte pas directement l'ISRC, mais on peut l'ajouter au commentaire + var comments = new List(); + if (!string.IsNullOrEmpty(song.Isrc)) + { + comments.Add($"ISRC: {song.Isrc}"); + } + + // Contributeurs dans le commentaire + if (song.Contributors.Count > 0) + { + tagFile.Tag.Composers = song.Contributors.ToArray(); + } + + // Copyright + if (!string.IsNullOrEmpty(song.Copyright)) + { + tagFile.Tag.Copyright = song.Copyright; + } + + // Commentaire avec infos supplémentaires + if (comments.Count > 0) + { + tagFile.Tag.Comment = string.Join(" | ", comments); + } + + // Télécharger et intégrer la cover art + var coverUrl = song.CoverArtUrlLarge ?? song.CoverArtUrl; + if (!string.IsNullOrEmpty(coverUrl)) + { + try + { + var coverData = await DownloadCoverArtAsync(coverUrl, cancellationToken); + if (coverData != null && coverData.Length > 0) + { + var mimeType = coverUrl.Contains(".png") ? "image/png" : "image/jpeg"; + var picture = new TagLib.Picture + { + Type = TagLib.PictureType.FrontCover, + MimeType = mimeType, + Description = "Cover", + Data = new TagLib.ByteVector(coverData) + }; + tagFile.Tag.Pictures = new TagLib.IPicture[] { picture }; + _logger.LogInformation("Cover art embedded: {Size} bytes", coverData.Length); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to download cover art from {Url}", coverUrl); + } + } + + // Sauvegarder les modifications + tagFile.Save(); + _logger.LogInformation("Metadata written successfully to: {Path}", filePath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to write metadata to: {Path}", filePath); + // Ne pas propager l'erreur - le fichier est téléchargé, juste sans métadonnées + } + } + + /// + /// Télécharge la cover art depuis une URL + /// + private async Task DownloadCoverArtAsync(string url, CancellationToken cancellationToken) + { + try + { + var response = await _httpClient.GetAsync(url, cancellationToken); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsByteArrayAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to download cover art from {Url}", url); + return null; + } + } + #endregion #region Decryption @@ -659,7 +811,7 @@ public static class PathHelper /// public static string ResolveUniquePath(string basePath) { - if (!File.Exists(basePath)) + if (!IOFile.Exists(basePath)) { return basePath; } @@ -674,7 +826,7 @@ public static class PathHelper { uniquePath = Path.Combine(directory, $"{fileNameWithoutExt} ({counter}){extension}"); counter++; - } while (File.Exists(uniquePath)); + } while (IOFile.Exists(uniquePath)); return uniquePath; } diff --git a/octo-fiesta/Services/DeezerMetadataService.cs b/octo-fiesta/Services/DeezerMetadataService.cs index d042178..57060e1 100644 --- a/octo-fiesta/Services/DeezerMetadataService.cs +++ b/octo-fiesta/Services/DeezerMetadataService.cs @@ -134,7 +134,65 @@ public class DeezerMetadataService : IMusicMetadataService if (track.TryGetProperty("error", out _)) return null; - return ParseDeezerTrack(track); + // Pour un track individuel, on récupère les métadonnées complètes + var song = ParseDeezerTrackFull(track); + + // Récupérer les infos supplémentaires depuis l'album (genre, nombre total de tracks, label, copyright) + if (track.TryGetProperty("album", out var albumRef) && + albumRef.TryGetProperty("id", out var albumIdEl)) + { + var albumId = albumIdEl.GetInt64().ToString(); + try + { + var albumUrl = $"{BaseUrl}/album/{albumId}"; + var albumResponse = await _httpClient.GetAsync(albumUrl); + if (albumResponse.IsSuccessStatusCode) + { + var albumJson = await albumResponse.Content.ReadAsStringAsync(); + var albumData = JsonDocument.Parse(albumJson).RootElement; + + // Genre + if (albumData.TryGetProperty("genres", out var genres) && + genres.TryGetProperty("data", out var genresData) && + genresData.GetArrayLength() > 0 && + genresData[0].TryGetProperty("name", out var genreName)) + { + song.Genre = genreName.GetString(); + } + + // Nombre total de tracks + if (albumData.TryGetProperty("nb_tracks", out var nbTracks)) + { + song.TotalTracks = nbTracks.GetInt32(); + } + + // Label + if (albumData.TryGetProperty("label", out var label)) + { + song.Label = label.GetString(); + } + + // Cover art XL si pas déjà définie + if (string.IsNullOrEmpty(song.CoverArtUrlLarge)) + { + if (albumData.TryGetProperty("cover_xl", out var coverXl)) + { + song.CoverArtUrlLarge = coverXl.GetString(); + } + else if (albumData.TryGetProperty("cover_big", out var coverBig)) + { + song.CoverArtUrlLarge = coverBig.GetString(); + } + } + } + } + catch + { + // Si on ne peut pas récupérer l'album, on continue avec les infos du track + } + } + + return song; } public async Task GetAlbumAsync(string externalProvider, string externalId) @@ -249,6 +307,128 @@ public class DeezerMetadataService : IMusicMetadataService }; } + /// + /// Parse un track Deezer avec toutes les métadonnées disponibles + /// Utilisé pour GetSongAsync qui retourne des données complètes + /// + private Song ParseDeezerTrackFull(JsonElement track) + { + var externalId = track.GetProperty("id").GetInt64().ToString(); + + // Track position et disc number + int? trackNumber = track.TryGetProperty("track_position", out var trackPos) + ? trackPos.GetInt32() + : null; + int? discNumber = track.TryGetProperty("disk_number", out var diskNum) + ? diskNum.GetInt32() + : null; + + // BPM + int? bpm = track.TryGetProperty("bpm", out var bpmVal) && bpmVal.ValueKind == JsonValueKind.Number + ? (int)bpmVal.GetDouble() + : null; + + // ISRC + string? isrc = track.TryGetProperty("isrc", out var isrcVal) + ? isrcVal.GetString() + : null; + + // Release date from album + string? releaseDate = null; + int? year = null; + if (track.TryGetProperty("release_date", out var relDate)) + { + releaseDate = relDate.GetString(); + if (!string.IsNullOrEmpty(releaseDate) && releaseDate.Length >= 4) + { + if (int.TryParse(releaseDate.Substring(0, 4), out var y)) + year = y; + } + } + else if (track.TryGetProperty("album", out var albumForDate) && + albumForDate.TryGetProperty("release_date", out var albumRelDate)) + { + releaseDate = albumRelDate.GetString(); + if (!string.IsNullOrEmpty(releaseDate) && releaseDate.Length >= 4) + { + if (int.TryParse(releaseDate.Substring(0, 4), out var y)) + year = y; + } + } + + // Contributors + var contributors = new List(); + if (track.TryGetProperty("contributors", out var contribs)) + { + foreach (var contrib in contribs.EnumerateArray()) + { + if (contrib.TryGetProperty("name", out var contribName)) + { + var name = contribName.GetString(); + if (!string.IsNullOrEmpty(name)) + contributors.Add(name); + } + } + } + + // Album artist (premier artiste de l'album, ou artiste principal du track) + string? albumArtist = null; + if (track.TryGetProperty("album", out var albumForArtist) && + albumForArtist.TryGetProperty("artist", out var albumArtistEl)) + { + albumArtist = albumArtistEl.TryGetProperty("name", out var aName) + ? aName.GetString() + : null; + } + + // Cover art URLs (différentes tailles) + string? coverMedium = null; + string? coverLarge = null; + if (track.TryGetProperty("album", out var albumForCover)) + { + coverMedium = albumForCover.TryGetProperty("cover_medium", out var cm) + ? cm.GetString() + : null; + coverLarge = albumForCover.TryGetProperty("cover_xl", out var cxl) + ? cxl.GetString() + : (albumForCover.TryGetProperty("cover_big", out var cb) ? cb.GetString() : null); + } + + return new Song + { + Id = $"ext-deezer-song-{externalId}", + Title = track.GetProperty("title").GetString() ?? "", + Artist = track.TryGetProperty("artist", out var artist) + ? artist.GetProperty("name").GetString() ?? "" + : "", + ArtistId = track.TryGetProperty("artist", out var artistForId) + ? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}" + : null, + Album = track.TryGetProperty("album", out var album) + ? album.GetProperty("title").GetString() ?? "" + : "", + AlbumId = track.TryGetProperty("album", out var albumForId) + ? $"ext-deezer-album-{albumForId.GetProperty("id").GetInt64()}" + : null, + Duration = track.TryGetProperty("duration", out var duration) + ? duration.GetInt32() + : null, + Track = trackNumber, + DiscNumber = discNumber, + Year = year, + Bpm = bpm, + Isrc = isrc, + ReleaseDate = releaseDate, + AlbumArtist = albumArtist, + Contributors = contributors, + CoverArtUrl = coverMedium, + CoverArtUrlLarge = coverLarge, + IsLocal = false, + ExternalProvider = "deezer", + ExternalId = externalId + }; + } + private Album ParseDeezerAlbum(JsonElement album) { var externalId = album.GetProperty("id").GetInt64().ToString(); diff --git a/octo-fiesta/octo-fiesta.csproj b/octo-fiesta/octo-fiesta.csproj index 07c460a..ab568f4 100644 --- a/octo-fiesta/octo-fiesta.csproj +++ b/octo-fiesta/octo-fiesta.csproj @@ -11,6 +11,7 @@ +