diff --git a/allstarr.Tests/DownloadsControllerLyricsArchiveTests.cs b/allstarr.Tests/DownloadsControllerLyricsArchiveTests.cs new file mode 100644 index 0000000..b8de3d6 --- /dev/null +++ b/allstarr.Tests/DownloadsControllerLyricsArchiveTests.cs @@ -0,0 +1,180 @@ +using System.IO.Compression; +using allstarr.Controllers; +using allstarr.Models.Domain; +using allstarr.Services.Lyrics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; + +namespace allstarr.Tests; + +public class DownloadsControllerLyricsArchiveTests +{ + [Fact] + public async Task DownloadFile_WithLyricsSidecar_ReturnsZipContainingAudioAndLrc() + { + var testRoot = CreateTestRoot(); + var downloadsRoot = Path.Combine(testRoot, "downloads"); + var artistDir = Path.Combine(downloadsRoot, "kept", "Artist"); + var audioPath = Path.Combine(artistDir, "track.mp3"); + + Directory.CreateDirectory(artistDir); + await File.WriteAllTextAsync(audioPath, "audio-data"); + + try + { + var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: true)); + + var result = await controller.DownloadFile("Artist/track.mp3"); + + var fileResult = Assert.IsType(result); + Assert.Equal("application/zip", fileResult.ContentType); + Assert.Equal("track.zip", fileResult.FileDownloadName); + + var entries = ReadArchiveEntries(fileResult.FileStream); + Assert.Contains("track.mp3", entries); + Assert.Contains("track.lrc", entries); + } + finally + { + DeleteTestRoot(testRoot); + } + } + + [Fact] + public async Task DownloadAllFiles_BackfillsLyricsSidecarsIntoArchive() + { + var testRoot = CreateTestRoot(); + var downloadsRoot = Path.Combine(testRoot, "downloads"); + var artistDir = Path.Combine(downloadsRoot, "kept", "Artist", "Album"); + var audioPath = Path.Combine(artistDir, "01 - track.mp3"); + + Directory.CreateDirectory(artistDir); + await File.WriteAllTextAsync(audioPath, "audio-data"); + + try + { + var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: true)); + + var result = await controller.DownloadAllFiles(); + + var fileResult = Assert.IsType(result); + Assert.Equal("application/zip", fileResult.ContentType); + + var entries = ReadArchiveEntries(fileResult.FileStream); + Assert.Contains(Path.Combine("Artist", "Album", "01 - track.mp3").Replace('\\', '/'), entries); + Assert.Contains(Path.Combine("Artist", "Album", "01 - track.lrc").Replace('\\', '/'), entries); + } + finally + { + DeleteTestRoot(testRoot); + } + } + + [Fact] + public void DeleteDownload_RemovesAdjacentLyricsSidecar() + { + var testRoot = CreateTestRoot(); + var downloadsRoot = Path.Combine(testRoot, "downloads"); + var artistDir = Path.Combine(downloadsRoot, "kept", "Artist"); + var audioPath = Path.Combine(artistDir, "track.mp3"); + var sidecarPath = Path.Combine(artistDir, "track.lrc"); + + Directory.CreateDirectory(artistDir); + File.WriteAllText(audioPath, "audio-data"); + File.WriteAllText(sidecarPath, "[00:00.00]lyrics"); + + try + { + var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: false)); + + var result = controller.DeleteDownload("Artist/track.mp3"); + + Assert.IsType(result); + Assert.False(File.Exists(audioPath)); + Assert.False(File.Exists(sidecarPath)); + } + finally + { + DeleteTestRoot(testRoot); + } + } + + private static DownloadsController CreateController(string downloadsRoot, IKeptLyricsSidecarService? keptLyricsSidecarService = null) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Library:DownloadPath"] = downloadsRoot + }) + .Build(); + + return new DownloadsController( + NullLogger.Instance, + config, + keptLyricsSidecarService) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + } + + private static HashSet ReadArchiveEntries(Stream archiveStream) + { + archiveStream.Position = 0; + using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true); + return zip.Entries + .Select(entry => entry.FullName.Replace('\\', '/')) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + private static string CreateTestRoot() + { + var root = Path.Combine(Path.GetTempPath(), "allstarr-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + private static void DeleteTestRoot(string root) + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + + private sealed class FakeKeptLyricsSidecarService : IKeptLyricsSidecarService + { + private readonly bool _createSidecar; + + public FakeKeptLyricsSidecarService(bool createSidecar) + { + _createSidecar = createSidecar; + } + + public string GetSidecarPath(string audioFilePath) + { + return Path.ChangeExtension(audioFilePath, ".lrc"); + } + + public Task EnsureSidecarAsync( + string audioFilePath, + Song? song = null, + string? externalProvider = null, + string? externalId = null, + CancellationToken cancellationToken = default) + { + var sidecarPath = GetSidecarPath(audioFilePath); + if (_createSidecar) + { + File.WriteAllText(sidecarPath, "[00:00.00]lyrics"); + return Task.FromResult(sidecarPath); + } + + return Task.FromResult(null); + } + } +} diff --git a/allstarr.Tests/DownloadsControllerPathSecurityTests.cs b/allstarr.Tests/DownloadsControllerPathSecurityTests.cs index 8712fe4..6f852e8 100644 --- a/allstarr.Tests/DownloadsControllerPathSecurityTests.cs +++ b/allstarr.Tests/DownloadsControllerPathSecurityTests.cs @@ -9,7 +9,7 @@ namespace allstarr.Tests; public class DownloadsControllerPathSecurityTests { [Fact] - public void DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected() + public async Task DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected() { var testRoot = CreateTestRoot(); var downloadsRoot = Path.Combine(testRoot, "downloads"); @@ -23,7 +23,7 @@ public class DownloadsControllerPathSecurityTests try { var controller = CreateController(downloadsRoot); - var result = controller.DownloadFile("../kept-malicious/attack.mp3"); + var result = await controller.DownloadFile("../kept-malicious/attack.mp3"); var badRequest = Assert.IsType(result); Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); @@ -63,7 +63,7 @@ public class DownloadsControllerPathSecurityTests } [Fact] - public void DownloadFile_ValidPathInsideKeptFolder_AllowsDownload() + public async Task DownloadFile_ValidPathInsideKeptFolder_AllowsDownload() { var testRoot = CreateTestRoot(); var downloadsRoot = Path.Combine(testRoot, "downloads"); @@ -76,7 +76,7 @@ public class DownloadsControllerPathSecurityTests try { var controller = CreateController(downloadsRoot); - var result = controller.DownloadFile("Artist/track.mp3"); + var result = await controller.DownloadFile("Artist/track.mp3"); Assert.IsType(result); } @@ -97,7 +97,13 @@ public class DownloadsControllerPathSecurityTests return new DownloadsController( NullLogger.Instance, - config); + config) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; } private static string CreateTestRoot() diff --git a/allstarr/Controllers/DownloadsController.cs b/allstarr/Controllers/DownloadsController.cs index 5a2b7cf..bef268c 100644 --- a/allstarr/Controllers/DownloadsController.cs +++ b/allstarr/Controllers/DownloadsController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using allstarr.Filters; using allstarr.Services.Admin; +using allstarr.Services.Lyrics; namespace allstarr.Controllers; @@ -9,15 +10,20 @@ namespace allstarr.Controllers; [ServiceFilter(typeof(AdminPortFilter))] public class DownloadsController : ControllerBase { + private static readonly string[] AudioExtensions = [".flac", ".mp3", ".m4a", ".opus"]; + private readonly ILogger _logger; private readonly IConfiguration _configuration; + private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService; public DownloadsController( ILogger logger, - IConfiguration configuration) + IConfiguration configuration, + IKeptLyricsSidecarService? keptLyricsSidecarService = null) { _logger = logger; _configuration = configuration; + _keptLyricsSidecarService = keptLyricsSidecarService; } [HttpGet("downloads")] @@ -36,10 +42,8 @@ public class DownloadsController : ControllerBase long totalSize = 0; // Recursively get all audio files from kept folder - var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" }; - var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories) - .Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) + .Where(IsSupportedAudioFile) .ToList(); foreach (var filePath in allFiles) @@ -112,6 +116,11 @@ public class DownloadsController : ControllerBase } System.IO.File.Delete(fullPath); + var sidecarPath = _keptLyricsSidecarService?.GetSidecarPath(fullPath) ?? Path.ChangeExtension(fullPath, ".lrc"); + if (System.IO.File.Exists(sidecarPath)) + { + System.IO.File.Delete(sidecarPath); + } // Clean up empty directories (Album folder, then Artist folder if empty) var directory = Path.GetDirectoryName(fullPath); @@ -154,9 +163,8 @@ public class DownloadsController : ControllerBase return Ok(new { success = true, deletedCount = 0, message = "No kept downloads found" }); } - var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" }; var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories) - .Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) + .Where(IsSupportedAudioFile) .ToList(); foreach (var filePath in allFiles) @@ -164,6 +172,12 @@ public class DownloadsController : ControllerBase System.IO.File.Delete(filePath); } + var sidecarFiles = Directory.GetFiles(keptPath, "*.lrc", SearchOption.AllDirectories); + foreach (var sidecarFile in sidecarFiles) + { + System.IO.File.Delete(sidecarFile); + } + // Clean up empty directories under kept root (deepest first) var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories) .OrderByDescending(d => d.Length); @@ -194,7 +208,7 @@ public class DownloadsController : ControllerBase /// Downloads a specific file from the kept folder /// [HttpGet("downloads/file")] - public IActionResult DownloadFile([FromQuery] string path) + public async Task DownloadFile([FromQuery] string path) { try { @@ -216,8 +230,16 @@ public class DownloadsController : ControllerBase } var fileName = Path.GetFileName(fullPath); - var fileStream = System.IO.File.OpenRead(fullPath); + if (IsSupportedAudioFile(fullPath)) + { + var sidecarPath = await EnsureLyricsSidecarIfPossibleAsync(fullPath, HttpContext.RequestAborted); + if (System.IO.File.Exists(sidecarPath)) + { + return await CreateSingleTrackArchiveAsync(fullPath, sidecarPath, fileName); + } + } + var fileStream = System.IO.File.OpenRead(fullPath); return File(fileStream, "application/octet-stream", fileName); } catch (Exception ex) @@ -232,7 +254,7 @@ public class DownloadsController : ControllerBase /// Downloads all kept files as a zip archive /// [HttpGet("downloads/all")] - public IActionResult DownloadAllFiles() + public async Task DownloadAllFiles() { try { @@ -243,9 +265,8 @@ public class DownloadsController : ControllerBase return NotFound(new { error = "No kept files found" }); } - var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" }; var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories) - .Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) + .Where(IsSupportedAudioFile) .ToList(); if (allFiles.Count == 0) @@ -259,14 +280,18 @@ public class DownloadsController : ControllerBase var memoryStream = new MemoryStream(); using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true)) { + var addedEntries = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var filePath in allFiles) { var relativePath = Path.GetRelativePath(keptPath, filePath); - var entry = archive.CreateEntry(relativePath, System.IO.Compression.CompressionLevel.NoCompression); + await AddFileToArchiveAsync(archive, filePath, relativePath, addedEntries); - using var entryStream = entry.Open(); - using var fileStream = System.IO.File.OpenRead(filePath); - fileStream.CopyTo(entryStream); + var sidecarPath = await EnsureLyricsSidecarIfPossibleAsync(filePath, HttpContext.RequestAborted); + if (System.IO.File.Exists(sidecarPath)) + { + var sidecarRelativePath = Path.GetRelativePath(keptPath, sidecarPath); + await AddFileToArchiveAsync(archive, sidecarPath, sidecarRelativePath, addedEntries); + } } } @@ -330,6 +355,54 @@ public class DownloadsController : ControllerBase : StringComparison.Ordinal; } + private async Task EnsureLyricsSidecarIfPossibleAsync(string audioFilePath, CancellationToken cancellationToken) + { + var sidecarPath = _keptLyricsSidecarService?.GetSidecarPath(audioFilePath) ?? Path.ChangeExtension(audioFilePath, ".lrc"); + if (System.IO.File.Exists(sidecarPath) || _keptLyricsSidecarService == null) + { + return sidecarPath; + } + + var generatedSidecar = await _keptLyricsSidecarService.EnsureSidecarAsync(audioFilePath, cancellationToken: cancellationToken); + return generatedSidecar ?? sidecarPath; + } + + private async Task CreateSingleTrackArchiveAsync(string audioFilePath, string sidecarPath, string fileName) + { + var archiveStream = new MemoryStream(); + using (var archive = new System.IO.Compression.ZipArchive(archiveStream, System.IO.Compression.ZipArchiveMode.Create, true)) + { + await AddFileToArchiveAsync(archive, audioFilePath, Path.GetFileName(audioFilePath), null); + await AddFileToArchiveAsync(archive, sidecarPath, Path.GetFileName(sidecarPath), null); + } + + archiveStream.Position = 0; + var downloadName = $"{Path.GetFileNameWithoutExtension(fileName)}.zip"; + return File(archiveStream, "application/zip", downloadName); + } + + private static async Task AddFileToArchiveAsync( + System.IO.Compression.ZipArchive archive, + string filePath, + string entryPath, + HashSet? addedEntries) + { + if (addedEntries != null && !addedEntries.Add(entryPath)) + { + return; + } + + var entry = archive.CreateEntry(entryPath, System.IO.Compression.CompressionLevel.NoCompression); + await using var entryStream = entry.Open(); + await using var fileStream = System.IO.File.OpenRead(filePath); + await fileStream.CopyToAsync(entryStream); + } + + private static bool IsSupportedAudioFile(string path) + { + return AudioExtensions.Contains(Path.GetExtension(path).ToLowerInvariant()); + } + /// /// Gets all Spotify track mappings (paginated) /// diff --git a/allstarr/Controllers/JellyfinController.Spotify.cs b/allstarr/Controllers/JellyfinController.Spotify.cs index 059d082..2980b00 100644 --- a/allstarr/Controllers/JellyfinController.Spotify.cs +++ b/allstarr/Controllers/JellyfinController.Spotify.cs @@ -9,6 +9,8 @@ namespace allstarr.Controllers; public partial class JellyfinController { + private static readonly string[] KeptAudioExtensions = [".flac", ".mp3", ".m4a", ".opus"]; + #region Spotify Playlist Injection /// @@ -480,10 +482,13 @@ public partial class JellyfinController if (Directory.Exists(keptAlbumPath)) { var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title); - var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*"); - if (existingFiles.Length > 0) + var existingAudioFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*") + .Where(IsKeptAudioFile) + .ToArray(); + if (existingAudioFiles.Length > 0) { - _logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]); + _logger.LogInformation("Track already exists in kept folder: {Path}", existingAudioFiles[0]); + await EnsureLyricsSidecarForKeptTrackAsync(existingAudioFiles[0], song, provider, externalId); // Mark as favorited even if we didn't download it await MarkTrackAsFavoritedAsync(itemId, song); return; @@ -572,6 +577,7 @@ public partial class JellyfinController { // Race condition - file was created by another request _logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath); + await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId); await MarkTrackAsFavoritedAsync(itemId, song); return; } @@ -589,6 +595,7 @@ public partial class JellyfinController { // Race condition on copy fallback _logger.LogInformation("Track already exists in kept folder (race condition on copy): {Path}", keptFilePath); + await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId); await MarkTrackAsFavoritedAsync(itemId, song); return; } @@ -650,6 +657,8 @@ public partial class JellyfinController } } + await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId); + // Mark as favorited in persistent storage await MarkTrackAsFavoritedAsync(itemId, song); } @@ -903,6 +912,33 @@ public partial class JellyfinController } } + private async Task EnsureLyricsSidecarForKeptTrackAsync(string keptFilePath, Song song, string provider, string externalId) + { + if (_keptLyricsSidecarService == null) + { + return; + } + + try + { + await _keptLyricsSidecarService.EnsureSidecarAsync( + keptFilePath, + song, + provider, + externalId, + CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to create kept lyrics sidecar for {Path}", keptFilePath); + } + } + + private static bool IsKeptAudioFile(string path) + { + return KeptAudioExtensions.Contains(Path.GetExtension(path).ToLowerInvariant()); + } + #endregion /// diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 7e2596b..3ae73c2 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -47,6 +47,7 @@ public partial class JellyfinController : ControllerBase private readonly LyricsPlusService? _lyricsPlusService; private readonly LrclibService? _lrclibService; private readonly LyricsOrchestrator? _lyricsOrchestrator; + private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService; private readonly ScrobblingOrchestrator? _scrobblingOrchestrator; private readonly ScrobblingHelper? _scrobblingHelper; private readonly OdesliService _odesliService; @@ -77,6 +78,7 @@ public partial class JellyfinController : ControllerBase LyricsPlusService? lyricsPlusService = null, LrclibService? lrclibService = null, LyricsOrchestrator? lyricsOrchestrator = null, + IKeptLyricsSidecarService? keptLyricsSidecarService = null, ScrobblingOrchestrator? scrobblingOrchestrator = null, ScrobblingHelper? scrobblingHelper = null) { @@ -98,6 +100,7 @@ public partial class JellyfinController : ControllerBase _lyricsPlusService = lyricsPlusService; _lrclibService = lrclibService; _lyricsOrchestrator = lyricsOrchestrator; + _keptLyricsSidecarService = keptLyricsSidecarService; _scrobblingOrchestrator = scrobblingOrchestrator; _scrobblingHelper = scrobblingHelper; _odesliService = odesliService; diff --git a/allstarr/Program.cs b/allstarr/Program.cs index c53cf49..420cbbc 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -712,6 +712,7 @@ builder.Services.AddSingleton(); // Register Lyrics Orchestrator (manages priority-based lyrics fetching) builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Register Spotify mapping service (global Spotify ID → Local/External mappings) builder.Services.AddSingleton(); diff --git a/allstarr/Services/Lyrics/IKeptLyricsSidecarService.cs b/allstarr/Services/Lyrics/IKeptLyricsSidecarService.cs new file mode 100644 index 0000000..76d3ad1 --- /dev/null +++ b/allstarr/Services/Lyrics/IKeptLyricsSidecarService.cs @@ -0,0 +1,15 @@ +using allstarr.Models.Domain; + +namespace allstarr.Services.Lyrics; + +public interface IKeptLyricsSidecarService +{ + string GetSidecarPath(string audioFilePath); + + Task EnsureSidecarAsync( + string audioFilePath, + Song? song = null, + string? externalProvider = null, + string? externalId = null, + CancellationToken cancellationToken = default); +} diff --git a/allstarr/Services/Lyrics/KeptLyricsSidecarService.cs b/allstarr/Services/Lyrics/KeptLyricsSidecarService.cs new file mode 100644 index 0000000..e261981 --- /dev/null +++ b/allstarr/Services/Lyrics/KeptLyricsSidecarService.cs @@ -0,0 +1,319 @@ +using System.Text.RegularExpressions; +using TagLib; +using allstarr.Models.Domain; +using allstarr.Models.Lyrics; +using allstarr.Models.Settings; +using allstarr.Models.Spotify; +using allstarr.Services.Common; +using Microsoft.Extensions.Options; + +namespace allstarr.Services.Lyrics; + +public class KeptLyricsSidecarService : IKeptLyricsSidecarService +{ + private static readonly Regex ProviderSuffixRegex = new( + @"\[(?[A-Za-z0-9_-]+)-(?[^\]]+)\]$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private readonly LyricsOrchestrator _lyricsOrchestrator; + private readonly RedisCacheService _cache; + private readonly SpotifyImportSettings _spotifySettings; + private readonly OdesliService _odesliService; + private readonly ILogger _logger; + + public KeptLyricsSidecarService( + LyricsOrchestrator lyricsOrchestrator, + RedisCacheService cache, + IOptions spotifySettings, + OdesliService odesliService, + ILogger logger) + { + _lyricsOrchestrator = lyricsOrchestrator; + _cache = cache; + _spotifySettings = spotifySettings.Value; + _odesliService = odesliService; + _logger = logger; + } + + public string GetSidecarPath(string audioFilePath) + { + return Path.ChangeExtension(audioFilePath, ".lrc"); + } + + public async Task EnsureSidecarAsync( + string audioFilePath, + Song? song = null, + string? externalProvider = null, + string? externalId = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(audioFilePath) || !System.IO.File.Exists(audioFilePath)) + { + return null; + } + + var sidecarPath = GetSidecarPath(audioFilePath); + if (System.IO.File.Exists(sidecarPath)) + { + return sidecarPath; + } + + try + { + var inferredExternalRef = ParseExternalReferenceFromPath(audioFilePath); + externalProvider ??= inferredExternalRef.Provider; + externalId ??= inferredExternalRef.ExternalId; + + var metadata = ReadAudioMetadata(audioFilePath); + var artistNames = ResolveArtists(song, metadata); + var title = FirstNonEmpty( + StripTrackDecorators(song?.Title), + StripTrackDecorators(metadata.Title), + GetFallbackTitleFromPath(audioFilePath)); + var album = FirstNonEmpty( + StripTrackDecorators(song?.Album), + StripTrackDecorators(metadata.Album)); + var durationSeconds = song?.Duration ?? metadata.DurationSeconds; + + if (string.IsNullOrWhiteSpace(title) || artistNames.Count == 0) + { + _logger.LogDebug("Skipping lyrics sidecar generation for {Path}: missing title or artist metadata", audioFilePath); + return null; + } + + var spotifyTrackId = FirstNonEmpty(song?.SpotifyId); + if (string.IsNullOrWhiteSpace(spotifyTrackId) && + !string.IsNullOrWhiteSpace(externalProvider) && + !string.IsNullOrWhiteSpace(externalId)) + { + spotifyTrackId = await ResolveSpotifyTrackIdAsync(externalProvider, externalId, cancellationToken); + } + + var lyrics = await _lyricsOrchestrator.GetLyricsAsync( + trackName: title, + artistNames: artistNames.ToArray(), + albumName: album, + durationSeconds: durationSeconds, + spotifyTrackId: spotifyTrackId); + + if (lyrics == null) + { + return null; + } + + var lrcContent = BuildLrcContent( + lyrics, + title, + artistNames, + album, + durationSeconds); + + if (string.IsNullOrWhiteSpace(lrcContent)) + { + return null; + } + + await System.IO.File.WriteAllTextAsync(sidecarPath, lrcContent, cancellationToken); + _logger.LogInformation("Saved lyrics sidecar: {SidecarPath}", sidecarPath); + return sidecarPath; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to create lyrics sidecar for {Path}", audioFilePath); + return null; + } + } + + private async Task ResolveSpotifyTrackIdAsync( + string externalProvider, + string externalId, + CancellationToken cancellationToken) + { + var spotifyId = await FindSpotifyIdFromMatchedTracksAsync(externalProvider, externalId); + if (!string.IsNullOrWhiteSpace(spotifyId)) + { + return spotifyId; + } + + return externalProvider.ToLowerInvariant() switch + { + "squidwtf" => await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, cancellationToken), + "deezer" => await _odesliService.ConvertUrlToSpotifyIdAsync($"https://www.deezer.com/track/{externalId}", cancellationToken), + "qobuz" => await _odesliService.ConvertUrlToSpotifyIdAsync($"https://www.qobuz.com/us-en/album/-/-/{externalId}", cancellationToken), + _ => null + }; + } + + private async Task FindSpotifyIdFromMatchedTracksAsync(string externalProvider, string externalId) + { + if (_spotifySettings.Playlists == null || _spotifySettings.Playlists.Count == 0) + { + return null; + } + + foreach (var playlist in _spotifySettings.Playlists) + { + var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name); + var matchedTracks = await _cache.GetAsync>(cacheKey); + + var match = matchedTracks?.FirstOrDefault(track => + track.MatchedSong != null && + string.Equals(track.MatchedSong.ExternalProvider, externalProvider, StringComparison.OrdinalIgnoreCase) && + string.Equals(track.MatchedSong.ExternalId, externalId, StringComparison.Ordinal)); + + if (match != null && !string.IsNullOrWhiteSpace(match.SpotifyId)) + { + return match.SpotifyId; + } + } + + return null; + } + + private static (string? Provider, string? ExternalId) ParseExternalReferenceFromPath(string audioFilePath) + { + var baseName = Path.GetFileNameWithoutExtension(audioFilePath); + var match = ProviderSuffixRegex.Match(baseName); + if (!match.Success) + { + return (null, null); + } + + return ( + match.Groups["provider"].Value, + match.Groups["externalId"].Value); + } + + private static AudioMetadata ReadAudioMetadata(string audioFilePath) + { + try + { + using var tagFile = TagLib.File.Create(audioFilePath); + return new AudioMetadata + { + Title = tagFile.Tag.Title, + Album = tagFile.Tag.Album, + Artists = tagFile.Tag.Performers?.Where(value => !string.IsNullOrWhiteSpace(value)).ToList() ?? new List(), + DurationSeconds = (int)Math.Round(tagFile.Properties.Duration.TotalSeconds) + }; + } + catch + { + return new AudioMetadata(); + } + } + + private static List ResolveArtists(Song? song, AudioMetadata metadata) + { + var artists = new List(); + + if (song?.Artists != null && song.Artists.Count > 0) + { + artists.AddRange(song.Artists.Where(value => !string.IsNullOrWhiteSpace(value))); + } + else if (!string.IsNullOrWhiteSpace(song?.Artist)) + { + artists.Add(song.Artist); + } + + if (artists.Count == 0 && metadata.Artists.Count > 0) + { + artists.AddRange(metadata.Artists); + } + + return artists + .Select(StripTrackDecorators) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string BuildLrcContent( + LyricsInfo lyrics, + string fallbackTitle, + IReadOnlyList fallbackArtists, + string? fallbackAlbum, + int fallbackDurationSeconds) + { + var title = FirstNonEmpty(lyrics.TrackName, fallbackTitle); + var artist = FirstNonEmpty(lyrics.ArtistName, string.Join(", ", fallbackArtists)); + var album = FirstNonEmpty(lyrics.AlbumName, fallbackAlbum); + var durationSeconds = lyrics.Duration > 0 ? lyrics.Duration : fallbackDurationSeconds; + + var body = FirstNonEmpty( + NormalizeLineEndings(lyrics.SyncedLyrics), + NormalizeLineEndings(lyrics.PlainLyrics)); + + if (string.IsNullOrWhiteSpace(body)) + { + return string.Empty; + } + + var headerLines = new List(); + if (!string.IsNullOrWhiteSpace(artist)) + { + headerLines.Add($"[ar:{artist}]"); + } + + if (!string.IsNullOrWhiteSpace(album)) + { + headerLines.Add($"[al:{album}]"); + } + + if (!string.IsNullOrWhiteSpace(title)) + { + headerLines.Add($"[ti:{title}]"); + } + + if (durationSeconds > 0) + { + var duration = TimeSpan.FromSeconds(durationSeconds); + headerLines.Add($"[length:{(int)duration.TotalMinutes}:{duration.Seconds:D2}]"); + } + + return headerLines.Count == 0 + ? body + : $"{string.Join('\n', headerLines)}\n\n{body}"; + } + + private static string? GetFallbackTitleFromPath(string audioFilePath) + { + var baseName = Path.GetFileNameWithoutExtension(audioFilePath); + baseName = ProviderSuffixRegex.Replace(baseName, string.Empty).Trim(); + baseName = Regex.Replace(baseName, @"^\d+\s*-\s*", string.Empty); + return baseName.Trim(); + } + + private static string FirstNonEmpty(params string?[] values) + { + return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; + } + + private static string NormalizeLineEndings(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Replace("\r\n", "\n").Replace('\r', '\n').Trim(); + } + + private static string StripTrackDecorators(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + return value + .Replace(" [S]", "", StringComparison.Ordinal) + .Replace(" [E]", "", StringComparison.Ordinal) + .Trim(); + } + + private sealed class AudioMetadata + { + public string? Title { get; init; } + public string? Album { get; init; } + public List Artists { get; init; } = new(); + public int DurationSeconds { get; init; } + } +}