mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
feat: playlist implementation
This commit is contained in:
@@ -4,6 +4,7 @@ using octo_fiesta.Models.Download;
|
||||
using octo_fiesta.Models.Search;
|
||||
using octo_fiesta.Models.Subsonic;
|
||||
using octo_fiesta.Services.Local;
|
||||
using octo_fiesta.Services.Subsonic;
|
||||
using TagLib;
|
||||
using IOFile = System.IO.File;
|
||||
|
||||
@@ -21,6 +22,7 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
protected readonly IMusicMetadataService MetadataService;
|
||||
protected readonly SubsonicSettings SubsonicSettings;
|
||||
protected readonly ILogger Logger;
|
||||
protected readonly IServiceProvider ServiceProvider;
|
||||
|
||||
protected readonly string DownloadPath;
|
||||
protected readonly string CachePath;
|
||||
@@ -38,12 +40,14 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
ILocalLibraryService localLibraryService,
|
||||
IMusicMetadataService metadataService,
|
||||
SubsonicSettings subsonicSettings,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger logger)
|
||||
{
|
||||
Configuration = configuration;
|
||||
LocalLibraryService = localLibraryService;
|
||||
MetadataService = metadataService;
|
||||
SubsonicSettings = subsonicSettings;
|
||||
ServiceProvider = serviceProvider;
|
||||
Logger = logger;
|
||||
|
||||
DownloadPath = configuration["Library:DownloadPath"] ?? "./downloads";
|
||||
@@ -79,6 +83,30 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
return info;
|
||||
}
|
||||
|
||||
public async Task<string?> GetLocalPathIfExistsAsync(string externalProvider, string externalId)
|
||||
{
|
||||
if (externalProvider != ProviderName)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check local library
|
||||
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||
if (localPath != null && IOFile.Exists(localPath))
|
||||
{
|
||||
return localPath;
|
||||
}
|
||||
|
||||
// Check cache directory
|
||||
var cachedPath = GetCachedFilePath(externalProvider, externalId);
|
||||
if (cachedPath != null && IOFile.Exists(cachedPath))
|
||||
{
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public abstract Task<bool> IsAvailableAsync();
|
||||
|
||||
public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId)
|
||||
@@ -240,6 +268,25 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
|
||||
song.LocalPath = localPath;
|
||||
|
||||
// Check if this track belongs to a playlist and update M3U
|
||||
try
|
||||
{
|
||||
var playlistSyncService = ServiceProvider.GetService(typeof(PlaylistSyncService)) as PlaylistSyncService;
|
||||
if (playlistSyncService != null)
|
||||
{
|
||||
var playlistId = playlistSyncService.GetPlaylistIdForTrack(songId);
|
||||
if (playlistId != null)
|
||||
{
|
||||
Logger.LogInformation("Track {SongId} belongs to playlist {PlaylistId}, adding to M3U", songId, playlistId);
|
||||
await playlistSyncService.AddTrackToM3UAsync(playlistId, song, localPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to update playlist M3U for track {SongId}", songId);
|
||||
}
|
||||
|
||||
// Only register and scan if NOT in cache mode
|
||||
if (!isCache)
|
||||
{
|
||||
|
||||
76
octo-fiesta/Services/Common/PlaylistIdHelper.cs
Normal file
76
octo-fiesta/Services/Common/PlaylistIdHelper.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
namespace octo_fiesta.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for handling external playlist IDs.
|
||||
/// Playlist IDs use the format: "pl-{provider}-{externalId}"
|
||||
/// Example: "pl-deezer-123456", "pl-qobuz-789"
|
||||
/// </summary>
|
||||
public static class PlaylistIdHelper
|
||||
{
|
||||
private const string PlaylistPrefix = "pl-";
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an ID represents an external playlist.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID to check</param>
|
||||
/// <returns>True if the ID starts with "pl-", false otherwise</returns>
|
||||
public static bool IsExternalPlaylist(string? id)
|
||||
{
|
||||
return !string.IsNullOrEmpty(id) && id.StartsWith(PlaylistPrefix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a playlist ID to extract provider and external ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The playlist ID in format "pl-{provider}-{externalId}"</param>
|
||||
/// <returns>A tuple containing (provider, externalId)</returns>
|
||||
/// <exception cref="ArgumentException">Thrown if the ID format is invalid</exception>
|
||||
public static (string provider, string externalId) ParsePlaylistId(string id)
|
||||
{
|
||||
if (!IsExternalPlaylist(id))
|
||||
{
|
||||
throw new ArgumentException($"Invalid playlist ID format. Expected 'pl-{{provider}}-{{externalId}}', got '{id}'", nameof(id));
|
||||
}
|
||||
|
||||
// Remove "pl-" prefix
|
||||
var withoutPrefix = id.Substring(PlaylistPrefix.Length);
|
||||
|
||||
// Split by first dash to get provider and externalId
|
||||
var dashIndex = withoutPrefix.IndexOf('-');
|
||||
if (dashIndex == -1)
|
||||
{
|
||||
throw new ArgumentException($"Invalid playlist ID format. Expected 'pl-{{provider}}-{{externalId}}', got '{id}'", nameof(id));
|
||||
}
|
||||
|
||||
var provider = withoutPrefix.Substring(0, dashIndex);
|
||||
var externalId = withoutPrefix.Substring(dashIndex + 1);
|
||||
|
||||
if (string.IsNullOrEmpty(provider) || string.IsNullOrEmpty(externalId))
|
||||
{
|
||||
throw new ArgumentException($"Invalid playlist ID format. Provider or external ID is empty in '{id}'", nameof(id));
|
||||
}
|
||||
|
||||
return (provider, externalId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a playlist ID from provider and external ID.
|
||||
/// </summary>
|
||||
/// <param name="provider">The provider name (e.g., "deezer", "qobuz")</param>
|
||||
/// <param name="externalId">The external ID from the provider</param>
|
||||
/// <returns>A playlist ID in format "pl-{provider}-{externalId}"</returns>
|
||||
public static string CreatePlaylistId(string provider, string externalId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(provider))
|
||||
{
|
||||
throw new ArgumentException("Provider cannot be null or empty", nameof(provider));
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(externalId))
|
||||
{
|
||||
throw new ArgumentException("External ID cannot be null or empty", nameof(externalId));
|
||||
}
|
||||
|
||||
return $"{PlaylistPrefix}{provider.ToLowerInvariant()}-{externalId}";
|
||||
}
|
||||
}
|
||||
@@ -50,8 +50,9 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
IMusicMetadataService metadataService,
|
||||
IOptions<SubsonicSettings> subsonicSettings,
|
||||
IOptions<DeezerSettings> deezerSettings,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<DeezerDownloadService> logger)
|
||||
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, logger)
|
||||
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
|
||||
|
||||
@@ -231,6 +231,12 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
{
|
||||
// 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);
|
||||
@@ -511,6 +517,163 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -42,6 +42,14 @@ public interface IDownloadService
|
||||
/// </summary>
|
||||
DownloadInfo? GetDownloadStatus(string songId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the local path for a song if it has been downloaded already
|
||||
/// </summary>
|
||||
/// <param name="externalProvider">The provider (deezer, qobuz, etc.)</param>
|
||||
/// <param name="externalId">The ID on the external provider</param>
|
||||
/// <returns>The local file path if exists, null otherwise</returns>
|
||||
Task<string?> GetLocalPathIfExistsAsync(string externalProvider, string externalId);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the service is properly configured and functional
|
||||
/// </summary>
|
||||
|
||||
@@ -54,4 +54,28 @@ public interface IMusicMetadataService
|
||||
/// Gets an artist's albums
|
||||
/// </summary>
|
||||
Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for playlists on external providers
|
||||
/// </summary>
|
||||
/// <param name="query">Search term</param>
|
||||
/// <param name="limit">Maximum number of results</param>
|
||||
/// <returns>List of found playlists</returns>
|
||||
Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20);
|
||||
|
||||
/// <summary>
|
||||
/// Gets details of an external playlist (metadata only, not tracks)
|
||||
/// </summary>
|
||||
/// <param name="externalProvider">Provider name (e.g., "deezer", "qobuz")</param>
|
||||
/// <param name="externalId">Playlist ID from the provider</param>
|
||||
/// <returns>Playlist details or null if not found</returns>
|
||||
Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tracks from an external playlist
|
||||
/// </summary>
|
||||
/// <param name="externalProvider">Provider name (e.g., "deezer", "qobuz")</param>
|
||||
/// <param name="externalId">Playlist ID from the provider</param>
|
||||
/// <returns>List of songs in the playlist</returns>
|
||||
Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId);
|
||||
}
|
||||
|
||||
@@ -43,8 +43,9 @@ public class QobuzDownloadService : BaseDownloadService
|
||||
QobuzBundleService bundleService,
|
||||
IOptions<SubsonicSettings> subsonicSettings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<QobuzDownloadService> logger)
|
||||
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, logger)
|
||||
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_bundleService = bundleService;
|
||||
|
||||
@@ -212,6 +212,12 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
foreach (var track in tracksData.EnumerateArray())
|
||||
{
|
||||
var song = ParseQobuzTrack(track);
|
||||
|
||||
// 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);
|
||||
@@ -305,6 +311,180 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
var url = $"{BaseUrl}playlist/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}";
|
||||
|
||||
var response = await GetWithAuthAsync(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("playlists", out var playlistsData) &&
|
||||
playlistsData.TryGetProperty("items", out var items))
|
||||
{
|
||||
foreach (var playlist in items.EnumerateArray())
|
||||
{
|
||||
playlists.Add(ParseQobuzPlaylist(playlist));
|
||||
}
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to search playlists for query: {Query}", query);
|
||||
return new List<ExternalPlaylist>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId)
|
||||
{
|
||||
if (externalProvider != "qobuz") return null;
|
||||
|
||||
try
|
||||
{
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
var url = $"{BaseUrl}playlist/get?playlist_id={externalId}&app_id={appId}";
|
||||
|
||||
var response = await GetWithAuthAsync(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 ParseQobuzPlaylist(playlistElement);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get playlist {ExternalId}", externalId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId)
|
||||
{
|
||||
if (externalProvider != "qobuz") return new List<Song>();
|
||||
|
||||
try
|
||||
{
|
||||
var appId = await _bundleService.GetAppIdAsync();
|
||||
var url = $"{BaseUrl}playlist/get?playlist_id={externalId}&app_id={appId}&extra=tracks";
|
||||
|
||||
var response = await GetWithAuthAsync(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("name", out var nameEl)
|
||||
? nameEl.GetString() ?? "Unknown Playlist"
|
||||
: "Unknown Playlist";
|
||||
|
||||
if (playlistElement.TryGetProperty("tracks", out var tracks) &&
|
||||
tracks.TryGetProperty("items", 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 = ParseQobuzTrack(track);
|
||||
|
||||
// Override album name to be the playlist name
|
||||
song.Album = playlistName;
|
||||
song.Track = trackIndex;
|
||||
|
||||
if (ShouldIncludeSong(song))
|
||||
{
|
||||
songs.Add(song);
|
||||
}
|
||||
trackIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return songs;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get playlist tracks for {ExternalId}", externalId);
|
||||
return new List<Song>();
|
||||
}
|
||||
}
|
||||
|
||||
private ExternalPlaylist ParseQobuzPlaylist(JsonElement playlist)
|
||||
{
|
||||
var externalId = GetIdAsString(playlist.GetProperty("id"));
|
||||
|
||||
// Get curator/creator name
|
||||
string? curatorName = null;
|
||||
if (playlist.TryGetProperty("owner", out var owner) &&
|
||||
owner.TryGetProperty("name", out var ownerName))
|
||||
{
|
||||
curatorName = ownerName.GetString();
|
||||
}
|
||||
|
||||
// Get creation date
|
||||
DateTime? createdDate = null;
|
||||
if (playlist.TryGetProperty("created_at", out var createdAtEl))
|
||||
{
|
||||
var timestamp = createdAtEl.GetInt64();
|
||||
createdDate = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime;
|
||||
}
|
||||
|
||||
// Get cover URL from images
|
||||
string? coverUrl = null;
|
||||
if (playlist.TryGetProperty("images300", out var images300))
|
||||
{
|
||||
var imagesArray = images300.EnumerateArray().ToList();
|
||||
if (imagesArray.Count > 0)
|
||||
{
|
||||
coverUrl = imagesArray[0].GetString();
|
||||
}
|
||||
}
|
||||
else if (playlist.TryGetProperty("image_rectangle", out var imageRect))
|
||||
{
|
||||
var imagesArray = imageRect.EnumerateArray().ToList();
|
||||
if (imagesArray.Count > 0)
|
||||
{
|
||||
coverUrl = imagesArray[0].GetString();
|
||||
}
|
||||
}
|
||||
|
||||
return new ExternalPlaylist
|
||||
{
|
||||
Id = Common.PlaylistIdHelper.CreatePlaylistId("qobuz", externalId),
|
||||
Name = playlist.TryGetProperty("name", out var name)
|
||||
? name.GetString() ?? ""
|
||||
: "",
|
||||
Description = playlist.TryGetProperty("description", out var desc)
|
||||
? desc.GetString()
|
||||
: null,
|
||||
CuratorName = curatorName,
|
||||
Provider = "qobuz",
|
||||
ExternalId = externalId,
|
||||
TrackCount = playlist.TryGetProperty("tracks_count", out var tracksCount)
|
||||
? tracksCount.GetInt32()
|
||||
: 0,
|
||||
Duration = playlist.TryGetProperty("duration", out var duration)
|
||||
? duration.GetInt32()
|
||||
: 0,
|
||||
CoverUrl = coverUrl,
|
||||
CreatedDate = createdDate
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Safely gets an ID value as a string, handling both number and string types from JSON
|
||||
/// </summary>
|
||||
|
||||
375
octo-fiesta/Services/Subsonic/PlaylistSyncService.cs
Normal file
375
octo-fiesta/Services/Subsonic/PlaylistSyncService.cs
Normal file
@@ -0,0 +1,375 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using octo_fiesta.Models.Domain;
|
||||
using octo_fiesta.Models.Settings;
|
||||
using octo_fiesta.Models.Subsonic;
|
||||
using octo_fiesta.Services.Common;
|
||||
using IOFile = System.IO.File;
|
||||
|
||||
namespace octo_fiesta.Services.Subsonic;
|
||||
|
||||
/// <summary>
|
||||
/// Service responsible for downloading playlist tracks and creating M3U files
|
||||
/// </summary>
|
||||
public class PlaylistSyncService
|
||||
{
|
||||
private readonly IMusicMetadataService _deezerMetadataService;
|
||||
private readonly IMusicMetadataService _qobuzMetadataService;
|
||||
private readonly IEnumerable<IDownloadService> _downloadServices;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly SubsonicSettings _subsonicSettings;
|
||||
private readonly ILogger<PlaylistSyncService> _logger;
|
||||
|
||||
// In-memory cache to track which playlist a track belongs to
|
||||
// Key: trackId (format: ext-{provider}-{externalId}), Value: playlistId
|
||||
// TTL: 5 minutes (tracks expire automatically)
|
||||
private readonly ConcurrentDictionary<string, (string PlaylistId, DateTime ExpiresAt)> _trackPlaylistCache = new();
|
||||
private static readonly TimeSpan CacheTTL = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly string _musicDirectory;
|
||||
private readonly string _playlistDirectory;
|
||||
|
||||
public PlaylistSyncService(
|
||||
IEnumerable<IMusicMetadataService> metadataServices,
|
||||
IEnumerable<IDownloadService> downloadServices,
|
||||
IConfiguration configuration,
|
||||
IOptions<SubsonicSettings> subsonicSettings,
|
||||
ILogger<PlaylistSyncService> logger)
|
||||
{
|
||||
// Get Deezer and Qobuz metadata services
|
||||
_deezerMetadataService = metadataServices.FirstOrDefault(s => s.GetType().Name.Contains("Deezer"))
|
||||
?? throw new InvalidOperationException("Deezer metadata service not found");
|
||||
_qobuzMetadataService = metadataServices.FirstOrDefault(s => s.GetType().Name.Contains("Qobuz"))
|
||||
?? throw new InvalidOperationException("Qobuz metadata service not found");
|
||||
|
||||
_downloadServices = downloadServices;
|
||||
_configuration = configuration;
|
||||
_subsonicSettings = subsonicSettings.Value;
|
||||
_logger = logger;
|
||||
|
||||
_musicDirectory = configuration["Library:DownloadPath"] ?? "./downloads";
|
||||
_playlistDirectory = Path.Combine(_musicDirectory, _subsonicSettings.PlaylistsDirectory ?? "playlists");
|
||||
|
||||
// Ensure playlists directory exists
|
||||
if (!Directory.Exists(_playlistDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(_playlistDirectory);
|
||||
}
|
||||
|
||||
// Start background cleanup task for expired cache entries
|
||||
_ = Task.Run(CleanupExpiredCacheEntriesAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a track to the playlist context cache.
|
||||
/// This allows the download service to know which playlist a track belongs to.
|
||||
/// </summary>
|
||||
public void AddTrackToPlaylistCache(string trackId, string playlistId)
|
||||
{
|
||||
var expiresAt = DateTime.UtcNow.Add(CacheTTL);
|
||||
_trackPlaylistCache[trackId] = (playlistId, expiresAt);
|
||||
_logger.LogDebug("Added track {TrackId} to playlist cache with playlistId {PlaylistId}", trackId, playlistId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the playlist ID for a given track ID from cache.
|
||||
/// Returns null if not found or expired.
|
||||
/// </summary>
|
||||
public string? GetPlaylistIdForTrack(string trackId)
|
||||
{
|
||||
if (_trackPlaylistCache.TryGetValue(trackId, out var entry))
|
||||
{
|
||||
if (entry.ExpiresAt > DateTime.UtcNow)
|
||||
{
|
||||
return entry.PlaylistId;
|
||||
}
|
||||
|
||||
// Expired, remove it
|
||||
_trackPlaylistCache.TryRemove(trackId, out _);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads all tracks from a playlist and creates an M3U file.
|
||||
/// This is triggered when a user stars a playlist.
|
||||
/// </summary>
|
||||
public async Task DownloadFullPlaylistAsync(string playlistId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting download for playlist {PlaylistId}", playlistId);
|
||||
|
||||
// Parse playlist ID
|
||||
if (!PlaylistIdHelper.IsExternalPlaylist(playlistId))
|
||||
{
|
||||
_logger.LogWarning("Invalid playlist ID format: {PlaylistId}", playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||
|
||||
// Get playlist metadata
|
||||
var metadataService = provider.ToLower() switch
|
||||
{
|
||||
"deezer" => _deezerMetadataService,
|
||||
"qobuz" => _qobuzMetadataService,
|
||||
_ => throw new NotSupportedException($"Provider '{provider}' not supported for playlists")
|
||||
};
|
||||
|
||||
var playlist = await metadataService.GetPlaylistAsync(provider, externalId);
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogWarning("Playlist not found: {PlaylistId}", playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
var tracks = await metadataService.GetPlaylistTracksAsync(provider, externalId);
|
||||
if (tracks == null || tracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No tracks found in playlist {PlaylistId}", playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {TrackCount} tracks in playlist '{PlaylistName}'", tracks.Count, playlist.Name);
|
||||
|
||||
// Get the appropriate download service for this provider
|
||||
var downloadService = _downloadServices.FirstOrDefault(s =>
|
||||
s.GetType().Name.Contains(provider, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (downloadService == null)
|
||||
{
|
||||
_logger.LogError("No download service found for provider '{Provider}'", provider);
|
||||
return;
|
||||
}
|
||||
|
||||
// Download all tracks
|
||||
var downloadedTracks = new List<(Song Song, string LocalPath)>();
|
||||
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(track.ExternalId))
|
||||
{
|
||||
_logger.LogWarning("Track has no external ID, skipping: {Title}", track.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add track to playlist cache BEFORE downloading
|
||||
var trackId = $"ext-{provider}-{track.ExternalId}";
|
||||
AddTrackToPlaylistCache(trackId, playlistId);
|
||||
|
||||
_logger.LogInformation("Downloading track '{Artist} - {Title}'", track.Artist, track.Title);
|
||||
var localPath = await downloadService.DownloadSongAsync(provider, track.ExternalId, cancellationToken);
|
||||
|
||||
downloadedTracks.Add((track, localPath));
|
||||
_logger.LogDebug("Downloaded: {Path}", localPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to download track '{Artist} - {Title}'", track.Artist, track.Title);
|
||||
}
|
||||
}
|
||||
|
||||
if (downloadedTracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No tracks were successfully downloaded for playlist '{PlaylistName}'", playlist.Name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create M3U file
|
||||
await CreateM3UPlaylistAsync(playlist.Name, downloadedTracks);
|
||||
|
||||
_logger.LogInformation("Playlist download completed: {DownloadedCount}/{TotalCount} tracks for '{PlaylistName}'",
|
||||
downloadedTracks.Count, tracks.Count, playlist.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to download playlist {PlaylistId}", playlistId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an M3U playlist file with relative paths to downloaded tracks
|
||||
/// </summary>
|
||||
private async Task CreateM3UPlaylistAsync(string playlistName, List<(Song Song, string LocalPath)> tracks)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Sanitize playlist name for file system
|
||||
var fileName = PathHelper.SanitizeFileName(playlistName) + ".m3u";
|
||||
var playlistPath = Path.Combine(_playlistDirectory, fileName);
|
||||
|
||||
var m3uContent = new StringBuilder();
|
||||
m3uContent.AppendLine("#EXTM3U");
|
||||
|
||||
foreach (var (song, localPath) in tracks)
|
||||
{
|
||||
// Calculate relative path from playlist directory to track
|
||||
var relativePath = Path.GetRelativePath(_playlistDirectory, localPath);
|
||||
|
||||
// Convert backslashes to forward slashes for M3U compatibility
|
||||
relativePath = relativePath.Replace('\\', '/');
|
||||
|
||||
// Add EXTINF line with duration and artist - title
|
||||
var duration = song.Duration ?? 0;
|
||||
m3uContent.AppendLine($"#EXTINF:{duration},{song.Artist} - {song.Title}");
|
||||
m3uContent.AppendLine(relativePath);
|
||||
}
|
||||
|
||||
await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString());
|
||||
_logger.LogInformation("Created M3U playlist: {Path}", playlistPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create M3U playlist for '{PlaylistName}'", playlistName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a track to an existing M3U playlist or creates it if it doesn't exist.
|
||||
/// This is called progressively as tracks are downloaded.
|
||||
/// The M3U is rebuilt in the correct playlist order each time.
|
||||
/// </summary>
|
||||
public async Task AddTrackToM3UAsync(string playlistId, Song track, string localPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get playlist metadata to get the name and track order
|
||||
if (!PlaylistIdHelper.IsExternalPlaylist(playlistId))
|
||||
{
|
||||
_logger.LogWarning("Invalid playlist ID format: {PlaylistId}", playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||
|
||||
var metadataService = provider.ToLower() switch
|
||||
{
|
||||
"deezer" => _deezerMetadataService,
|
||||
"qobuz" => _qobuzMetadataService,
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (metadataService == null)
|
||||
{
|
||||
_logger.LogWarning("No metadata service found for provider '{Provider}'", provider);
|
||||
return;
|
||||
}
|
||||
|
||||
var playlist = await metadataService.GetPlaylistAsync(provider, externalId);
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogWarning("Playlist not found: {PlaylistId}", playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all tracks from the playlist to maintain order
|
||||
var allPlaylistTracks = await metadataService.GetPlaylistTracksAsync(provider, externalId);
|
||||
if (allPlaylistTracks == null || allPlaylistTracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No tracks found in playlist: {PlaylistId}", playlistId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize playlist name for file system
|
||||
var fileName = PathHelper.SanitizeFileName(playlist.Name) + ".m3u";
|
||||
var playlistPath = Path.Combine(_playlistDirectory, fileName);
|
||||
|
||||
// Build M3U content in the correct order
|
||||
var m3uContent = new StringBuilder();
|
||||
m3uContent.AppendLine("#EXTM3U");
|
||||
|
||||
int addedCount = 0;
|
||||
foreach (var playlistTrack in allPlaylistTracks)
|
||||
{
|
||||
// Check if this track has been downloaded locally
|
||||
string? trackLocalPath = null;
|
||||
|
||||
// If this is the track we just downloaded
|
||||
if (playlistTrack.Id == track.Id)
|
||||
{
|
||||
trackLocalPath = localPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if track was previously downloaded
|
||||
var trackProvider = playlistTrack.ExternalProvider;
|
||||
var trackExternalId = playlistTrack.ExternalId;
|
||||
|
||||
if (!string.IsNullOrEmpty(trackProvider) && !string.IsNullOrEmpty(trackExternalId))
|
||||
{
|
||||
// Try to find the download service for this provider
|
||||
var downloadService = _downloadServices.FirstOrDefault(s =>
|
||||
s.GetType().Name.Contains(trackProvider, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (downloadService != null)
|
||||
{
|
||||
trackLocalPath = await downloadService.GetLocalPathIfExistsAsync(trackProvider, trackExternalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If track is downloaded, add it to M3U
|
||||
if (!string.IsNullOrEmpty(trackLocalPath) && IOFile.Exists(trackLocalPath))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(_playlistDirectory, trackLocalPath);
|
||||
relativePath = relativePath.Replace('\\', '/');
|
||||
|
||||
var duration = playlistTrack.Duration ?? 0;
|
||||
m3uContent.AppendLine($"#EXTINF:{duration},{playlistTrack.Artist} - {playlistTrack.Title}");
|
||||
m3uContent.AppendLine(relativePath);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Write the M3U file (overwrites existing)
|
||||
await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString());
|
||||
_logger.LogInformation("Updated M3U playlist '{PlaylistName}' with {Count} tracks (in correct order)",
|
||||
playlist.Name, addedCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to add track to M3U playlist");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background task to clean up expired cache entries every minute
|
||||
/// </summary>
|
||||
private async Task CleanupExpiredCacheEntriesAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(1));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var expiredKeys = _trackPlaylistCache
|
||||
.Where(kvp => kvp.Value.ExpiresAt <= now)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_trackPlaylistCache.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
if (expiredKeys.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Cleaned up {Count} expired playlist cache entries", expiredKeys.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error during playlist cache cleanup");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Linq;
|
||||
using octo_fiesta.Models.Search;
|
||||
using octo_fiesta.Models.Subsonic;
|
||||
|
||||
namespace octo_fiesta.Services.Subsonic;
|
||||
|
||||
@@ -97,22 +98,23 @@ public class SubsonicModelMapper
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges local search results with external search results, deduplicating by name.
|
||||
/// Merges local and external search results (songs, albums, artists, playlists).
|
||||
/// </summary>
|
||||
public (List<object> MergedSongs, List<object> MergedAlbums, List<object> MergedArtists) MergeSearchResults(
|
||||
List<object> localSongs,
|
||||
List<object> localAlbums,
|
||||
List<object> localArtists,
|
||||
SearchResult externalResult,
|
||||
List<ExternalPlaylist> externalPlaylists,
|
||||
bool isJson)
|
||||
{
|
||||
if (isJson)
|
||||
{
|
||||
return MergeSearchResultsJson(localSongs, localAlbums, localArtists, externalResult);
|
||||
return MergeSearchResultsJson(localSongs, localAlbums, localArtists, externalResult, externalPlaylists);
|
||||
}
|
||||
else
|
||||
{
|
||||
return MergeSearchResultsXml(localSongs, localAlbums, localArtists, externalResult);
|
||||
return MergeSearchResultsXml(localSongs, localAlbums, localArtists, externalResult, externalPlaylists);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,14 +122,17 @@ public class SubsonicModelMapper
|
||||
List<object> localSongs,
|
||||
List<object> localAlbums,
|
||||
List<object> localArtists,
|
||||
SearchResult externalResult)
|
||||
SearchResult externalResult,
|
||||
List<ExternalPlaylist> externalPlaylists)
|
||||
{
|
||||
var mergedSongs = localSongs
|
||||
.Concat(externalResult.Songs.Select(s => _responseBuilder.ConvertSongToJson(s)))
|
||||
.ToList();
|
||||
|
||||
// Merge albums with playlists (playlists appear as albums with genre "Playlist")
|
||||
var mergedAlbums = localAlbums
|
||||
.Concat(externalResult.Albums.Select(a => _responseBuilder.ConvertAlbumToJson(a)))
|
||||
.Concat(externalPlaylists.Select(p => ConvertPlaylistToAlbumJson(p)))
|
||||
.ToList();
|
||||
|
||||
// Deduplicate artists by name - prefer local artists over external ones
|
||||
@@ -157,7 +162,8 @@ public class SubsonicModelMapper
|
||||
List<object> localSongs,
|
||||
List<object> localAlbums,
|
||||
List<object> localArtists,
|
||||
SearchResult externalResult)
|
||||
SearchResult externalResult,
|
||||
List<ExternalPlaylist> externalPlaylists)
|
||||
{
|
||||
var ns = XNamespace.Get("http://subsonic.org/restapi");
|
||||
|
||||
@@ -196,6 +202,11 @@ public class SubsonicModelMapper
|
||||
{
|
||||
mergedAlbums.Add(_responseBuilder.ConvertAlbumToXml(album, ns));
|
||||
}
|
||||
// Add playlists as albums
|
||||
foreach (var playlist in externalPlaylists)
|
||||
{
|
||||
mergedAlbums.Add(ConvertPlaylistToAlbumXml(playlist, ns));
|
||||
}
|
||||
|
||||
// Songs
|
||||
var mergedSongs = new List<object>();
|
||||
@@ -211,4 +222,81 @@ public class SubsonicModelMapper
|
||||
|
||||
return (mergedSongs, mergedAlbums, mergedArtists);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an ExternalPlaylist to a JSON object representing an album.
|
||||
/// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}".
|
||||
/// </summary>
|
||||
private Dictionary<string, object> ConvertPlaylistToAlbumJson(ExternalPlaylist playlist)
|
||||
{
|
||||
var artistName = $"🎵 {char.ToUpper(playlist.Provider[0])}{playlist.Provider.Substring(1)}";
|
||||
if (!string.IsNullOrEmpty(playlist.CuratorName))
|
||||
{
|
||||
artistName += $" {playlist.CuratorName}";
|
||||
}
|
||||
|
||||
var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}";
|
||||
|
||||
var album = new Dictionary<string, object>
|
||||
{
|
||||
["id"] = playlist.Id,
|
||||
["name"] = playlist.Name,
|
||||
["artist"] = artistName,
|
||||
["artistId"] = artistId,
|
||||
["genre"] = "Playlist",
|
||||
["songCount"] = playlist.TrackCount,
|
||||
["duration"] = playlist.Duration
|
||||
};
|
||||
|
||||
if (playlist.CreatedDate.HasValue)
|
||||
{
|
||||
album["year"] = playlist.CreatedDate.Value.Year;
|
||||
album["created"] = playlist.CreatedDate.Value.ToString("yyyy-MM-ddTHH:mm:ss");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(playlist.CoverUrl))
|
||||
{
|
||||
album["coverArt"] = playlist.Id;
|
||||
}
|
||||
|
||||
return album;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an ExternalPlaylist to an XML element representing an album.
|
||||
/// Playlists are represented as albums with genre "Playlist" and artist "🎵 {Provider} {Curator}".
|
||||
/// </summary>
|
||||
private XElement ConvertPlaylistToAlbumXml(ExternalPlaylist playlist, XNamespace ns)
|
||||
{
|
||||
var artistName = $"🎵 {char.ToUpper(playlist.Provider[0])}{playlist.Provider.Substring(1)}";
|
||||
if (!string.IsNullOrEmpty(playlist.CuratorName))
|
||||
{
|
||||
artistName += $" {playlist.CuratorName}";
|
||||
}
|
||||
|
||||
var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}";
|
||||
|
||||
var album = new XElement(ns + "album",
|
||||
new XAttribute("id", playlist.Id),
|
||||
new XAttribute("name", playlist.Name),
|
||||
new XAttribute("artist", artistName),
|
||||
new XAttribute("artistId", artistId),
|
||||
new XAttribute("genre", "Playlist"),
|
||||
new XAttribute("songCount", playlist.TrackCount),
|
||||
new XAttribute("duration", playlist.Duration)
|
||||
);
|
||||
|
||||
if (playlist.CreatedDate.HasValue)
|
||||
{
|
||||
album.Add(new XAttribute("year", playlist.CreatedDate.Value.Year));
|
||||
album.Add(new XAttribute("created", playlist.CreatedDate.Value.ToString("yyyy-MM-ddTHH:mm:ss")));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(playlist.CoverUrl))
|
||||
{
|
||||
album.Add(new XAttribute("coverArt", playlist.Id));
|
||||
}
|
||||
|
||||
return album;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using System.Xml.Linq;
|
||||
using System.Text.Json;
|
||||
using octo_fiesta.Models.Domain;
|
||||
using octo_fiesta.Models.Subsonic;
|
||||
|
||||
namespace octo_fiesta.Services.Subsonic;
|
||||
|
||||
@@ -137,6 +138,81 @@ public class SubsonicResponseBuilder
|
||||
);
|
||||
return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Subsonic response for a playlist represented as an album.
|
||||
/// Playlists appear as albums with genre "Playlist".
|
||||
/// </summary>
|
||||
public IActionResult CreatePlaylistAsAlbumResponse(string format, ExternalPlaylist playlist, List<Song> tracks)
|
||||
{
|
||||
var totalDuration = tracks.Sum(s => s.Duration ?? 0);
|
||||
|
||||
// Build artist name with emoji and curator
|
||||
var artistName = $"🎵 {char.ToUpper(playlist.Provider[0])}{playlist.Provider.Substring(1)}";
|
||||
if (!string.IsNullOrEmpty(playlist.CuratorName))
|
||||
{
|
||||
artistName += $" {playlist.CuratorName}";
|
||||
}
|
||||
|
||||
var artistId = $"curator-{playlist.Provider}-{playlist.CuratorName?.ToLowerInvariant().Replace(" ", "-") ?? "unknown"}";
|
||||
|
||||
if (format == "json")
|
||||
{
|
||||
return CreateJsonResponse(new
|
||||
{
|
||||
status = "ok",
|
||||
version = SubsonicVersion,
|
||||
album = new
|
||||
{
|
||||
id = playlist.Id,
|
||||
name = playlist.Name,
|
||||
artist = artistName,
|
||||
artistId = artistId,
|
||||
coverArt = playlist.Id,
|
||||
songCount = tracks.Count,
|
||||
duration = totalDuration,
|
||||
year = playlist.CreatedDate?.Year ?? 0,
|
||||
genre = "Playlist",
|
||||
isCompilation = false,
|
||||
created = playlist.CreatedDate?.ToString("yyyy-MM-ddTHH:mm:ss"),
|
||||
song = tracks.Select(s => ConvertSongToJson(s)).ToList()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var ns = XNamespace.Get(SubsonicNamespace);
|
||||
var albumElement = new XElement(ns + "album",
|
||||
new XAttribute("id", playlist.Id),
|
||||
new XAttribute("name", playlist.Name),
|
||||
new XAttribute("artist", artistName),
|
||||
new XAttribute("artistId", artistId),
|
||||
new XAttribute("songCount", tracks.Count),
|
||||
new XAttribute("duration", totalDuration),
|
||||
new XAttribute("genre", "Playlist"),
|
||||
new XAttribute("coverArt", playlist.Id)
|
||||
);
|
||||
|
||||
if (playlist.CreatedDate.HasValue)
|
||||
{
|
||||
albumElement.Add(new XAttribute("year", playlist.CreatedDate.Value.Year));
|
||||
albumElement.Add(new XAttribute("created", playlist.CreatedDate.Value.ToString("yyyy-MM-ddTHH:mm:ss")));
|
||||
}
|
||||
|
||||
// Add songs
|
||||
foreach (var song in tracks)
|
||||
{
|
||||
albumElement.Add(ConvertSongToXml(song, ns));
|
||||
}
|
||||
|
||||
var doc = new XDocument(
|
||||
new XElement(ns + "subsonic-response",
|
||||
new XAttribute("status", "ok"),
|
||||
new XAttribute("version", SubsonicVersion),
|
||||
albumElement
|
||||
)
|
||||
);
|
||||
return new ContentResult { Content = doc.ToString(), ContentType = "application/xml" };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Subsonic response containing an artist with albums.
|
||||
|
||||
Reference in New Issue
Block a user