mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 03:53:10 -04:00
986 lines
41 KiB
C#
986 lines
41 KiB
C#
using System.Text.Json;
|
|
using allstarr.Models.Domain;
|
|
using allstarr.Models.Spotify;
|
|
using allstarr.Services.Admin;
|
|
using allstarr.Services.Common;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace allstarr.Controllers;
|
|
|
|
public partial class JellyfinController
|
|
{
|
|
#region Spotify Playlist Injection
|
|
|
|
/// <summary>
|
|
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers
|
|
/// and merging with existing local tracks from Jellyfin.
|
|
///
|
|
/// Supports two modes:
|
|
/// 1. Direct Spotify API (new): Uses SpotifyPlaylistFetcher for ordered tracks with ISRC matching
|
|
/// 2. Jellyfin Plugin (legacy): Uses MissingTrack data from Jellyfin Spotify Import plugin
|
|
/// </summary>
|
|
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
|
|
{
|
|
try
|
|
{
|
|
// Only inject tracks if Spotify API is enabled
|
|
if (_spotifyApiSettings.Enabled && _spotifyPlaylistFetcher != null)
|
|
{
|
|
var orderedResult = await GetSpotifyPlaylistTracksOrderedAsync(spotifyPlaylistName, playlistId);
|
|
if (orderedResult != null) return orderedResult;
|
|
}
|
|
|
|
// Spotify API not enabled or no ordered tracks - proxy through without modification
|
|
_logger.LogDebug("Spotify API not enabled or no tracks found, proxying playlist {PlaylistName} without modification",
|
|
spotifyPlaylistName);
|
|
|
|
var endpoint = $"Playlists/{playlistId}/Items";
|
|
if (Request.QueryString.HasValue)
|
|
{
|
|
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
|
}
|
|
|
|
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
|
return HandleProxyResponse(result, statusCode);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName);
|
|
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// New mode: Gets playlist tracks with correct ordering using direct Spotify API data.
|
|
/// Optimized to only re-match when Jellyfin playlist changes (cheap check).
|
|
/// </summary>
|
|
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName,
|
|
string playlistId)
|
|
{
|
|
// Check if Jellyfin playlist has changed (cheap API call)
|
|
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}";
|
|
var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
|
|
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
|
|
|
|
var jellyfinPlaylistChanged = cachedJellyfinSignature != currentJellyfinSignature;
|
|
|
|
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
|
|
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
|
|
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
|
|
|
|
if (cachedItems != null && cachedItems.Count > 0 && !jellyfinPlaylistChanged)
|
|
{
|
|
_logger.LogDebug("✅ Loaded {Count} playlist items from Redis cache for {Playlist} (Jellyfin unchanged)",
|
|
cachedItems.Count, spotifyPlaylistName);
|
|
|
|
return new JsonResult(new
|
|
{
|
|
Items = cachedItems,
|
|
TotalRecordCount = cachedItems.Count,
|
|
StartIndex = 0
|
|
});
|
|
}
|
|
|
|
if (jellyfinPlaylistChanged)
|
|
{
|
|
_logger.LogInformation("🔄 Jellyfin playlist changed for {Playlist} - re-matching tracks",
|
|
spotifyPlaylistName);
|
|
}
|
|
|
|
// Check file cache as fallback
|
|
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
|
|
if (fileItems != null && fileItems.Count > 0)
|
|
{
|
|
_logger.LogDebug("✅ Loaded {Count} playlist items from file cache for {Playlist}",
|
|
fileItems.Count, spotifyPlaylistName);
|
|
|
|
// Restore to Redis cache
|
|
await _cache.SetAsync(cacheKey, fileItems, CacheExtensions.SpotifyPlaylistItemsTTL);
|
|
|
|
return new JsonResult(new
|
|
{
|
|
Items = fileItems,
|
|
TotalRecordCount = fileItems.Count,
|
|
StartIndex = 0
|
|
});
|
|
}
|
|
|
|
// Check for ordered matched tracks from SpotifyTrackMatchingService
|
|
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(spotifyPlaylistName);
|
|
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
|
|
|
if (orderedTracks == null || orderedTracks.Count == 0)
|
|
{
|
|
_logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
|
|
spotifyPlaylistName);
|
|
return null; // Fall back to legacy mode
|
|
}
|
|
|
|
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
|
orderedTracks.Count, spotifyPlaylistName);
|
|
|
|
// Get existing Jellyfin playlist items (RAW - don't convert!)
|
|
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
|
var userId = _settings.UserId;
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
_logger.LogError(
|
|
"❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI.");
|
|
return null; // Fall back to legacy mode
|
|
}
|
|
|
|
// Pass through all requested fields from the original request
|
|
var queryString = Request.QueryString.Value ?? "";
|
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
|
|
|
|
// Append the original query string (which includes Fields parameter)
|
|
if (!string.IsNullOrEmpty(queryString))
|
|
{
|
|
// Remove the leading ? if present
|
|
queryString = queryString.TrimStart('?');
|
|
playlistItemsUrl = $"{playlistItemsUrl}&{queryString}";
|
|
}
|
|
|
|
_logger.LogDebug("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
|
playlistId, userId);
|
|
|
|
var (existingTracksResponse, statusCode) = await _proxyService.GetJsonAsync(
|
|
playlistItemsUrl,
|
|
null,
|
|
Request.Headers);
|
|
|
|
if (statusCode != 200)
|
|
{
|
|
_logger.LogError(
|
|
"❌ Failed to fetch Jellyfin playlist items: HTTP {StatusCode}. Check JELLYFIN_USER_ID is correct.",
|
|
statusCode);
|
|
return null;
|
|
}
|
|
|
|
// Keep raw Jellyfin items - don't convert to Song objects!
|
|
var jellyfinItems = new List<JsonElement>();
|
|
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
|
|
|
|
if (existingTracksResponse != null &&
|
|
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
|
{
|
|
foreach (var item in items.EnumerateArray())
|
|
{
|
|
jellyfinItems.Add(item);
|
|
|
|
// Index by title+artist for matching
|
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
|
var artist = "";
|
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
|
{
|
|
artist = artistsEl[0].GetString() ?? "";
|
|
}
|
|
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
|
{
|
|
artist = albumArtistEl.GetString() ?? "";
|
|
}
|
|
|
|
var key = $"{title}|{artist}".ToLowerInvariant();
|
|
if (!jellyfinItemsByName.ContainsKey(key))
|
|
{
|
|
jellyfinItemsByName[key] = item;
|
|
}
|
|
}
|
|
|
|
_logger.LogDebug("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty",
|
|
playlistId);
|
|
}
|
|
|
|
// Get the full playlist from Spotify to know the correct order
|
|
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
|
|
if (spotifyTracks.Count == 0)
|
|
{
|
|
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
|
|
return null; // Fall back to legacy
|
|
}
|
|
|
|
// Build the final track list in correct Spotify order
|
|
var finalItems = new List<Dictionary<string, object?>>();
|
|
var usedJellyfinItems = new HashSet<string>();
|
|
var localUsedCount = 0;
|
|
var externalUsedCount = 0;
|
|
|
|
_logger.LogDebug("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
|
|
|
|
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
|
{
|
|
// Try to find matching Jellyfin item by fuzzy matching
|
|
JsonElement? matchedJellyfinItem = null;
|
|
string? matchedKey = null;
|
|
double bestScore = 0;
|
|
|
|
foreach (var kvp in jellyfinItemsByName)
|
|
{
|
|
if (usedJellyfinItems.Contains(kvp.Key)) continue;
|
|
|
|
var item = kvp.Value;
|
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
|
var artist = "";
|
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
|
{
|
|
artist = artistsEl[0].GetString() ?? "";
|
|
}
|
|
|
|
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
|
|
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
|
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
|
|
|
if (totalScore > bestScore && totalScore >= 70)
|
|
{
|
|
bestScore = totalScore;
|
|
matchedJellyfinItem = item;
|
|
matchedKey = kvp.Key;
|
|
}
|
|
}
|
|
|
|
if (matchedJellyfinItem.HasValue && matchedKey != null)
|
|
{
|
|
// Use the raw Jellyfin item (preserves ALL metadata including MediaSources!)
|
|
var itemDict = JsonElementToDictionary(matchedJellyfinItem.Value);
|
|
ProviderIdsEnricher.EnsureSpotifyProviderIds(itemDict, spotifyTrack.SpotifyId, spotifyTrack.AlbumId);
|
|
ApplySpotifyAddedAtDateCreated(itemDict, spotifyTrack.AddedAt);
|
|
finalItems.Add(itemDict);
|
|
usedJellyfinItems.Add(matchedKey);
|
|
localUsedCount++;
|
|
_logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL (score: {Score:F1}%)",
|
|
spotifyTrack.Position, spotifyTrack.Title, bestScore);
|
|
}
|
|
else
|
|
{
|
|
// No local match via fuzzy matching - try to find in orderedTracks cache
|
|
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
|
if (matched != null && matched.MatchedSong != null)
|
|
{
|
|
// Check if this is a LOCAL track that we should fetch from Jellyfin
|
|
if (matched.MatchedSong.IsLocal && !string.IsNullOrEmpty(matched.MatchedSong.Id))
|
|
{
|
|
// Try to find the full Jellyfin item by ID
|
|
var jellyfinItem = jellyfinItems.FirstOrDefault(item =>
|
|
item.TryGetProperty("Id", out var idProp) &&
|
|
idProp.GetString() == matched.MatchedSong.Id);
|
|
|
|
if (jellyfinItem.ValueKind != JsonValueKind.Undefined)
|
|
{
|
|
// Found the full Jellyfin item - use it!
|
|
var itemDict = JsonElementToDictionary(jellyfinItem);
|
|
ProviderIdsEnricher.EnsureSpotifyProviderIds(itemDict, spotifyTrack.SpotifyId,
|
|
spotifyTrack.AlbumId);
|
|
ApplySpotifyAddedAtDateCreated(itemDict, spotifyTrack.AddedAt);
|
|
finalItems.Add(itemDict);
|
|
localUsedCount++;
|
|
_logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL from cache (ID: {Id})",
|
|
spotifyTrack.Position, spotifyTrack.Title, matched.MatchedSong.Id);
|
|
continue;
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(
|
|
"⚠️ Position #{Pos}: '{Title}' marked as LOCAL but not found in Jellyfin items (ID: {Id})",
|
|
spotifyTrack.Position, spotifyTrack.Title, matched.MatchedSong.Id);
|
|
}
|
|
}
|
|
|
|
// External track or local track not found - convert Song to Jellyfin item format
|
|
var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
|
|
|
|
// Enhance with additional Spotify metadata
|
|
ProviderIdsEnricher.EnsureSpotifyProviderIds(externalItem, spotifyTrack.SpotifyId,
|
|
spotifyTrack.AlbumId);
|
|
|
|
ApplySpotifyAddedAtDateCreated(externalItem, spotifyTrack.AddedAt);
|
|
|
|
finalItems.Add(externalItem);
|
|
externalUsedCount++;
|
|
_logger.LogDebug(
|
|
"📥 Position #{Pos}: '{Title}' → EXTERNAL: {Provider}/{Id} (Spotify ID: {SpotifyId})",
|
|
spotifyTrack.Position, spotifyTrack.Title,
|
|
matched.MatchedSong.ExternalProvider, matched.MatchedSong.ExternalId, spotifyTrack.SpotifyId);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug("❌ Position #{Pos}: '{Title}' → NO MATCH",
|
|
spotifyTrack.Position, spotifyTrack.Title);
|
|
}
|
|
}
|
|
}
|
|
|
|
_logger.LogDebug("🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
|
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
|
|
|
|
// Save to file cache for persistence across restarts
|
|
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
|
|
|
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
|
|
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
|
|
|
|
// Cache the Jellyfin playlist signature to detect future changes
|
|
await _cache.SetAsync(jellyfinSignatureCacheKey, currentJellyfinSignature,
|
|
CacheExtensions.SpotifyPlaylistItemsTTL);
|
|
|
|
// Return raw Jellyfin response format
|
|
return new JsonResult(new
|
|
{
|
|
Items = finalItems,
|
|
TotalRecordCount = finalItems.Count,
|
|
StartIndex = 0
|
|
});
|
|
}
|
|
|
|
private static void ApplySpotifyAddedAtDateCreated(
|
|
Dictionary<string, object?> item,
|
|
DateTime? addedAt)
|
|
{
|
|
if (!addedAt.HasValue)
|
|
{
|
|
return;
|
|
}
|
|
|
|
item["DateCreated"] = addedAt.Value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ");
|
|
}
|
|
|
|
/// <summary>
|
|
/// <summary>
|
|
/// Copies an external track to the kept folder when favorited.
|
|
/// </summary>
|
|
private async Task CopyExternalTrackToKeptAsync(string itemId, string provider, string externalId)
|
|
{
|
|
try
|
|
{
|
|
// Check if already favorited (persistent tracking)
|
|
if (await IsTrackFavoritedAsync(itemId))
|
|
{
|
|
_logger.LogInformation("Track already favorited (persistent): {ItemId}", itemId);
|
|
return;
|
|
}
|
|
|
|
// Get the song metadata first to build paths
|
|
var song = await _metadataService.GetSongAsync(provider, externalId);
|
|
if (song == null)
|
|
{
|
|
_logger.LogWarning("Could not find song metadata for {ItemId}", itemId);
|
|
return;
|
|
}
|
|
|
|
// Build kept folder path: Artist/Album/
|
|
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
|
var keptArtistPath = Path.Combine(keptBasePath, AdminHelperService.SanitizeFileName(song.Artist));
|
|
var keptAlbumPath = Path.Combine(keptArtistPath, AdminHelperService.SanitizeFileName(song.Album));
|
|
|
|
// Check if track already exists in kept folder
|
|
if (Directory.Exists(keptAlbumPath))
|
|
{
|
|
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
|
|
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
|
|
if (existingFiles.Length > 0)
|
|
{
|
|
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
|
|
// Mark as favorited even if we didn't download it
|
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Look for the track in cache folder first
|
|
var cacheBasePath = "/tmp/allstarr-cache";
|
|
var cacheArtistPath = Path.Combine(cacheBasePath, AdminHelperService.SanitizeFileName(song.Artist));
|
|
var cacheAlbumPath = Path.Combine(cacheArtistPath, AdminHelperService.SanitizeFileName(song.Album));
|
|
|
|
string? sourceFilePath = null;
|
|
|
|
if (Directory.Exists(cacheAlbumPath))
|
|
{
|
|
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
|
|
var cacheFiles = Directory.GetFiles(cacheAlbumPath, $"*{sanitizedTitle}*");
|
|
if (cacheFiles.Length > 0)
|
|
{
|
|
sourceFilePath = cacheFiles[0];
|
|
_logger.LogDebug("Found track in cache folder: {Path}", sourceFilePath);
|
|
}
|
|
}
|
|
|
|
// If not in cache, download it first
|
|
if (sourceFilePath == null)
|
|
{
|
|
_logger.LogInformation("Track not in cache, downloading: {ItemId}", itemId);
|
|
try
|
|
{
|
|
// Use CancellationToken.None to ensure download completes even if user navigates away
|
|
sourceFilePath =
|
|
await _downloadService.DownloadSongAsync(provider, externalId, CancellationToken.None);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to download track {ItemId}", itemId);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Create the kept folder structure
|
|
Directory.CreateDirectory(keptAlbumPath);
|
|
|
|
// Copy file to kept folder
|
|
var fileName = Path.GetFileName(sourceFilePath);
|
|
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
|
|
|
|
// Create hard link instead of copying to save space
|
|
// Both locations will point to the same file data on disk
|
|
try
|
|
{
|
|
// Use ln command on Unix systems for hard links
|
|
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
|
{
|
|
var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = "ln",
|
|
Arguments = $"\"{sourceFilePath}\" \"{keptFilePath}\"",
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false
|
|
});
|
|
|
|
if (process != null)
|
|
{
|
|
await process.WaitForExitAsync();
|
|
|
|
// Check if link was created successfully
|
|
if (process.ExitCode != 0)
|
|
{
|
|
throw new IOException($"ln command failed with exit code {process.ExitCode}");
|
|
}
|
|
|
|
_logger.LogInformation("🔗 Created hard link: {Source} → {Destination}", sourceFilePath, keptFilePath);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Fall back to copy on Windows
|
|
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
|
|
_logger.LogInformation("📋 Copied track: {Source} → {Destination}", sourceFilePath, keptFilePath);
|
|
}
|
|
}
|
|
catch (IOException ex) when (ex.Message.Contains("already exists") || System.IO.File.Exists(keptFilePath))
|
|
{
|
|
// Race condition - file was created by another request
|
|
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
|
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
|
return;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Fall back to copy if hard link fails (e.g., different filesystems)
|
|
_logger.LogWarning(ex, "Failed to create hard link, falling back to copy");
|
|
|
|
try
|
|
{
|
|
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
|
|
_logger.LogInformation("📋 Copied track (fallback): {Source} → {Destination}", sourceFilePath, keptFilePath);
|
|
}
|
|
catch (IOException copyEx) when (copyEx.Message.Contains("already exists") || System.IO.File.Exists(keptFilePath))
|
|
{
|
|
// Race condition on copy fallback
|
|
_logger.LogInformation("Track already exists in kept folder (race condition on copy): {Path}", keptFilePath);
|
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Also create hard link for cover art if it exists
|
|
var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
|
|
if (System.IO.File.Exists(sourceCoverPath))
|
|
{
|
|
var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
|
|
if (!System.IO.File.Exists(keptCoverPath))
|
|
{
|
|
try
|
|
{
|
|
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
|
{
|
|
var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
|
{
|
|
FileName = "ln",
|
|
Arguments = $"\"{sourceCoverPath}\" \"{keptCoverPath}\"",
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false
|
|
});
|
|
|
|
if (process != null)
|
|
{
|
|
await process.WaitForExitAsync();
|
|
_logger.LogDebug("🔗 Created hard link for cover: {Source} → {Destination}", sourceCoverPath, keptCoverPath);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false);
|
|
_logger.LogDebug("📋 Copied cover: {Source} → {Destination}", sourceCoverPath, keptCoverPath);
|
|
}
|
|
}
|
|
catch (IOException ex) when (ex.Message.Contains("already exists") || System.IO.File.Exists(keptCoverPath))
|
|
{
|
|
// Race condition - cover already exists
|
|
_logger.LogDebug("Cover art already exists (race condition)");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Fall back to copy if hard link fails
|
|
_logger.LogDebug(ex, "Failed to create hard link for cover, falling back to copy");
|
|
|
|
try
|
|
{
|
|
System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false);
|
|
_logger.LogDebug("📋 Copied cover (fallback): {Source} → {Destination}", sourceCoverPath, keptCoverPath);
|
|
}
|
|
catch (IOException copyEx) when (copyEx.Message.Contains("already exists") || System.IO.File.Exists(keptCoverPath))
|
|
{
|
|
// Race condition on copy fallback
|
|
_logger.LogDebug("Cover art already exists (race condition on copy)");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark as favorited in persistent storage
|
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error copying external track {ItemId} to kept folder", itemId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Copies an external album (all tracks) to the kept folder when favorited.
|
|
/// </summary>
|
|
private async Task CopyExternalAlbumToKeptAsync(string itemId, string provider, string externalId)
|
|
{
|
|
try
|
|
{
|
|
// Get the album metadata with all tracks
|
|
var album = await _metadataService.GetAlbumAsync(provider, externalId);
|
|
if (album == null)
|
|
{
|
|
_logger.LogWarning("Could not find album metadata for {ItemId}", itemId);
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Downloading {Count} tracks from album: {Artist} - {Album}",
|
|
album.Songs.Count, album.Artist, album.Title);
|
|
|
|
// Download all tracks in the album
|
|
var downloadTasks = album.Songs.Select(async song =>
|
|
{
|
|
try
|
|
{
|
|
var songItemId = song.Id; // Already in format: ext-provider-song-id
|
|
var (_, songProvider, songExternalId) = _localLibraryService.ParseSongId(songItemId);
|
|
|
|
if (songProvider != null && songExternalId != null)
|
|
{
|
|
await CopyExternalTrackToKeptAsync(songItemId, songProvider, songExternalId);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to download track: {Title}", song.Title);
|
|
}
|
|
});
|
|
|
|
await Task.WhenAll(downloadTasks);
|
|
|
|
_logger.LogInformation("Finished downloading album: {Artist} - {Album}", album.Artist, album.Title);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error copying external album {ItemId} to kept folder", itemId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes an external track from the kept folder when unfavorited.
|
|
/// </summary>
|
|
private async Task RemoveExternalTrackFromKeptAsync(string itemId, string provider, string externalId)
|
|
{
|
|
try
|
|
{
|
|
// Mark for deletion instead of immediate deletion
|
|
await MarkTrackForDeletionAsync(itemId);
|
|
_logger.LogInformation("✓ Marked track for deletion: {ItemId}", itemId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error marking external track {ItemId} for deletion", itemId);
|
|
}
|
|
}
|
|
|
|
#region Persistent Favorites Tracking
|
|
|
|
private readonly string _favoritesFilePath = "/app/cache/favorites.json";
|
|
|
|
/// <summary>
|
|
/// Checks if a track is already favorited (persistent across restarts).
|
|
/// </summary>
|
|
private async Task<bool> IsTrackFavoritedAsync(string itemId)
|
|
{
|
|
try
|
|
{
|
|
if (!System.IO.File.Exists(_favoritesFilePath))
|
|
return false;
|
|
|
|
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
|
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
|
|
|
return favorites.ContainsKey(itemId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to check favorite status for {ItemId}", itemId);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks a track as favorited in persistent storage.
|
|
/// </summary>
|
|
private async Task MarkTrackAsFavoritedAsync(string itemId, Song song)
|
|
{
|
|
try
|
|
{
|
|
var favorites = new Dictionary<string, FavoriteTrackInfo>();
|
|
|
|
if (System.IO.File.Exists(_favoritesFilePath))
|
|
{
|
|
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
|
favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
|
}
|
|
|
|
favorites[itemId] = new FavoriteTrackInfo
|
|
{
|
|
ItemId = itemId,
|
|
Title = song.Title,
|
|
Artist = song.Artist,
|
|
Album = song.Album,
|
|
FavoritedAt = DateTime.UtcNow
|
|
};
|
|
|
|
// Ensure cache directory exists
|
|
Directory.CreateDirectory(Path.GetDirectoryName(_favoritesFilePath)!);
|
|
|
|
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
|
|
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
|
|
|
|
_logger.LogDebug("Marked track as favorited: {ItemId}", itemId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to mark track as favorited: {ItemId}", itemId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a track from persistent favorites storage.
|
|
/// </summary>
|
|
private async Task UnmarkTrackAsFavoritedAsync(string itemId)
|
|
{
|
|
try
|
|
{
|
|
if (!System.IO.File.Exists(_favoritesFilePath))
|
|
return;
|
|
|
|
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
|
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
|
|
|
if (favorites.Remove(itemId))
|
|
{
|
|
var updatedJson =
|
|
JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
|
|
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
|
|
_logger.LogDebug("Removed track from favorites: {ItemId}", itemId);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to remove track from favorites: {ItemId}", itemId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks a track for deletion (delayed deletion for safety).
|
|
/// </summary>
|
|
private async Task MarkTrackForDeletionAsync(string itemId)
|
|
{
|
|
try
|
|
{
|
|
var deletionFilePath = "/app/cache/pending_deletions.json";
|
|
var pendingDeletions = new Dictionary<string, DateTime>();
|
|
|
|
if (System.IO.File.Exists(deletionFilePath))
|
|
{
|
|
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
|
|
pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
|
|
}
|
|
|
|
// Mark for deletion 24 hours from now
|
|
pendingDeletions[itemId] = DateTime.UtcNow.AddHours(24);
|
|
|
|
// Ensure cache directory exists
|
|
Directory.CreateDirectory(Path.GetDirectoryName(deletionFilePath)!);
|
|
|
|
var updatedJson =
|
|
JsonSerializer.Serialize(pendingDeletions, new JsonSerializerOptions { WriteIndented = true });
|
|
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
|
|
|
// Also remove from favorites immediately
|
|
await UnmarkTrackAsFavoritedAsync(itemId);
|
|
|
|
_logger.LogDebug("Marked track for deletion in 24 hours: {ItemId}", itemId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to mark track for deletion: {ItemId}", itemId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Information about a favorited track for persistent storage.
|
|
/// </summary>
|
|
private class FavoriteTrackInfo
|
|
{
|
|
public string ItemId { get; set; } = "";
|
|
public string Title { get; set; } = "";
|
|
public string Artist { get; set; } = "";
|
|
public string Album { get; set; } = "";
|
|
public DateTime FavoritedAt { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes pending deletions (called by cleanup service).
|
|
/// </summary>
|
|
public async Task ProcessPendingDeletionsAsync()
|
|
{
|
|
try
|
|
{
|
|
var deletionFilePath = "/app/cache/pending_deletions.json";
|
|
if (!System.IO.File.Exists(deletionFilePath))
|
|
return;
|
|
|
|
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
|
|
var pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
|
|
|
|
var now = DateTime.UtcNow;
|
|
var toDelete = pendingDeletions.Where(kvp => kvp.Value <= now).ToList();
|
|
var remaining = pendingDeletions.Where(kvp => kvp.Value > now)
|
|
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
|
|
|
foreach (var (itemId, _) in toDelete)
|
|
{
|
|
await ActuallyDeleteTrackAsync(itemId);
|
|
}
|
|
|
|
if (toDelete.Count > 0)
|
|
{
|
|
// Update pending deletions file
|
|
var updatedJson =
|
|
JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
|
|
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
|
|
|
_logger.LogDebug("Processed {Count} pending deletions", toDelete.Count);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error processing pending deletions");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Actually deletes a track from the kept folder.
|
|
/// </summary>
|
|
private async Task ActuallyDeleteTrackAsync(string itemId)
|
|
{
|
|
try
|
|
{
|
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
|
if (!isExternal) return;
|
|
|
|
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
|
if (song == null) return;
|
|
|
|
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
|
var keptArtistPath = Path.Combine(keptBasePath, AdminHelperService.SanitizeFileName(song.Artist));
|
|
var keptAlbumPath = Path.Combine(keptArtistPath, AdminHelperService.SanitizeFileName(song.Album));
|
|
|
|
if (!Directory.Exists(keptAlbumPath)) return;
|
|
|
|
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
|
|
var trackFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
|
|
|
|
foreach (var trackFile in trackFiles)
|
|
{
|
|
System.IO.File.Delete(trackFile);
|
|
_logger.LogDebug("✓ Deleted track from kept folder: {Path}", trackFile);
|
|
}
|
|
|
|
// Clean up empty directories
|
|
if (Directory.GetFiles(keptAlbumPath).Length == 0 && Directory.GetDirectories(keptAlbumPath).Length == 0)
|
|
{
|
|
Directory.Delete(keptAlbumPath);
|
|
|
|
if (Directory.Exists(keptArtistPath) &&
|
|
Directory.GetFiles(keptArtistPath).Length == 0 &&
|
|
Directory.GetDirectories(keptArtistPath).Length == 0)
|
|
{
|
|
Directory.Delete(keptArtistPath);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to delete track {ItemId}", itemId);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// Loads missing tracks from file cache as fallback when Redis is empty.
|
|
/// <summary>
|
|
/// Gets a signature (hash) of the Jellyfin playlist to detect changes.
|
|
/// This is a cheap operation compared to re-matching all tracks.
|
|
/// Signature includes: track count + concatenated track IDs.
|
|
/// </summary>
|
|
private async Task<string> GetJellyfinPlaylistSignatureAsync(string playlistId)
|
|
{
|
|
try
|
|
{
|
|
var userId = _settings.UserId;
|
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
|
|
if (!string.IsNullOrEmpty(userId))
|
|
{
|
|
playlistItemsUrl += $"&UserId={userId}";
|
|
}
|
|
|
|
var (response, _) = await _proxyService.GetJsonAsync(playlistItemsUrl, null, Request.Headers);
|
|
|
|
if (response != null && response.RootElement.TryGetProperty("Items", out var items))
|
|
{
|
|
var trackIds = new List<string>();
|
|
foreach (var item in items.EnumerateArray())
|
|
{
|
|
if (item.TryGetProperty("Id", out var idEl))
|
|
{
|
|
trackIds.Add(idEl.GetString() ?? "");
|
|
}
|
|
}
|
|
|
|
// Create signature: count + sorted IDs (sorted for consistency)
|
|
trackIds.Sort();
|
|
var signature = $"{trackIds.Count}:{string.Join(",", trackIds)}";
|
|
|
|
// Hash it to keep it compact
|
|
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
|
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(signature));
|
|
return Convert.ToHexString(hashBytes);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to get Jellyfin playlist signature for {PlaylistId}", playlistId);
|
|
}
|
|
|
|
// Return empty string if failed (will trigger re-match)
|
|
return string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
|
/// </summary>
|
|
private async Task SavePlaylistItemsToFile(string playlistName, List<Dictionary<string, object?>> items)
|
|
{
|
|
try
|
|
{
|
|
var cacheDir = "/app/cache/spotify";
|
|
Directory.CreateDirectory(cacheDir);
|
|
|
|
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
|
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
|
|
|
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
|
await System.IO.File.WriteAllTextAsync(filePath, json);
|
|
|
|
_logger.LogDebug("💾 Saved {Count} playlist items to file cache for {Playlist}",
|
|
items.Count, playlistName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads playlist items (raw Jellyfin JSON) from file cache.
|
|
/// </summary>
|
|
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(string playlistName)
|
|
{
|
|
try
|
|
{
|
|
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
|
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
|
|
|
|
if (!System.IO.File.Exists(filePath))
|
|
{
|
|
_logger.LogDebug("No playlist items file cache found for {Playlist} at {Path}", playlistName, filePath);
|
|
return null;
|
|
}
|
|
|
|
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
|
|
|
|
// Check if cache is too old (more than 24 hours)
|
|
if (fileAge.TotalHours > 24)
|
|
{
|
|
_logger.LogDebug("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
|
|
playlistName, fileAge.TotalHours);
|
|
return null;
|
|
}
|
|
|
|
_logger.LogDebug("Playlist items file cache for {Playlist} age: {Age:F1}h", playlistName,
|
|
fileAge.TotalHours);
|
|
|
|
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
|
|
|
// Parse as JsonDocument first to preserve nested structures
|
|
using var doc = JsonDocument.Parse(json);
|
|
var items = new List<Dictionary<string, object?>>();
|
|
|
|
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var item in doc.RootElement.EnumerateArray())
|
|
{
|
|
items.Add(JsonElementToDictionary(item));
|
|
}
|
|
}
|
|
|
|
_logger.LogDebug("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
|
|
items.Count, playlistName, fileAge.TotalHours);
|
|
|
|
return items;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|