Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 86 additions & 5 deletions DeezNET/Downloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -105,7 +105,7 @@ public async Task<byte[]> 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();
Expand All @@ -118,10 +118,10 @@ public async Task<byte[]> ApplyMetadataToTrackBytes(long trackId, byte[] trackDa
/// <param name="trackId">The track ID to base metadata on.</param>
/// <param name="trackPath">The track path to apply the metadata to.</param>
/// <returns>The modified track data</returns>
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);
}

/// <summary>
Expand Down Expand Up @@ -285,7 +285,7 @@ private async Task<TrackUrls> GetTrackUrl(string token, Bitrate bitrate, Cancell
return json.ToObject<TrackUrls>()!;
}

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());
Expand All @@ -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();
}

Expand Down
206 changes: 206 additions & 0 deletions DeezNET/MusicBrainzLookup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
using System.Text.Json;

namespace DeezNET;

/// <summary>
/// Looks up MusicBrainz IDs for Deezer tracks/albums to enable
/// seamless import into Lidarr. Uses the MusicBrainz web service API.
/// </summary>
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)";
}

/// <summary>
/// Looks up a MusicBrainz release by artist and album name.
/// Returns the best matching release with MBIDs, or null if not found.
/// </summary>
public async Task<MusicBrainzRelease?> 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;
}
}

/// <summary>
/// Looks up a MusicBrainz recording (track) by artist and track title.
/// Returns the recording MBID, or null if not found.
/// </summary>
public async Task<string?> 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<MusicBrainzTrack>();
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<int, string>? 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<MusicBrainzTrack>? Tracks { get; set; }
}

public class MusicBrainzTrack
{
public string RecordingId { get; set; } = "";
public string Title { get; set; } = "";
public int Position { get; set; }
}