mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-21 02:02:31 -04:00
feat(lyrics): add kept download lrc sidecars
This commit is contained in:
@@ -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<FileStreamResult>(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<FileStreamResult>(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<OkObjectResult>(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<string, string?>
|
||||
{
|
||||
["Library:DownloadPath"] = downloadsRoot
|
||||
})
|
||||
.Build();
|
||||
|
||||
return new DownloadsController(
|
||||
NullLogger<DownloadsController>.Instance,
|
||||
config,
|
||||
keptLyricsSidecarService)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static HashSet<string> 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<string?> 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<string?>(sidecarPath);
|
||||
}
|
||||
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<BadRequestObjectResult>(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<FileStreamResult>(result);
|
||||
}
|
||||
@@ -97,7 +97,13 @@ public class DownloadsControllerPathSecurityTests
|
||||
|
||||
return new DownloadsController(
|
||||
NullLogger<DownloadsController>.Instance,
|
||||
config);
|
||||
config)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateTestRoot()
|
||||
|
||||
@@ -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<DownloadsController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService;
|
||||
|
||||
public DownloadsController(
|
||||
ILogger<DownloadsController> 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
|
||||
/// </summary>
|
||||
[HttpGet("downloads/file")]
|
||||
public IActionResult DownloadFile([FromQuery] string path)
|
||||
public async Task<IActionResult> 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
|
||||
/// </summary>
|
||||
[HttpGet("downloads/all")]
|
||||
public IActionResult DownloadAllFiles()
|
||||
public async Task<IActionResult> 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<string>(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<string> 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<IActionResult> 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<string>? 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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all Spotify track mappings (paginated)
|
||||
/// </summary>
|
||||
|
||||
@@ -9,6 +9,8 @@ namespace allstarr.Controllers;
|
||||
|
||||
public partial class JellyfinController
|
||||
{
|
||||
private static readonly string[] KeptAudioExtensions = [".flac", ".mp3", ".m4a", ".opus"];
|
||||
|
||||
#region Spotify Playlist Injection
|
||||
|
||||
/// <summary>
|
||||
@@ -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
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -712,6 +712,7 @@ builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
|
||||
|
||||
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.IKeptLyricsSidecarService, allstarr.Services.Lyrics.KeptLyricsSidecarService>();
|
||||
|
||||
// Register Spotify mapping service (global Spotify ID → Local/External mappings)
|
||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyMappingService>();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using allstarr.Models.Domain;
|
||||
|
||||
namespace allstarr.Services.Lyrics;
|
||||
|
||||
public interface IKeptLyricsSidecarService
|
||||
{
|
||||
string GetSidecarPath(string audioFilePath);
|
||||
|
||||
Task<string?> EnsureSidecarAsync(
|
||||
string audioFilePath,
|
||||
Song? song = null,
|
||||
string? externalProvider = null,
|
||||
string? externalId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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(
|
||||
@"\[(?<provider>[A-Za-z0-9_-]+)-(?<externalId>[^\]]+)\]$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly LyricsOrchestrator _lyricsOrchestrator;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly OdesliService _odesliService;
|
||||
private readonly ILogger<KeptLyricsSidecarService> _logger;
|
||||
|
||||
public KeptLyricsSidecarService(
|
||||
LyricsOrchestrator lyricsOrchestrator,
|
||||
RedisCacheService cache,
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
OdesliService odesliService,
|
||||
ILogger<KeptLyricsSidecarService> 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<string?> 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<string?> 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<string?> 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<List<MatchedTrack>>(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<string>(),
|
||||
DurationSeconds = (int)Math.Round(tagFile.Properties.Duration.TotalSeconds)
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AudioMetadata();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ResolveArtists(Song? song, AudioMetadata metadata)
|
||||
{
|
||||
var artists = new List<string>();
|
||||
|
||||
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<string> 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<string>();
|
||||
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<string> Artists { get; init; } = new();
|
||||
public int DurationSeconds { get; init; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user