mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-23 02:32:48 -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
|
public class DownloadsControllerPathSecurityTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected()
|
public async Task DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected()
|
||||||
{
|
{
|
||||||
var testRoot = CreateTestRoot();
|
var testRoot = CreateTestRoot();
|
||||||
var downloadsRoot = Path.Combine(testRoot, "downloads");
|
var downloadsRoot = Path.Combine(testRoot, "downloads");
|
||||||
@@ -23,7 +23,7 @@ public class DownloadsControllerPathSecurityTests
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var controller = CreateController(downloadsRoot);
|
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);
|
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
|
||||||
Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode);
|
Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode);
|
||||||
@@ -63,7 +63,7 @@ public class DownloadsControllerPathSecurityTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DownloadFile_ValidPathInsideKeptFolder_AllowsDownload()
|
public async Task DownloadFile_ValidPathInsideKeptFolder_AllowsDownload()
|
||||||
{
|
{
|
||||||
var testRoot = CreateTestRoot();
|
var testRoot = CreateTestRoot();
|
||||||
var downloadsRoot = Path.Combine(testRoot, "downloads");
|
var downloadsRoot = Path.Combine(testRoot, "downloads");
|
||||||
@@ -76,7 +76,7 @@ public class DownloadsControllerPathSecurityTests
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var controller = CreateController(downloadsRoot);
|
var controller = CreateController(downloadsRoot);
|
||||||
var result = controller.DownloadFile("Artist/track.mp3");
|
var result = await controller.DownloadFile("Artist/track.mp3");
|
||||||
|
|
||||||
Assert.IsType<FileStreamResult>(result);
|
Assert.IsType<FileStreamResult>(result);
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,13 @@ public class DownloadsControllerPathSecurityTests
|
|||||||
|
|
||||||
return new DownloadsController(
|
return new DownloadsController(
|
||||||
NullLogger<DownloadsController>.Instance,
|
NullLogger<DownloadsController>.Instance,
|
||||||
config);
|
config)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext()
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CreateTestRoot()
|
private static string CreateTestRoot()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using allstarr.Filters;
|
using allstarr.Filters;
|
||||||
using allstarr.Services.Admin;
|
using allstarr.Services.Admin;
|
||||||
|
using allstarr.Services.Lyrics;
|
||||||
|
|
||||||
namespace allstarr.Controllers;
|
namespace allstarr.Controllers;
|
||||||
|
|
||||||
@@ -9,15 +10,20 @@ namespace allstarr.Controllers;
|
|||||||
[ServiceFilter(typeof(AdminPortFilter))]
|
[ServiceFilter(typeof(AdminPortFilter))]
|
||||||
public class DownloadsController : ControllerBase
|
public class DownloadsController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private static readonly string[] AudioExtensions = [".flac", ".mp3", ".m4a", ".opus"];
|
||||||
|
|
||||||
private readonly ILogger<DownloadsController> _logger;
|
private readonly ILogger<DownloadsController> _logger;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService;
|
||||||
|
|
||||||
public DownloadsController(
|
public DownloadsController(
|
||||||
ILogger<DownloadsController> logger,
|
ILogger<DownloadsController> logger,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration,
|
||||||
|
IKeptLyricsSidecarService? keptLyricsSidecarService = null)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
|
_keptLyricsSidecarService = keptLyricsSidecarService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("downloads")]
|
[HttpGet("downloads")]
|
||||||
@@ -36,10 +42,8 @@ public class DownloadsController : ControllerBase
|
|||||||
long totalSize = 0;
|
long totalSize = 0;
|
||||||
|
|
||||||
// Recursively get all audio files from kept folder
|
// Recursively get all audio files from kept folder
|
||||||
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
|
||||||
|
|
||||||
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
||||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
.Where(IsSupportedAudioFile)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
foreach (var filePath in allFiles)
|
foreach (var filePath in allFiles)
|
||||||
@@ -112,6 +116,11 @@ public class DownloadsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
System.IO.File.Delete(fullPath);
|
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)
|
// Clean up empty directories (Album folder, then Artist folder if empty)
|
||||||
var directory = Path.GetDirectoryName(fullPath);
|
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" });
|
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)
|
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
||||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
.Where(IsSupportedAudioFile)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
foreach (var filePath in allFiles)
|
foreach (var filePath in allFiles)
|
||||||
@@ -164,6 +172,12 @@ public class DownloadsController : ControllerBase
|
|||||||
System.IO.File.Delete(filePath);
|
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)
|
// Clean up empty directories under kept root (deepest first)
|
||||||
var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories)
|
var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories)
|
||||||
.OrderByDescending(d => d.Length);
|
.OrderByDescending(d => d.Length);
|
||||||
@@ -194,7 +208,7 @@ public class DownloadsController : ControllerBase
|
|||||||
/// Downloads a specific file from the kept folder
|
/// Downloads a specific file from the kept folder
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("downloads/file")]
|
[HttpGet("downloads/file")]
|
||||||
public IActionResult DownloadFile([FromQuery] string path)
|
public async Task<IActionResult> DownloadFile([FromQuery] string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -216,8 +230,16 @@ public class DownloadsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fileName = Path.GetFileName(fullPath);
|
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);
|
return File(fileStream, "application/octet-stream", fileName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -232,7 +254,7 @@ public class DownloadsController : ControllerBase
|
|||||||
/// Downloads all kept files as a zip archive
|
/// Downloads all kept files as a zip archive
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("downloads/all")]
|
[HttpGet("downloads/all")]
|
||||||
public IActionResult DownloadAllFiles()
|
public async Task<IActionResult> DownloadAllFiles()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -243,9 +265,8 @@ public class DownloadsController : ControllerBase
|
|||||||
return NotFound(new { error = "No kept files found" });
|
return NotFound(new { error = "No kept files found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
|
||||||
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
||||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
.Where(IsSupportedAudioFile)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (allFiles.Count == 0)
|
if (allFiles.Count == 0)
|
||||||
@@ -259,14 +280,18 @@ public class DownloadsController : ControllerBase
|
|||||||
var memoryStream = new MemoryStream();
|
var memoryStream = new MemoryStream();
|
||||||
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
|
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)
|
foreach (var filePath in allFiles)
|
||||||
{
|
{
|
||||||
var relativePath = Path.GetRelativePath(keptPath, filePath);
|
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();
|
var sidecarPath = await EnsureLyricsSidecarIfPossibleAsync(filePath, HttpContext.RequestAborted);
|
||||||
using var fileStream = System.IO.File.OpenRead(filePath);
|
if (System.IO.File.Exists(sidecarPath))
|
||||||
fileStream.CopyTo(entryStream);
|
{
|
||||||
|
var sidecarRelativePath = Path.GetRelativePath(keptPath, sidecarPath);
|
||||||
|
await AddFileToArchiveAsync(archive, sidecarPath, sidecarRelativePath, addedEntries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +355,54 @@ public class DownloadsController : ControllerBase
|
|||||||
: StringComparison.Ordinal;
|
: 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>
|
/// <summary>
|
||||||
/// Gets all Spotify track mappings (paginated)
|
/// Gets all Spotify track mappings (paginated)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ namespace allstarr.Controllers;
|
|||||||
|
|
||||||
public partial class JellyfinController
|
public partial class JellyfinController
|
||||||
{
|
{
|
||||||
|
private static readonly string[] KeptAudioExtensions = [".flac", ".mp3", ".m4a", ".opus"];
|
||||||
|
|
||||||
#region Spotify Playlist Injection
|
#region Spotify Playlist Injection
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -480,10 +482,13 @@ public partial class JellyfinController
|
|||||||
if (Directory.Exists(keptAlbumPath))
|
if (Directory.Exists(keptAlbumPath))
|
||||||
{
|
{
|
||||||
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
|
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
|
||||||
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
|
var existingAudioFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*")
|
||||||
if (existingFiles.Length > 0)
|
.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
|
// Mark as favorited even if we didn't download it
|
||||||
await MarkTrackAsFavoritedAsync(itemId, song);
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||||
return;
|
return;
|
||||||
@@ -572,6 +577,7 @@ public partial class JellyfinController
|
|||||||
{
|
{
|
||||||
// Race condition - file was created by another request
|
// Race condition - file was created by another request
|
||||||
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
|
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
|
||||||
|
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
|
||||||
await MarkTrackAsFavoritedAsync(itemId, song);
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -589,6 +595,7 @@ public partial class JellyfinController
|
|||||||
{
|
{
|
||||||
// Race condition on copy fallback
|
// Race condition on copy fallback
|
||||||
_logger.LogInformation("Track already exists in kept folder (race condition on copy): {Path}", keptFilePath);
|
_logger.LogInformation("Track already exists in kept folder (race condition on copy): {Path}", keptFilePath);
|
||||||
|
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
|
||||||
await MarkTrackAsFavoritedAsync(itemId, song);
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -650,6 +657,8 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
|
||||||
|
|
||||||
// Mark as favorited in persistent storage
|
// Mark as favorited in persistent storage
|
||||||
await MarkTrackAsFavoritedAsync(itemId, song);
|
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
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public partial class JellyfinController : ControllerBase
|
|||||||
private readonly LyricsPlusService? _lyricsPlusService;
|
private readonly LyricsPlusService? _lyricsPlusService;
|
||||||
private readonly LrclibService? _lrclibService;
|
private readonly LrclibService? _lrclibService;
|
||||||
private readonly LyricsOrchestrator? _lyricsOrchestrator;
|
private readonly LyricsOrchestrator? _lyricsOrchestrator;
|
||||||
|
private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService;
|
||||||
private readonly ScrobblingOrchestrator? _scrobblingOrchestrator;
|
private readonly ScrobblingOrchestrator? _scrobblingOrchestrator;
|
||||||
private readonly ScrobblingHelper? _scrobblingHelper;
|
private readonly ScrobblingHelper? _scrobblingHelper;
|
||||||
private readonly OdesliService _odesliService;
|
private readonly OdesliService _odesliService;
|
||||||
@@ -77,6 +78,7 @@ public partial class JellyfinController : ControllerBase
|
|||||||
LyricsPlusService? lyricsPlusService = null,
|
LyricsPlusService? lyricsPlusService = null,
|
||||||
LrclibService? lrclibService = null,
|
LrclibService? lrclibService = null,
|
||||||
LyricsOrchestrator? lyricsOrchestrator = null,
|
LyricsOrchestrator? lyricsOrchestrator = null,
|
||||||
|
IKeptLyricsSidecarService? keptLyricsSidecarService = null,
|
||||||
ScrobblingOrchestrator? scrobblingOrchestrator = null,
|
ScrobblingOrchestrator? scrobblingOrchestrator = null,
|
||||||
ScrobblingHelper? scrobblingHelper = null)
|
ScrobblingHelper? scrobblingHelper = null)
|
||||||
{
|
{
|
||||||
@@ -98,6 +100,7 @@ public partial class JellyfinController : ControllerBase
|
|||||||
_lyricsPlusService = lyricsPlusService;
|
_lyricsPlusService = lyricsPlusService;
|
||||||
_lrclibService = lrclibService;
|
_lrclibService = lrclibService;
|
||||||
_lyricsOrchestrator = lyricsOrchestrator;
|
_lyricsOrchestrator = lyricsOrchestrator;
|
||||||
|
_keptLyricsSidecarService = keptLyricsSidecarService;
|
||||||
_scrobblingOrchestrator = scrobblingOrchestrator;
|
_scrobblingOrchestrator = scrobblingOrchestrator;
|
||||||
_scrobblingHelper = scrobblingHelper;
|
_scrobblingHelper = scrobblingHelper;
|
||||||
_odesliService = odesliService;
|
_odesliService = odesliService;
|
||||||
|
|||||||
@@ -712,6 +712,7 @@ builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
|
|||||||
|
|
||||||
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
|
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
|
||||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
|
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)
|
// Register Spotify mapping service (global Spotify ID → Local/External mappings)
|
||||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyMappingService>();
|
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">
|
<div class="app-shell">
|
||||||
<aside class="sidebar" aria-label="Admin navigation">
|
<aside class="sidebar" aria-label="Admin navigation">
|
||||||
<div class="sidebar-brand">
|
<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-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>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button>
|
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button>
|
||||||
@@ -86,20 +95,6 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="app-main">
|
<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="tabs top-tabs" aria-hidden="true">
|
||||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ let onCookieNeedsInit = async () => {};
|
|||||||
let setCurrentConfigState = () => {};
|
let setCurrentConfigState = () => {};
|
||||||
let syncConfigUiExtras = () => {};
|
let syncConfigUiExtras = () => {};
|
||||||
let loadScrobblingConfig = () => {};
|
let loadScrobblingConfig = () => {};
|
||||||
|
let injectedPlaylistRequestToken = 0;
|
||||||
let jellyfinPlaylistRequestToken = 0;
|
let jellyfinPlaylistRequestToken = 0;
|
||||||
|
|
||||||
async function fetchStatus() {
|
async function fetchStatus() {
|
||||||
@@ -39,10 +40,20 @@ async function fetchStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPlaylists(silent = false) {
|
async function fetchPlaylists(silent = false) {
|
||||||
|
const requestToken = ++injectedPlaylistRequestToken;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await API.fetchPlaylists();
|
const data = await API.fetchPlaylists();
|
||||||
|
if (requestToken !== injectedPlaylistRequestToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
UI.updatePlaylistsUI(data);
|
UI.updatePlaylistsUI(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestToken !== injectedPlaylistRequestToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
console.error("Failed to fetch playlists:", error);
|
console.error("Failed to fetch playlists:", error);
|
||||||
showToast("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 rowMenuHandlersBound = false;
|
||||||
let tableRowHandlersBound = false;
|
let tableRowHandlersBound = false;
|
||||||
const expandedInjectedPlaylistDetails = new Set();
|
const expandedInjectedPlaylistDetails = new Set();
|
||||||
|
let openInjectedPlaylistMenuKey = null;
|
||||||
|
|
||||||
function bindRowMenuHandlers() {
|
function bindRowMenuHandlers() {
|
||||||
if (rowMenuHandlersBound) {
|
if (rowMenuHandlersBound) {
|
||||||
@@ -57,8 +58,16 @@ function closeAllRowMenus(exceptId = null) {
|
|||||||
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
|
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
|
||||||
if (!exceptId || menu.id !== exceptId) {
|
if (!exceptId || menu.id !== exceptId) {
|
||||||
menu.classList.remove("open");
|
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) {
|
function closeRowMenu(event, menuId) {
|
||||||
@@ -69,6 +78,13 @@ function closeRowMenu(event, menuId) {
|
|||||||
const menu = document.getElementById(menuId);
|
const menu = document.getElementById(menuId);
|
||||||
if (menu) {
|
if (menu) {
|
||||||
menu.classList.remove("open");
|
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");
|
const isOpen = menu.classList.contains("open");
|
||||||
closeAllRowMenus(menuId);
|
closeAllRowMenus(menuId);
|
||||||
menu.classList.toggle("open", !isOpen);
|
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) {
|
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") {
|
if (typeof window !== "undefined") {
|
||||||
window.toggleRowMenu = toggleRowMenu;
|
window.toggleRowMenu = toggleRowMenu;
|
||||||
window.closeRowMenu = closeRowMenu;
|
window.closeRowMenu = closeRowMenu;
|
||||||
@@ -235,9 +528,6 @@ bindRowMenuHandlers();
|
|||||||
bindTableRowHandlers();
|
bindTableRowHandlers();
|
||||||
|
|
||||||
export function updateStatusUI(data) {
|
export function updateStatusUI(data) {
|
||||||
const versionEl = document.getElementById("version");
|
|
||||||
if (versionEl) versionEl.textContent = "v" + data.version;
|
|
||||||
|
|
||||||
const sidebarVersionEl = document.getElementById("sidebar-version");
|
const sidebarVersionEl = document.getElementById("sidebar-version");
|
||||||
if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version;
|
if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version;
|
||||||
|
|
||||||
@@ -321,10 +611,15 @@ export function updateStatusUI(data) {
|
|||||||
|
|
||||||
export function updatePlaylistsUI(data) {
|
export function updatePlaylistsUI(data) {
|
||||||
const tbody = document.getElementById("playlist-table-body");
|
const tbody = document.getElementById("playlist-table-body");
|
||||||
|
if (!tbody) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const playlists = data.playlists || [];
|
const playlists = data.playlists || [];
|
||||||
|
|
||||||
if (playlists.length === 0) {
|
if (playlists.length === 0) {
|
||||||
expandedInjectedPlaylistDetails.clear();
|
expandedInjectedPlaylistDetails.clear();
|
||||||
|
openInjectedPlaylistMenuKey = null;
|
||||||
tbody.innerHTML =
|
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>';
|
'<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", [
|
renderGuidance("playlists-guidance", [
|
||||||
@@ -378,91 +673,68 @@ export function updatePlaylistsUI(data) {
|
|||||||
});
|
});
|
||||||
renderGuidance("playlists-guidance", guidance);
|
renderGuidance("playlists-guidance", guidance);
|
||||||
|
|
||||||
tbody.innerHTML = playlists
|
const existingPairs = new Map();
|
||||||
.map((playlist, index) => {
|
Array.from(
|
||||||
const summary = getPlaylistStatusSummary(playlist);
|
tbody.querySelectorAll("tr.compact-row[data-details-key]"),
|
||||||
const detailsRowId = `playlist-details-${index}`;
|
).forEach((mainRow) => {
|
||||||
const menuId = `playlist-menu-${index}`;
|
const detailsKey = mainRow.getAttribute("data-details-key");
|
||||||
const detailsKey = `${playlist.id || playlist.name || index}`;
|
if (!detailsKey || existingPairs.has(detailsKey)) {
|
||||||
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
|
return;
|
||||||
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
|
}
|
||||||
const escapedPlaylistName = escapeHtml(playlist.name);
|
|
||||||
const escapedSyncSchedule = escapeHtml(syncSchedule);
|
|
||||||
const escapedDetailsKey = escapeHtml(detailsKey);
|
|
||||||
|
|
||||||
const breakdownBadges = [
|
const detailsRowId = mainRow.getAttribute("data-details-row");
|
||||||
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
const detailsRow =
|
||||||
`<span class="status-pill info">${summary.externalMatched} External</span>`,
|
(detailsRowId && document.getElementById(detailsRowId)) ||
|
||||||
];
|
mainRow.nextElementSibling;
|
||||||
|
if (!detailsRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (summary.externalMissing > 0) {
|
existingPairs.set(detailsKey, { mainRow, detailsRow });
|
||||||
breakdownBadges.push(
|
});
|
||||||
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
const orderedRows = [];
|
||||||
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
|
playlists.forEach((playlist, index) => {
|
||||||
<td>
|
const detailsKey = `${playlist.id || playlist.name || index}`;
|
||||||
<div class="name-cell">
|
const { mainRow: nextMainRow, detailsRow: nextDetailsRow } =
|
||||||
<strong>${escapeHtml(playlist.name)}</strong>
|
createPlaylistRowPair(playlist, index);
|
||||||
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
|
const existingPair = existingPairs.get(detailsKey);
|
||||||
</div>
|
|
||||||
</td>
|
if (!existingPair) {
|
||||||
<td>
|
orderedRows.push(nextMainRow, nextDetailsRow);
|
||||||
<span class="track-count">${summary.totalPlayable}/${summary.spotifyTotal}</span>
|
return;
|
||||||
<div class="meta-text">${summary.completionPct}% playable</div>
|
}
|
||||||
</td>
|
|
||||||
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
|
syncPlaylistMainRow(
|
||||||
<td class="row-controls">
|
existingPair.mainRow,
|
||||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button>
|
nextMainRow,
|
||||||
<div class="row-actions-wrap">
|
detailsKey === openInjectedPlaylistMenuKey,
|
||||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
);
|
||||||
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
syncPlaylistDetailsRow(existingPair.detailsRow, nextDetailsRow);
|
||||||
<div class="row-actions-menu" id="${menuId}" role="menu">
|
|
||||||
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button>
|
orderedRows.push(existingPair.mainRow, existingPair.detailsRow);
|
||||||
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button>
|
existingPairs.delete(detailsKey);
|
||||||
<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>
|
const activeRows = new Set(orderedRows);
|
||||||
<hr>
|
orderedRows.forEach((row) => {
|
||||||
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button>
|
tbody.append(row);
|
||||||
</div>
|
});
|
||||||
</div>
|
Array.from(tbody.children).forEach((row) => {
|
||||||
</td>
|
if (!activeRows.has(row)) {
|
||||||
</tr>
|
row.remove();
|
||||||
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
|
}
|
||||||
<td colspan="4">
|
});
|
||||||
<div class="details-panel">
|
|
||||||
<div class="details-grid">
|
if (
|
||||||
<div class="detail-item">
|
openInjectedPlaylistMenuKey &&
|
||||||
<span class="detail-label">Sync Schedule</span>
|
!playlists.some(
|
||||||
<span class="detail-value mono">
|
(playlist, index) =>
|
||||||
${escapeHtml(syncSchedule)}
|
`${playlist.id || playlist.name || index}` === openInjectedPlaylistMenuKey,
|
||||||
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button>
|
)
|
||||||
</span>
|
) {
|
||||||
</div>
|
openInjectedPlaylistMenuKey = null;
|
||||||
<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("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTrackMappingsUI(data) {
|
export function updateTrackMappingsUI(data) {
|
||||||
|
|||||||
+28
-33
@@ -146,9 +146,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto 0 0;
|
||||||
padding: 20px;
|
padding: 20px 20px 20px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
@@ -182,6 +182,15 @@ body {
|
|||||||
letter-spacing: 0.2px;
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-link:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-subtitle {
|
.sidebar-subtitle {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -190,6 +199,18 @@ body {
|
|||||||
monospace;
|
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 {
|
.sidebar-nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -232,15 +253,6 @@ body {
|
|||||||
min-width: 0;
|
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,
|
.top-tabs,
|
||||||
.tabs.top-tabs {
|
.tabs.top-tabs {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -255,21 +267,6 @@ body {
|
|||||||
text-align: center;
|
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 {
|
.auth-user {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -283,12 +280,6 @@ h1 {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 .version {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1040,6 +1031,10 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user