mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 03:53:10 -04:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d89dd5e7db
|
|||
|
b715802a4e
|
|||
|
5f817abda2
|
|||
|
69f0c53ade
|
|||
|
8baa8277e0
|
|||
|
baaea5747f
|
|||
|
f8a355f97e
|
|||
|
5a97573e58
|
|||
|
3cd4560406
|
|||
|
993a750008
|
|||
|
6737b2e0f4
|
|||
|
24811909b2
|
|||
|
9d80ff65c5
|
|||
|
2eeda9dda0
|
|||
|
fd02ea9167
|
|||
|
b1ad871632
|
|||
|
c3f6e8e3b7
|
|||
|
eaf256659d
|
|||
|
7fb71d5ccc
|
|||
|
7ef0fd01dc
|
|||
|
f0ccb873a2
|
|||
|
105acb881d
|
|||
|
93213fa335
|
|||
|
b58d466a80
|
|||
|
77614ccfb9
|
|||
|
02d49c1ab6
|
|||
|
3c02988134
|
|||
|
919336b81a
|
|||
|
c59fa2dd11
|
|||
|
a5de24587a
|
|||
|
b8f8fcb1f8
|
|||
|
228e1a7f42
|
|||
|
c2c20cb5b3
|
|||
|
8239316019
|
|||
|
e8e7f69e13
|
|||
|
815a75fd56
|
|||
|
9d58cdd1bd
|
|||
|
806511d727
|
|||
|
02967c8c67
|
|||
|
bf6fa4e647
|
|||
|
04e0c357aa
|
|||
|
ee98464475
|
|||
|
66f64d6de7
|
|||
|
8d3fde8fb9
|
|||
|
51d3d784b5
|
|||
|
dbc7bd6ea1
|
|||
|
b54d41f560
|
|||
|
877d2ffddf
|
+1
-1
@@ -10,6 +10,6 @@ liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
buy_me_a_coffee: treeman183
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
||||
/// <summary>
|
||||
/// Current application version.
|
||||
/// </summary>
|
||||
public const string Version = "1.5.2";
|
||||
public const string Version = "1.5.3";
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path fill="#FFDD00" d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 96" aria-hidden="true"><path fill="#f0f6fc" fill-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 980 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_1_219" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
|
||||
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1_219)">
|
||||
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
|
||||
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
|
||||
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
|
||||
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
|
||||
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
|
||||
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
+56
-25
@@ -34,11 +34,30 @@
|
||||
</div>
|
||||
|
||||
<div class="support-badge">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
<p class="support-text">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development
|
||||
</p>
|
||||
<ul class="support-funding-icons">
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Support on Ko-fi">
|
||||
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="GitHub Sponsors">
|
||||
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
|
||||
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,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>
|
||||
@@ -67,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>
|
||||
@@ -974,13 +988,30 @@
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<p>
|
||||
<p class="support-text">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
supporting its development
|
||||
</p>
|
||||
<ul class="support-funding-icons">
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Support on Ko-fi">
|
||||
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="GitHub Sponsors">
|
||||
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
|
||||
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</main>
|
||||
</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) {
|
||||
|
||||
@@ -669,13 +669,30 @@
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<p>
|
||||
<p class="support-text">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
supporting its development
|
||||
</p>
|
||||
<ul class="support-funding-icons">
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Support on Ko-fi">
|
||||
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="GitHub Sponsors">
|
||||
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
|
||||
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+76
-33
@@ -97,10 +97,58 @@ body {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.support-text {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.support-funding-icons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.support-funding-icons li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.support-badge .support-funding-icons {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.support-footer .support-funding-icons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.support-funding-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.9;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.support-badge .support-funding-link:hover,
|
||||
.support-footer .support-funding-link:hover {
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.support-funding-link img {
|
||||
display: block;
|
||||
height: 30px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto 0 0;
|
||||
padding: 20px 20px 20px 8px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
@@ -134,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);
|
||||
@@ -142,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;
|
||||
@@ -184,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;
|
||||
@@ -207,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;
|
||||
@@ -235,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;
|
||||
@@ -992,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