Files
allstarr/octo-fiesta/Services/Deezer/DeezerMetadataService.cs
2026-01-15 23:32:31 +01:00

706 lines
26 KiB
C#

using octo_fiesta.Models.Domain;
using octo_fiesta.Models.Settings;
using octo_fiesta.Models.Download;
using octo_fiesta.Models.Search;
using octo_fiesta.Models.Subsonic;
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace octo_fiesta.Services.Deezer;
/// <summary>
/// Metadata service implementation using the Deezer API (free, no key required)
/// </summary>
public class DeezerMetadataService : IMusicMetadataService
{
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _settings;
private const string BaseUrl = "https://api.deezer.com";
public DeezerMetadataService(IHttpClientFactory httpClientFactory, IOptions<SubsonicSettings> settings)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
{
try
{
var url = $"{BaseUrl}/search/track?q={Uri.EscapeDataString(query)}&limit={limit}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Song>();
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var songs = new List<Song>();
if (result.RootElement.TryGetProperty("data", out var data))
{
foreach (var track in data.EnumerateArray())
{
var song = ParseDeezerTrack(track);
if (ShouldIncludeSong(song))
{
songs.Add(song);
}
}
}
return songs;
}
catch
{
return new List<Song>();
}
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
{
try
{
var url = $"{BaseUrl}/search/album?q={Uri.EscapeDataString(query)}&limit={limit}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Album>();
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
if (result.RootElement.TryGetProperty("data", out var data))
{
foreach (var album in data.EnumerateArray())
{
albums.Add(ParseDeezerAlbum(album));
}
}
return albums;
}
catch
{
return new List<Album>();
}
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
{
try
{
var url = $"{BaseUrl}/search/artist?q={Uri.EscapeDataString(query)}&limit={limit}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Artist>();
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var artists = new List<Artist>();
if (result.RootElement.TryGetProperty("data", out var data))
{
foreach (var artist in data.EnumerateArray())
{
artists.Add(ParseDeezerArtist(artist));
}
}
return artists;
}
catch
{
return new List<Artist>();
}
}
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
{
// Execute searches in parallel
var songsTask = SearchSongsAsync(query, songLimit);
var albumsTask = SearchAlbumsAsync(query, albumLimit);
var artistsTask = SearchArtistsAsync(query, artistLimit);
await Task.WhenAll(songsTask, albumsTask, artistsTask);
return new SearchResult
{
Songs = await songsTask,
Albums = await albumsTask,
Artists = await artistsTask
};
}
public async Task<Song?> GetSongAsync(string externalProvider, string externalId)
{
if (externalProvider != "deezer") return null;
var url = $"{BaseUrl}/track/{externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
var track = JsonDocument.Parse(json).RootElement;
if (track.TryGetProperty("error", out _)) return null;
// For an individual track, get full metadata
var song = ParseDeezerTrackFull(track);
// Get additional info from album (genre, total track count, 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();
}
// Total track count
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 if not already set
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
{
// If we can't get the album, continue with track info only
}
}
return song;
}
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId)
{
if (externalProvider != "deezer") return null;
var url = $"{BaseUrl}/album/{externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
var albumElement = JsonDocument.Parse(json).RootElement;
if (albumElement.TryGetProperty("error", out _)) return null;
var album = ParseDeezerAlbum(albumElement);
// Get album songs
if (albumElement.TryGetProperty("tracks", out var tracks) &&
tracks.TryGetProperty("data", out var tracksData))
{
int trackIndex = 1;
foreach (var track in tracksData.EnumerateArray())
{
// Pass the album artist to ensure proper folder organization
var song = ParseDeezerTrack(track, trackIndex, album.Artist);
// Ensure album metadata is set (tracks in album response may not have full album object)
song.Album = album.Title;
song.AlbumId = album.Id;
song.AlbumArtist = album.Artist;
if (ShouldIncludeSong(song))
{
album.Songs.Add(song);
}
trackIndex++;
}
}
return album;
}
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId)
{
if (externalProvider != "deezer") return null;
var url = $"{BaseUrl}/artist/{externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
var artist = JsonDocument.Parse(json).RootElement;
if (artist.TryGetProperty("error", out _)) return null;
return ParseDeezerArtist(artist);
}
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId)
{
if (externalProvider != "deezer") return new List<Album>();
var url = $"{BaseUrl}/artist/{externalId}/albums";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Album>();
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
if (result.RootElement.TryGetProperty("data", out var data))
{
foreach (var album in data.EnumerateArray())
{
albums.Add(ParseDeezerAlbum(album));
}
}
return albums;
}
private Song ParseDeezerTrack(JsonElement track, int? fallbackTrackNumber = null, string? albumArtist = null)
{
var externalId = track.GetProperty("id").GetInt64().ToString();
// Try to get track_position from API, fallback to provided index
int? trackNumber = track.TryGetProperty("track_position", out var trackPos)
? trackPos.GetInt32()
: fallbackTrackNumber;
// Explicit content lyrics value
int? explicitContentLyrics = track.TryGetProperty("explicit_content_lyrics", out var ecl)
? ecl.GetInt32()
: 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,
CoverArtUrl = track.TryGetProperty("album", out var albumForCover) &&
albumForCover.TryGetProperty("cover_medium", out var cover)
? cover.GetString()
: null,
AlbumArtist = albumArtist,
IsLocal = false,
ExternalProvider = "deezer",
ExternalId = externalId,
ExplicitContentLyrics = explicitContentLyrics
};
}
/// <summary>
/// Parses a Deezer track with all available metadata
/// Used for GetSongAsync which returns complete data
/// </summary>
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<string>();
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 (first artist from album, or main track artist)
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 (different sizes)
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);
}
// Explicit content lyrics value
int? explicitContentLyrics = track.TryGetProperty("explicit_content_lyrics", out var ecl)
? ecl.GetInt32()
: 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,
ExplicitContentLyrics = explicitContentLyrics
};
}
private Album ParseDeezerAlbum(JsonElement album)
{
var externalId = album.GetProperty("id").GetInt64().ToString();
return new Album
{
Id = $"ext-deezer-album-{externalId}",
Title = album.GetProperty("title").GetString() ?? "",
Artist = album.TryGetProperty("artist", out var artist)
? artist.GetProperty("name").GetString() ?? ""
: "",
ArtistId = album.TryGetProperty("artist", out var artistForId)
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
: null,
Year = album.TryGetProperty("release_date", out var releaseDate)
? int.TryParse(releaseDate.GetString()?.Split('-')[0], out var year) ? year : null
: null,
SongCount = album.TryGetProperty("nb_tracks", out var nbTracks)
? nbTracks.GetInt32()
: null,
CoverArtUrl = album.TryGetProperty("cover_medium", out var cover)
? cover.GetString()
: null,
Genre = album.TryGetProperty("genres", out var genres) &&
genres.TryGetProperty("data", out var genresData) &&
genresData.GetArrayLength() > 0
? genresData[0].GetProperty("name").GetString()
: null,
IsLocal = false,
ExternalProvider = "deezer",
ExternalId = externalId
};
}
private Artist ParseDeezerArtist(JsonElement artist)
{
var externalId = artist.GetProperty("id").GetInt64().ToString();
return new Artist
{
Id = $"ext-deezer-artist-{externalId}",
Name = artist.GetProperty("name").GetString() ?? "",
ImageUrl = artist.TryGetProperty("picture_medium", out var picture)
? picture.GetString()
: null,
AlbumCount = artist.TryGetProperty("nb_album", out var nbAlbum)
? nbAlbum.GetInt32()
: null,
IsLocal = false,
ExternalProvider = "deezer",
ExternalId = externalId
};
}
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
{
try
{
var url = $"{BaseUrl}/search/playlist?q={Uri.EscapeDataString(query)}&limit={limit}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var playlists = new List<ExternalPlaylist>();
if (result.RootElement.TryGetProperty("data", out var data))
{
foreach (var playlist in data.EnumerateArray())
{
playlists.Add(ParseDeezerPlaylist(playlist));
}
}
return playlists;
}
catch
{
return new List<ExternalPlaylist>();
}
}
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId)
{
if (externalProvider != "deezer") return null;
try
{
var url = $"{BaseUrl}/playlist/{externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync();
var playlistElement = JsonDocument.Parse(json).RootElement;
if (playlistElement.TryGetProperty("error", out _)) return null;
return ParseDeezerPlaylist(playlistElement);
}
catch
{
return null;
}
}
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId)
{
if (externalProvider != "deezer") return new List<Song>();
try
{
var url = $"{BaseUrl}/playlist/{externalId}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Song>();
var json = await response.Content.ReadAsStringAsync();
var playlistElement = JsonDocument.Parse(json).RootElement;
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
var songs = new List<Song>();
// Get playlist name for album field
var playlistName = playlistElement.TryGetProperty("title", out var titleEl)
? titleEl.GetString() ?? "Unknown Playlist"
: "Unknown Playlist";
if (playlistElement.TryGetProperty("tracks", out var tracks) &&
tracks.TryGetProperty("data", out var tracksData))
{
int trackIndex = 1;
foreach (var track in tracksData.EnumerateArray())
{
// For playlists, use the track's own artist (not a single album artist)
var song = ParseDeezerTrack(track, trackIndex);
// Override album name to be the playlist name
song.Album = playlistName;
if (ShouldIncludeSong(song))
{
songs.Add(song);
}
trackIndex++;
}
}
return songs;
}
catch
{
return new List<Song>();
}
}
private ExternalPlaylist ParseDeezerPlaylist(JsonElement playlist)
{
var externalId = playlist.GetProperty("id").GetInt64().ToString();
// Get curator/creator name
string? curatorName = null;
if (playlist.TryGetProperty("user", out var user) &&
user.TryGetProperty("name", out var userName))
{
curatorName = userName.GetString();
}
else if (playlist.TryGetProperty("creator", out var creator) &&
creator.TryGetProperty("name", out var creatorName))
{
curatorName = creatorName.GetString();
}
// Get creation date
DateTime? createdDate = null;
if (playlist.TryGetProperty("creation_date", out var creationDateEl))
{
var dateStr = creationDateEl.GetString();
if (!string.IsNullOrEmpty(dateStr) && DateTime.TryParse(dateStr, out var date))
{
createdDate = date;
}
}
return new ExternalPlaylist
{
Id = Common.PlaylistIdHelper.CreatePlaylistId("deezer", externalId),
Name = playlist.GetProperty("title").GetString() ?? "",
Description = playlist.TryGetProperty("description", out var desc)
? desc.GetString()
: null,
CuratorName = curatorName,
Provider = "deezer",
ExternalId = externalId,
TrackCount = playlist.TryGetProperty("nb_tracks", out var nbTracks)
? nbTracks.GetInt32()
: 0,
Duration = playlist.TryGetProperty("duration", out var duration)
? duration.GetInt32()
: 0,
CoverUrl = playlist.TryGetProperty("picture_medium", out var picture)
? picture.GetString()
: (playlist.TryGetProperty("picture_big", out var pictureBig)
? pictureBig.GetString()
: null),
CreatedDate = createdDate
};
}
/// <summary>
/// Determines whether a song should be included based on the explicit content filter setting
/// </summary>
/// <param name="song">The song to check</param>
/// <returns>True if the song should be included, false otherwise</returns>
private bool ShouldIncludeSong(Song song)
{
// If no explicit content info, include the song
if (song.ExplicitContentLyrics == null)
return true;
return _settings.ExplicitFilter switch
{
// All: No filtering, include everything
ExplicitFilter.All => true,
// ExplicitOnly: Exclude clean/edited versions (value 3)
// Include: 0 (naturally clean), 1 (explicit), 2 (not applicable), 6/7 (unknown)
ExplicitFilter.ExplicitOnly => song.ExplicitContentLyrics != 3,
// CleanOnly: Only show clean content
// Include: 0 (naturally clean), 3 (clean/edited version)
// Exclude: 1 (explicit)
ExplicitFilter.CleanOnly => song.ExplicitContentLyrics != 1,
_ => true
};
}
}