mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-21 02:02:31 -04:00
Compare commits
5 Commits
baaea5747f
...
d89dd5e7db
| Author | SHA1 | Date | |
|---|---|---|---|
|
d89dd5e7db
|
|||
|
b715802a4e
|
|||
|
5f817abda2
|
|||
|
69f0c53ade
|
|||
|
8baa8277e0
|
@@ -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; }
|
||||
}
|
||||
}
|
||||
+10
-15
@@ -65,8 +65,17 @@
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Admin navigation">
|
||||
<div class="sidebar-brand">
|
||||
<div class="sidebar-title">Allstarr</div>
|
||||
<div class="sidebar-title">
|
||||
<a class="title-link" href="https://github.com/SoPat712/allstarr" target="_blank"
|
||||
rel="noopener noreferrer">Allstarr</a>
|
||||
</div>
|
||||
<div class="sidebar-subtitle" id="sidebar-version">Loading...</div>
|
||||
<div class="sidebar-status" id="status-indicator">
|
||||
<span class="status-badge" id="spotify-status">
|
||||
<span class="status-dot"></span>
|
||||
<span>Loading...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button>
|
||||
@@ -86,20 +95,6 @@
|
||||
</aside>
|
||||
|
||||
<main class="app-main">
|
||||
<header class="app-header">
|
||||
<h1>
|
||||
Allstarr <span class="version" id="version">Loading...</span>
|
||||
</h1>
|
||||
<div class="header-actions">
|
||||
<div id="status-indicator">
|
||||
<span class="status-badge" id="spotify-status">
|
||||
<span class="status-dot"></span>
|
||||
<span>Loading...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tabs top-tabs" aria-hidden="true">
|
||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ let onCookieNeedsInit = async () => {};
|
||||
let setCurrentConfigState = () => {};
|
||||
let syncConfigUiExtras = () => {};
|
||||
let loadScrobblingConfig = () => {};
|
||||
let injectedPlaylistRequestToken = 0;
|
||||
let jellyfinPlaylistRequestToken = 0;
|
||||
|
||||
async function fetchStatus() {
|
||||
@@ -39,10 +40,20 @@ async function fetchStatus() {
|
||||
}
|
||||
|
||||
async function fetchPlaylists(silent = false) {
|
||||
const requestToken = ++injectedPlaylistRequestToken;
|
||||
|
||||
try {
|
||||
const data = await API.fetchPlaylists();
|
||||
if (requestToken !== injectedPlaylistRequestToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
UI.updatePlaylistsUI(data);
|
||||
} catch (error) {
|
||||
if (requestToken !== injectedPlaylistRequestToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
console.error("Failed to fetch playlists:", error);
|
||||
showToast("Failed to fetch playlists", "error");
|
||||
|
||||
+357
-85
@@ -5,6 +5,7 @@ import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
|
||||
let rowMenuHandlersBound = false;
|
||||
let tableRowHandlersBound = false;
|
||||
const expandedInjectedPlaylistDetails = new Set();
|
||||
let openInjectedPlaylistMenuKey = null;
|
||||
|
||||
function bindRowMenuHandlers() {
|
||||
if (rowMenuHandlersBound) {
|
||||
@@ -57,8 +58,16 @@ function closeAllRowMenus(exceptId = null) {
|
||||
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
|
||||
if (!exceptId || menu.id !== exceptId) {
|
||||
menu.classList.remove("open");
|
||||
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
|
||||
if (trigger) {
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!exceptId) {
|
||||
openInjectedPlaylistMenuKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeRowMenu(event, menuId) {
|
||||
@@ -69,6 +78,13 @@ function closeRowMenu(event, menuId) {
|
||||
const menu = document.getElementById(menuId);
|
||||
if (menu) {
|
||||
menu.classList.remove("open");
|
||||
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
|
||||
if (trigger) {
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
if (menu.dataset.menuKey) {
|
||||
openInjectedPlaylistMenuKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +101,14 @@ function toggleRowMenu(event, menuId) {
|
||||
const isOpen = menu.classList.contains("open");
|
||||
closeAllRowMenus(menuId);
|
||||
menu.classList.toggle("open", !isOpen);
|
||||
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
|
||||
if (trigger) {
|
||||
trigger.setAttribute("aria-expanded", String(!isOpen));
|
||||
}
|
||||
|
||||
if (menu.dataset.menuKey) {
|
||||
openInjectedPlaylistMenuKey = isOpen ? null : menu.dataset.menuKey;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDetailsRow(event, detailsRowId) {
|
||||
@@ -224,6 +248,275 @@ function getPlaylistStatusSummary(playlist) {
|
||||
};
|
||||
}
|
||||
|
||||
function syncElementAttributes(target, source) {
|
||||
if (!target || !source) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceAttributes = new Map(
|
||||
Array.from(source.attributes || []).map((attribute) => [
|
||||
attribute.name,
|
||||
attribute.value,
|
||||
]),
|
||||
);
|
||||
|
||||
Array.from(target.attributes || []).forEach((attribute) => {
|
||||
if (!sourceAttributes.has(attribute.name)) {
|
||||
target.removeAttribute(attribute.name);
|
||||
}
|
||||
});
|
||||
|
||||
sourceAttributes.forEach((value, name) => {
|
||||
target.setAttribute(name, value);
|
||||
});
|
||||
}
|
||||
|
||||
function syncPlaylistRowActionsWrap(existingWrap, nextWrap) {
|
||||
if (!existingWrap || !nextWrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncElementAttributes(existingWrap, nextWrap);
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
let focusTarget = null;
|
||||
|
||||
if (activeElement && existingWrap.contains(activeElement)) {
|
||||
if (activeElement.classList.contains("menu-trigger")) {
|
||||
focusTarget = { type: "trigger" };
|
||||
} else if (activeElement.tagName === "BUTTON") {
|
||||
focusTarget = {
|
||||
type: "menu-item",
|
||||
action: activeElement.getAttribute("data-action") || "",
|
||||
text: activeElement.textContent || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const existingTrigger = existingWrap.querySelector(".menu-trigger");
|
||||
const nextTrigger = nextWrap.querySelector(".menu-trigger");
|
||||
if (existingTrigger && nextTrigger) {
|
||||
syncElementAttributes(existingTrigger, nextTrigger);
|
||||
existingTrigger.textContent = nextTrigger.textContent;
|
||||
} else if (nextTrigger && !existingTrigger) {
|
||||
existingWrap.prepend(nextTrigger.cloneNode(true));
|
||||
} else if (existingTrigger && !nextTrigger) {
|
||||
existingTrigger.remove();
|
||||
}
|
||||
|
||||
const existingMenu = existingWrap.querySelector(".row-actions-menu");
|
||||
const nextMenu = nextWrap.querySelector(".row-actions-menu");
|
||||
if (existingMenu && nextMenu) {
|
||||
syncElementAttributes(existingMenu, nextMenu);
|
||||
existingMenu.replaceChildren(
|
||||
...Array.from(nextMenu.children).map((child) => child.cloneNode(true)),
|
||||
);
|
||||
} else if (nextMenu && !existingMenu) {
|
||||
existingWrap.append(nextMenu.cloneNode(true));
|
||||
} else if (existingMenu && !nextMenu) {
|
||||
existingMenu.remove();
|
||||
}
|
||||
|
||||
if (!focusTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusTarget.type === "trigger") {
|
||||
existingWrap.querySelector(".menu-trigger")?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingButton =
|
||||
Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find(
|
||||
(button) =>
|
||||
(button.getAttribute("data-action") || "") === focusTarget.action &&
|
||||
button.textContent === focusTarget.text,
|
||||
) ||
|
||||
Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find(
|
||||
(button) =>
|
||||
(button.getAttribute("data-action") || "") === focusTarget.action,
|
||||
);
|
||||
|
||||
matchingButton?.focus();
|
||||
}
|
||||
|
||||
function syncPlaylistControlsCell(
|
||||
existingControlsCell,
|
||||
nextControlsCell,
|
||||
preserveOpenMenu = false,
|
||||
) {
|
||||
if (!existingControlsCell || !nextControlsCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncElementAttributes(existingControlsCell, nextControlsCell);
|
||||
|
||||
if (!preserveOpenMenu) {
|
||||
existingControlsCell.innerHTML = nextControlsCell.innerHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
const existingDetailsTrigger =
|
||||
existingControlsCell.querySelector(".details-trigger");
|
||||
const nextDetailsTrigger = nextControlsCell.querySelector(".details-trigger");
|
||||
const existingWrap = existingControlsCell.querySelector(".row-actions-wrap");
|
||||
const nextWrap = nextControlsCell.querySelector(".row-actions-wrap");
|
||||
|
||||
if (
|
||||
!existingDetailsTrigger ||
|
||||
!nextDetailsTrigger ||
|
||||
!existingWrap ||
|
||||
!nextWrap
|
||||
) {
|
||||
existingControlsCell.innerHTML = nextControlsCell.innerHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
syncElementAttributes(existingDetailsTrigger, nextDetailsTrigger);
|
||||
existingDetailsTrigger.textContent = nextDetailsTrigger.textContent;
|
||||
syncPlaylistRowActionsWrap(existingWrap, nextWrap);
|
||||
}
|
||||
|
||||
function syncPlaylistMainRow(
|
||||
existingMainRow,
|
||||
nextMainRow,
|
||||
preserveOpenMenu = false,
|
||||
) {
|
||||
if (!existingMainRow || !nextMainRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncElementAttributes(existingMainRow, nextMainRow);
|
||||
|
||||
const nextCells = Array.from(nextMainRow.children);
|
||||
const existingCells = Array.from(existingMainRow.children);
|
||||
|
||||
if (!preserveOpenMenu || nextCells.length !== existingCells.length) {
|
||||
existingMainRow.innerHTML = nextMainRow.innerHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
nextCells.forEach((nextCell, index) => {
|
||||
const existingCell = existingCells[index];
|
||||
if (!existingCell) {
|
||||
existingMainRow.append(nextCell.cloneNode(true));
|
||||
return;
|
||||
}
|
||||
|
||||
if (index === nextCells.length - 1) {
|
||||
syncPlaylistControlsCell(existingCell, nextCell, preserveOpenMenu);
|
||||
return;
|
||||
}
|
||||
|
||||
existingCell.replaceWith(nextCell.cloneNode(true));
|
||||
});
|
||||
|
||||
while (existingMainRow.children.length > nextCells.length) {
|
||||
existingMainRow.lastElementChild?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function syncPlaylistDetailsRow(existingDetailsRow, nextDetailsRow) {
|
||||
if (!existingDetailsRow || !nextDetailsRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncElementAttributes(existingDetailsRow, nextDetailsRow);
|
||||
existingDetailsRow.innerHTML = nextDetailsRow.innerHTML;
|
||||
}
|
||||
|
||||
function renderPlaylistRowPairMarkup(playlist, index) {
|
||||
const summary = getPlaylistStatusSummary(playlist);
|
||||
const detailsRowId = `playlist-details-${index}`;
|
||||
const menuId = `playlist-menu-${index}`;
|
||||
const detailsKey = `${playlist.id || playlist.name || index}`;
|
||||
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
|
||||
const isMenuOpen = openInjectedPlaylistMenuKey === detailsKey;
|
||||
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
|
||||
const escapedPlaylistName = escapeHtml(playlist.name);
|
||||
const escapedSyncSchedule = escapeHtml(syncSchedule);
|
||||
const escapedDetailsKey = escapeHtml(detailsKey);
|
||||
|
||||
const breakdownBadges = [
|
||||
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
||||
`<span class="status-pill info">${summary.externalMatched} External</span>`,
|
||||
];
|
||||
|
||||
if (summary.externalMissing > 0) {
|
||||
breakdownBadges.push(
|
||||
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
|
||||
);
|
||||
}
|
||||
|
||||
return `
|
||||
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
|
||||
<td>
|
||||
<div class="name-cell">
|
||||
<strong>${escapeHtml(playlist.name)}</strong>
|
||||
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="track-count">${summary.totalPlayable}/${summary.spotifyTotal}</span>
|
||||
<div class="meta-text">${summary.completionPct}% playable</div>
|
||||
</td>
|
||||
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
|
||||
<td class="row-controls">
|
||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button>
|
||||
<div class="row-actions-wrap">
|
||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="${isMenuOpen ? "true" : "false"}"
|
||||
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
||||
<div class="row-actions-menu ${isMenuOpen ? "open" : ""}" id="${menuId}" data-menu-key="${escapedDetailsKey}" role="menu">
|
||||
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button>
|
||||
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button>
|
||||
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button>
|
||||
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button>
|
||||
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button>
|
||||
<hr>
|
||||
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
|
||||
<td colspan="4">
|
||||
<div class="details-panel">
|
||||
<div class="details-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Sync Schedule</span>
|
||||
<span class="detail-value mono">
|
||||
${escapeHtml(syncSchedule)}
|
||||
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Cache Age</span>
|
||||
<span class="detail-value">${escapeHtml(playlist.cacheAge || "-")}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Track Breakdown</span>
|
||||
<span class="detail-value">${breakdownBadges.join(" ")}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Completion</span>
|
||||
<div class="completion-bar">
|
||||
<div class="completion-fill ${summary.completionClass}" style="width:${Math.max(0, Math.min(summary.completionPct, 100))}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function createPlaylistRowPair(playlist, index) {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = renderPlaylistRowPairMarkup(playlist, index).trim();
|
||||
const [mainRow, detailsRow] = template.content.querySelectorAll("tr");
|
||||
return { mainRow, detailsRow };
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.toggleRowMenu = toggleRowMenu;
|
||||
window.closeRowMenu = closeRowMenu;
|
||||
@@ -235,9 +528,6 @@ bindRowMenuHandlers();
|
||||
bindTableRowHandlers();
|
||||
|
||||
export function updateStatusUI(data) {
|
||||
const versionEl = document.getElementById("version");
|
||||
if (versionEl) versionEl.textContent = "v" + data.version;
|
||||
|
||||
const sidebarVersionEl = document.getElementById("sidebar-version");
|
||||
if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version;
|
||||
|
||||
@@ -321,10 +611,15 @@ export function updateStatusUI(data) {
|
||||
|
||||
export function updatePlaylistsUI(data) {
|
||||
const tbody = document.getElementById("playlist-table-body");
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playlists = data.playlists || [];
|
||||
|
||||
if (playlists.length === 0) {
|
||||
expandedInjectedPlaylistDetails.clear();
|
||||
openInjectedPlaylistMenuKey = null;
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
|
||||
renderGuidance("playlists-guidance", [
|
||||
@@ -378,91 +673,68 @@ export function updatePlaylistsUI(data) {
|
||||
});
|
||||
renderGuidance("playlists-guidance", guidance);
|
||||
|
||||
tbody.innerHTML = playlists
|
||||
.map((playlist, index) => {
|
||||
const summary = getPlaylistStatusSummary(playlist);
|
||||
const detailsRowId = `playlist-details-${index}`;
|
||||
const menuId = `playlist-menu-${index}`;
|
||||
const detailsKey = `${playlist.id || playlist.name || index}`;
|
||||
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
|
||||
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
|
||||
const escapedPlaylistName = escapeHtml(playlist.name);
|
||||
const escapedSyncSchedule = escapeHtml(syncSchedule);
|
||||
const escapedDetailsKey = escapeHtml(detailsKey);
|
||||
const existingPairs = new Map();
|
||||
Array.from(
|
||||
tbody.querySelectorAll("tr.compact-row[data-details-key]"),
|
||||
).forEach((mainRow) => {
|
||||
const detailsKey = mainRow.getAttribute("data-details-key");
|
||||
if (!detailsKey || existingPairs.has(detailsKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const breakdownBadges = [
|
||||
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
||||
`<span class="status-pill info">${summary.externalMatched} External</span>`,
|
||||
];
|
||||
const detailsRowId = mainRow.getAttribute("data-details-row");
|
||||
const detailsRow =
|
||||
(detailsRowId && document.getElementById(detailsRowId)) ||
|
||||
mainRow.nextElementSibling;
|
||||
if (!detailsRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (summary.externalMissing > 0) {
|
||||
breakdownBadges.push(
|
||||
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
|
||||
);
|
||||
}
|
||||
existingPairs.set(detailsKey, { mainRow, detailsRow });
|
||||
});
|
||||
|
||||
return `
|
||||
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
|
||||
<td>
|
||||
<div class="name-cell">
|
||||
<strong>${escapeHtml(playlist.name)}</strong>
|
||||
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="track-count">${summary.totalPlayable}/${summary.spotifyTotal}</span>
|
||||
<div class="meta-text">${summary.completionPct}% playable</div>
|
||||
</td>
|
||||
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
|
||||
<td class="row-controls">
|
||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button>
|
||||
<div class="row-actions-wrap">
|
||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
||||
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
||||
<div class="row-actions-menu" id="${menuId}" role="menu">
|
||||
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button>
|
||||
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button>
|
||||
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button>
|
||||
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button>
|
||||
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button>
|
||||
<hr>
|
||||
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
|
||||
<td colspan="4">
|
||||
<div class="details-panel">
|
||||
<div class="details-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Sync Schedule</span>
|
||||
<span class="detail-value mono">
|
||||
${escapeHtml(syncSchedule)}
|
||||
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Cache Age</span>
|
||||
<span class="detail-value">${escapeHtml(playlist.cacheAge || "-")}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Track Breakdown</span>
|
||||
<span class="detail-value">${breakdownBadges.join(" ")}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Completion</span>
|
||||
<div class="completion-bar">
|
||||
<div class="completion-fill ${summary.completionClass}" style="width:${Math.max(0, Math.min(summary.completionPct, 100))}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
const orderedRows = [];
|
||||
playlists.forEach((playlist, index) => {
|
||||
const detailsKey = `${playlist.id || playlist.name || index}`;
|
||||
const { mainRow: nextMainRow, detailsRow: nextDetailsRow } =
|
||||
createPlaylistRowPair(playlist, index);
|
||||
const existingPair = existingPairs.get(detailsKey);
|
||||
|
||||
if (!existingPair) {
|
||||
orderedRows.push(nextMainRow, nextDetailsRow);
|
||||
return;
|
||||
}
|
||||
|
||||
syncPlaylistMainRow(
|
||||
existingPair.mainRow,
|
||||
nextMainRow,
|
||||
detailsKey === openInjectedPlaylistMenuKey,
|
||||
);
|
||||
syncPlaylistDetailsRow(existingPair.detailsRow, nextDetailsRow);
|
||||
|
||||
orderedRows.push(existingPair.mainRow, existingPair.detailsRow);
|
||||
existingPairs.delete(detailsKey);
|
||||
});
|
||||
|
||||
const activeRows = new Set(orderedRows);
|
||||
orderedRows.forEach((row) => {
|
||||
tbody.append(row);
|
||||
});
|
||||
Array.from(tbody.children).forEach((row) => {
|
||||
if (!activeRows.has(row)) {
|
||||
row.remove();
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
openInjectedPlaylistMenuKey &&
|
||||
!playlists.some(
|
||||
(playlist, index) =>
|
||||
`${playlist.id || playlist.name || index}` === openInjectedPlaylistMenuKey,
|
||||
)
|
||||
) {
|
||||
openInjectedPlaylistMenuKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTrackMappingsUI(data) {
|
||||
|
||||
+28
-33
@@ -146,9 +146,9 @@ body {
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto 0 0;
|
||||
padding: 20px 20px 20px 8px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
@@ -182,6 +182,15 @@ body {
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.title-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title-link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.sidebar-subtitle {
|
||||
margin-top: 2px;
|
||||
color: var(--text-secondary);
|
||||
@@ -190,6 +199,18 @@ body {
|
||||
monospace;
|
||||
}
|
||||
|
||||
.sidebar-status {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sidebar-status .status-badge {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -232,15 +253,6 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.top-tabs,
|
||||
.tabs.top-tabs {
|
||||
display: none !important;
|
||||
@@ -255,21 +267,6 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-user {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
@@ -283,12 +280,6 @@ h1 {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h1 .version {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1040,6 +1031,10 @@ input::placeholder {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
|
||||
Reference in New Issue
Block a user