feat: playlist implementation

This commit is contained in:
V1ck3s
2026-01-14 23:18:27 +01:00
committed by Vickes
parent 2c5daeefed
commit ebe6e90f39
21 changed files with 2561 additions and 54 deletions

View 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");
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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.