diff --git a/DeezNET/Downloader.cs b/DeezNET/Downloader.cs index ca27a73..88a2d1f 100644 --- a/DeezNET/Downloader.cs +++ b/DeezNET/Downloader.cs @@ -88,7 +88,7 @@ public async Task ApplyMetadataToTrackStream(long trackId, Stream trackStream, i StreamAbstraction abstraction = new("track" + ext, trackStream); using TagLib.File file = TagLib.File.Create(abstraction); - await ApplyMetadataToTagLibFile(file, trackId, coverResolution, lyrics, token); + await ApplyMetadataToTagLibFile(file, trackId, coverResolution, lyrics, null, token); trackStream.Seek(0, SeekOrigin.Begin); } @@ -105,7 +105,7 @@ public async Task ApplyMetadataToTrackBytes(long trackId, byte[] trackDa FileBytesAbstraction abstraction = new("track" + ext, trackData); using TagLib.File file = TagLib.File.Create(abstraction); - await ApplyMetadataToTagLibFile(file, trackId, coverResolution, lyrics, token); + await ApplyMetadataToTagLibFile(file, trackId, coverResolution, lyrics, null, token); byte[] finalData = abstraction.MemoryStream.ToArray(); await abstraction.MemoryStream.DisposeAsync(); @@ -118,10 +118,10 @@ public async Task ApplyMetadataToTrackBytes(long trackId, byte[] trackDa /// The track ID to base metadata on. /// The track path to apply the metadata to. /// The modified track data - public async Task ApplyMetadataToFile(long trackId, string trackPath, int coverResolution = 512, string lyrics = "", CancellationToken token = default) + public async Task ApplyMetadataToFile(long trackId, string trackPath, int coverResolution = 512, string lyrics = "", MusicBrainzIds? mbids = null, CancellationToken token = default) { using TagLib.File file = TagLib.File.Create(trackPath); - await ApplyMetadataToTagLibFile(file, trackId, coverResolution, lyrics, token); + await ApplyMetadataToTagLibFile(file, trackId, coverResolution, lyrics, mbids, token); } /// @@ -285,7 +285,7 @@ private async Task GetTrackUrl(string token, Bitrate bitrate, Cancell return json.ToObject()!; } - private async Task ApplyMetadataToTagLibFile(TagLib.File track, long trackId, int coverResolution = 512, string lyrics = "", CancellationToken token = default) + private async Task ApplyMetadataToTagLibFile(TagLib.File track, long trackId, int coverResolution = 512, string lyrics = "", MusicBrainzIds? mbids = null, CancellationToken token = default) { JToken page = await _gw.GetTrackPage(trackId, token); long albumId = long.Parse(page["DATA"]!["ALB_ID"]!.ToString()); @@ -311,6 +311,87 @@ private async Task ApplyMetadataToTagLibFile(TagLib.File track, long trackId, in track.Tag.Lyrics = lyrics; + // MusicBrainz tagging for Lidarr import compatibility. + // Strategy: use pre-supplied MBIDs from Lidarr's matched album (zero ambiguity), + // then fall back to MusicBrainz API lookup for standalone downloads. + try + { + var trackNumber = (int)track.Tag.Track; + var tagged = false; + + // Prefer MBIDs supplied by the caller (from Lidarr's RemoteAlbum — already matched) + if (mbids != null) + { + if (!string.IsNullOrEmpty(mbids.ReleaseId)) + { + track.Tag.MusicBrainzReleaseId = mbids.ReleaseId; + tagged = true; + } + + if (!string.IsNullOrEmpty(mbids.ReleaseGroupId)) + { + track.Tag.MusicBrainzReleaseGroupId = mbids.ReleaseGroupId; + tagged = true; + } + + if (!string.IsNullOrEmpty(mbids.ArtistId)) + { + track.Tag.MusicBrainzArtistId = mbids.ArtistId; + } + + if (!string.IsNullOrEmpty(mbids.ReleaseArtistId)) + { + track.Tag.MusicBrainzReleaseArtistId = mbids.ReleaseArtistId; + } + + if (mbids.TrackRecordingIds != null && trackNumber > 0 && + mbids.TrackRecordingIds.TryGetValue(trackNumber, out var recordingId) && + !string.IsNullOrEmpty(recordingId)) + { + track.Tag.MusicBrainzTrackId = recordingId; + tagged = true; + } + } + + // Fallback: blind MusicBrainz API lookup (for standalone use outside Lidarr) + if (!tagged) + { + var mb = new MusicBrainzLookup(_client); + var artistName = track.Tag.AlbumArtists.FirstOrDefault() ?? track.Tag.Performers.FirstOrDefault() ?? ""; + var albumName = track.Tag.Album ?? ""; + var releaseYear = track.Tag.Year > 0 ? (int?)track.Tag.Year : null; + + if (!string.IsNullOrEmpty(artistName) && !string.IsNullOrEmpty(albumName)) + { + var mbRelease = await mb.LookupReleaseAsync(artistName, albumName, releaseYear, token); + if (mbRelease != null) + { + track.Tag.MusicBrainzReleaseId = mbRelease.ReleaseId; + track.Tag.MusicBrainzReleaseGroupId = mbRelease.ReleaseGroupId; + + if (!string.IsNullOrEmpty(mbRelease.ArtistId)) + { + track.Tag.MusicBrainzArtistId = mbRelease.ArtistId; + track.Tag.MusicBrainzReleaseArtistId = mbRelease.ArtistId; + } + + if (mbRelease.Tracks != null && trackNumber > 0) + { + var matchedTrack = mbRelease.Tracks.FirstOrDefault(t => t.Position == trackNumber); + if (matchedTrack != null && !string.IsNullOrEmpty(matchedTrack.RecordingId)) + { + track.Tag.MusicBrainzTrackId = matchedTrack.RecordingId; + } + } + } + } + } + } + catch (Exception) + { + // MusicBrainz lookup is best-effort; don't fail the download if it doesn't work + } + track.Save(); } diff --git a/DeezNET/MusicBrainzLookup.cs b/DeezNET/MusicBrainzLookup.cs new file mode 100644 index 0000000..378c274 --- /dev/null +++ b/DeezNET/MusicBrainzLookup.cs @@ -0,0 +1,206 @@ +using System.Text.Json; + +namespace DeezNET; + +/// +/// Looks up MusicBrainz IDs for Deezer tracks/albums to enable +/// seamless import into Lidarr. Uses the MusicBrainz web service API. +/// +public class MusicBrainzLookup +{ + private readonly HttpClient _client; + private readonly string _userAgent; + + public MusicBrainzLookup(HttpClient? client = null, string? userAgent = null) + { + _client = client ?? new HttpClient(); + _userAgent = userAgent ?? "DeezNET/1.2.1 (https://github.com/TrevTV/DeezNET)"; + } + + /// + /// Looks up a MusicBrainz release by artist and album name. + /// Returns the best matching release with MBIDs, or null if not found. + /// + public async Task LookupReleaseAsync(string artist, string album, int? year = null, CancellationToken token = default) + { + var query = $"release:\"{EscapeSearch(album)}\" AND artist:\"{EscapeSearch(artist)}\""; + if (year.HasValue) + { + query += $" AND date:{year.Value}"; + } + + var url = $"https://musicbrainz.org/ws/2/release?query={Uri.EscapeDataString(query)}&fmt=json&limit=5"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("User-Agent", _userAgent); + + try + { + var response = await _client.SendAsync(request, token); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(token); + using var doc = JsonDocument.Parse(json); + + var releases = doc.RootElement.GetProperty("releases"); + if (releases.GetArrayLength() == 0) + { + return null; + } + + // Score and pick the best match + foreach (var release in releases.EnumerateArray()) + { + var score = release.TryGetProperty("score", out var scoreEl) ? scoreEl.GetInt32() : 0; + if (score < 80) + { + continue; + } + + var mbRelease = ParseRelease(release); + if (mbRelease != null) + { + return mbRelease; + } + } + + // Fallback: take the first result even with lower score + if (releases.GetArrayLength() > 0) + { + return ParseRelease(releases[0]); + } + + return null; + } + catch + { + return null; + } + } + + /// + /// Looks up a MusicBrainz recording (track) by artist and track title. + /// Returns the recording MBID, or null if not found. + /// + public async Task LookupRecordingAsync(string artist, string trackTitle, CancellationToken token = default) + { + var query = $"recording:\"{EscapeSearch(trackTitle)}\" AND artist:\"{EscapeSearch(artist)}\""; + var url = $"https://musicbrainz.org/ws/2/recording?query={Uri.EscapeDataString(query)}&fmt=json&limit=1"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("User-Agent", _userAgent); + + try + { + var response = await _client.SendAsync(request, token); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(token); + using var doc = JsonDocument.Parse(json); + + var recordings = doc.RootElement.GetProperty("recordings"); + if (recordings.GetArrayLength() == 0) + { + return null; + } + + return recordings[0].GetProperty("id").GetString(); + } + catch + { + return null; + } + } + + private static MusicBrainzRelease? ParseRelease(JsonElement release) + { + try + { + var mbRelease = new MusicBrainzRelease + { + ReleaseId = release.GetProperty("id").GetString() ?? "", + Title = release.TryGetProperty("title", out var title) ? title.GetString() ?? "" : "", + }; + + // Release group + if (release.TryGetProperty("release-group", out var rg)) + { + mbRelease.ReleaseGroupId = rg.TryGetProperty("id", out var rgId) ? rgId.GetString() ?? "" : ""; + } + + // Artist credit -> first artist + if (release.TryGetProperty("artist-credit", out var ac) && ac.GetArrayLength() > 0) + { + var firstArtist = ac[0].GetProperty("artist"); + mbRelease.ArtistId = firstArtist.TryGetProperty("id", out var artId) ? artId.GetString() ?? "" : ""; + mbRelease.ArtistName = firstArtist.TryGetProperty("name", out var artName) ? artName.GetString() ?? "" : ""; + } + + // Media -> tracks + if (release.TryGetProperty("media", out var media) && media.GetArrayLength() > 0) + { + var firstMedia = media[0]; + if (firstMedia.TryGetProperty("track", out var tracks)) + { + mbRelease.TrackCount = tracks.GetArrayLength(); + mbRelease.Tracks = new List(); + foreach (var track in tracks.EnumerateArray()) + { + mbRelease.Tracks.Add(new MusicBrainzTrack + { + RecordingId = track.TryGetProperty("recording", out var rec) + ? rec.TryGetProperty("id", out var recId) ? recId.GetString() ?? "" : "" + : "", + Title = track.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "", + Position = track.TryGetProperty("position", out var pos) ? pos.GetInt32() : 0, + }); + } + } + } + + return mbRelease; + } + catch + { + return null; + } + } + + private static string EscapeSearch(string input) + { + return input.Replace("\"", "\\\""); + } +} + +public record MusicBrainzIds +{ + public string? ReleaseId { get; set; } + public string? ReleaseGroupId { get; set; } + public string? ArtistId { get; set; } + public string? ReleaseArtistId { get; set; } + public Dictionary? TrackRecordingIds { get; set; } +} + +public class MusicBrainzRelease +{ + public string ReleaseId { get; set; } = ""; + public string ReleaseGroupId { get; set; } = ""; + public string ArtistId { get; set; } = ""; + public string ArtistName { get; set; } = ""; + public string Title { get; set; } = ""; + public int TrackCount { get; set; } + public List? Tracks { get; set; } +} + +public class MusicBrainzTrack +{ + public string RecordingId { get; set; } = ""; + public string Title { get; set; } = ""; + public int Position { get; set; } +}