Compare commits

...

48 Commits

Author SHA1 Message Date
joshpatra d89dd5e7db fix(ui): remove duplicate top header
CI / build-and-test (push) Has been cancelled
2026-04-18 00:33:18 -04:00
joshpatra b715802a4e fix(ui): preserve playlist menu during refresh 2026-04-18 00:32:20 -04:00
joshpatra 5f817abda2 feat(ui): link admin titles to github 2026-04-18 00:20:25 -04:00
joshpatra 69f0c53ade feat(ui): move spotify status into sidebar 2026-04-18 00:18:51 -04:00
joshpatra 8baa8277e0 feat(lyrics): add kept download lrc sidecars 2026-04-18 00:14:48 -04:00
joshpatra baaea5747f Merge branch 'main' into dev
CI / build-and-test (push) Has been cancelled
2026-04-09 17:04:52 -04:00
joshpatra f8a355f97e v1.5.3: feat: completely overhaul search to much better respect the respective search orderings, treats it as fifo, and also entirely transparently proxies Syncplay endpoints and Sessions moreso to allow for syncplay to work
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-04-09 16:59:27 -04:00
joshpatra 5a97573e58 Merge branch 'beta' into dev 2026-04-09 16:55:23 -04:00
joshpatra 3cd4560406 v1.5.3-beta.1: small version bump, includes some UI updates and optimizations, and updated links, etc 2026-04-09 16:55:12 -04:00
joshpatra 993a750008 chore: version bump 2026-04-09 16:54:16 -04:00
joshpatra 6737b2e0f4 feat(ui): add funding icons for Ko-fi, GitHub Sponsors, and BMC 2026-04-09 16:50:24 -04:00
joshpatra 24811909b2 Merge branch 'beta' into dev
CI / build-and-test (push) Has been cancelled
2026-04-07 17:34:36 -04:00
joshpatra 9d80ff65c5 chore: version bump 2026-04-07 17:33:33 -04:00
joshpatra 2eeda9dda0 Merge branch 'beta' into dev 2026-04-07 17:26:15 -04:00
joshpatra fd02ea9167 Merge branch 'beta' into dev 2026-04-07 17:13:45 -04:00
joshpatra b1ad871632 fix(admin): avoid startup hangs and log external match timeouts 2026-04-07 16:38:33 -04:00
joshpatra c3f6e8e3b7 chore: version bump 2026-04-07 16:18:57 -04:00
joshpatra eaf256659d fix(webui): use squid search route for missing-track links 2026-04-07 16:18:32 -04:00
joshpatra 7fb71d5ccc fix(webui): restore missing track search and playlist selector parsing 2026-04-07 16:13:10 -04:00
joshpatra 7ef0fd01dc fix(webui): import escapeJs for kept downloads rendering 2026-04-07 16:09:48 -04:00
joshpatra f0ccb873a2 Revert "fix(webui): guard kept downloads fetch behind admin auth"
This reverts commit 02d49c1ab6.
2026-04-07 16:09:20 -04:00
joshpatra 105acb881d Revert "fix(webui): retry kept downloads fetch after auth race"
This reverts commit 77614ccfb9.
2026-04-07 16:09:20 -04:00
joshpatra 93213fa335 Revert "fix(webui): avoid logout on kept downloads auth race"
This reverts commit b58d466a80.
2026-04-07 16:09:20 -04:00
joshpatra b58d466a80 fix(webui): avoid logout on kept downloads auth race 2026-04-07 16:04:33 -04:00
joshpatra 77614ccfb9 fix(webui): retry kept downloads fetch after auth race 2026-04-07 16:01:22 -04:00
joshpatra 02d49c1ab6 fix(webui): guard kept downloads fetch behind admin auth 2026-04-07 15:59:05 -04:00
joshpatra 3c02988134 fix(webui): stabilize admin playlists and kept downloads UX 2026-04-07 15:55:03 -04:00
joshpatra 919336b81a fix(webui): hide legacy top tab strip
CI / build-and-test (push) Has been cancelled
2026-04-06 15:07:36 -04:00
joshpatra c59fa2dd11 fix spotify graphql playlist attribute parsing 2026-04-06 14:42:48 -04:00
joshpatra a5de24587a feat(webui): overhaul admin UI layout and interaction wiring 2026-04-06 14:42:34 -04:00
joshpatra b8f8fcb1f8 fix external search bucket fanout
CI / build-and-test (push) Has been cancelled
2026-04-06 12:55:43 -04:00
joshpatra 228e1a7f42 perf(images): support conditional ETag responses 2026-04-06 11:43:58 -04:00
joshpatra c2c20cb5b3 perf: use named HttpClient with SocketsHttpHandler connection pooling for Jellyfin backend 2026-04-06 11:31:19 -04:00
joshpatra 8239316019 chore: version bump 2026-04-06 03:02:50 -04:00
joshpatra e8e7f69e13 fix(search): add jellyfin-compatible external item fields
CI / build-and-test (push) Has been cancelled
2026-04-05 17:41:24 -04:00
joshpatra 815a75fd56 feat(search): implement fifo queue merge scoring 2026-04-05 17:39:46 -04:00
joshpatra 9d58cdd1bd tune(search): restore jellyfin lead boost 2026-04-05 17:16:20 -04:00
joshpatra 806511d727 fix(search): preserve native source ordering 2026-04-05 17:14:49 -04:00
joshpatra 02967c8c67 chore: version bump
CI / build-and-test (push) Has been cancelled
2026-04-04 17:34:38 -04:00
joshpatra bf6fa4e647 Add support footer and login badge to admin UI 2026-04-04 16:19:30 -04:00
joshpatra 04e0c357aa fix(search: true interleaving 2026-04-04 16:18:03 -04:00
joshpatra ee98464475 fix(jellyfin): return cached search responses as raw json
CI / build-and-test (push) Has been cancelled
2026-04-03 15:17:29 -04:00
joshpatra 66f64d6de7 fix: preserve Jellyfin remote control sessions
Forward session control requests transparently and avoid synthetic websocket or capability state overriding proxied client sockets.
2026-04-03 14:02:54 -04:00
joshpatra 8d3fde8fb9 fix: stale playlist artwork
CI / build-and-test (push) Has been cancelled
2026-03-30 02:40:29 -04:00
joshpatra 51d3d784b5 fix: performance improvements 2
CI / build-and-test (push) Has been cancelled
2026-03-30 02:12:22 -04:00
joshpatra dbc7bd6ea1 fix: performance improvements 2026-03-30 02:01:58 -04:00
joshpatra b54d41f560 feat: performance improvement for uninjected playlists 2026-03-30 01:56:26 -04:00
joshpatra 877d2ffddf v1.4.4: re-releasing tag
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-25 16:30:51 -04:00
18 changed files with 1195 additions and 173 deletions
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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";
}
+88 -15
View File
@@ -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;
+1
View File
@@ -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

+1
View File
@@ -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

+13
View File
@@ -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
View File
@@ -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>
+11
View File
@@ -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
View File
@@ -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) {
+22 -5
View File
@@ -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
View File
@@ -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;