Compare commits

..

98 Commits

Author SHA1 Message Date
joshpatra cc519c7818 fix(streaming): race squid metadata and handle upstream errors
CI / build-and-test (push) Has been cancelled
2026-04-23 18:29:49 -04:00
joshpatra cae8eae509 fix(squidwtf): race manifests and degrade gracefully 2026-04-23 18:15:15 -04:00
joshpatra 3e28af4f4f Merge branch 'main' into dev 2026-04-23 17:44:29 -04:00
joshpatra a840a22cc2 fix(squidwtf): add geeked uptime feed 2026-04-23 17:10:21 -04:00
joshpatra 317369d120 fix(ui): keep dashboard open on issue draft
CI / build-and-test (push) Has been cancelled
2026-04-18 23:04:50 -04:00
joshpatra b23678e95a fix(auth): use temp session store in tests 2026-04-18 22:48:46 -04:00
joshpatra 00a6cbc20e feat(auth): persist admin web sessions 2026-04-18 22:42:04 -04:00
joshpatra 34d307fd4e fix(ui): normalize issue draft markdown 2026-04-18 22:30:31 -04:00
joshpatra ca9813f1ea feat(ui): improve issue report diagnostics 2026-04-18 22:24:37 -04:00
joshpatra dc4e5b907a feat(ui): add in-app GitHub issue drafting 2026-04-18 22:14:56 -04:00
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 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 4dbb3d72e7 v1.5.2-beta.1: version bump, refactor: fixed searched to properly run a FIFO search interleaving, Got SyncPlay and Sessions to transparently proxy a bit better, overhauled the WebUI to use the space a little better, cleaned up some broken features, stopped the major version rebuild from blocking the WebUI from opening, broken button fixes
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-04-07 17:34:25 -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 3c291d5fac v1.5.1-beta.1: version bump, refactor: fixed searched to properly run a FIFO search interleaving, Got SyncPlay and Sessions to transparently proxy a bit better, overhauled the WebUI to use the space a little better, cleaned up some broken features, stopped the major version rebuild from blocking the WebUI from opening, broken button fixes 2026-04-07 17:25:59 -04:00
joshpatra 2a430a1c38 v1.5.0-beta.1: version bump, refactor: fixed searched to properly run a FIFO search interleaving, Got SyncPlay and Sessions to transparently proxy a bit better, overhauled the WebUI to use the space a little better, cleaned up some broken features, stopped the major version rebuild from blocking the WebUI from opening, broken button fixes 2026-04-07 17:24:46 -04:00
joshpatra fd02ea9167 Merge branch 'beta' into dev 2026-04-07 17:13:45 -04:00
joshpatra 1a0f7c0282 fix(jellyfin): remove duplicate playlist image tag resolver 2026-04-07 17:13:26 -04:00
joshpatra 6b89fe548f v1.5.0-beta.1: refactor: fixed searched to properly run a FIFO search interleaving, Got SyncPlay and Sessions to transparently proxy a bit better, overhauled the WebUI to use the space a little better, cleaned up some broken features, stopped the major version rebuild from blocking the WebUI from opening, broken button fixes 2026-04-07 16:51:12 -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 233af5dc8f v1.4.6-beta.1: Hopefully handles #14 and #15, fixes search up to truly interleave, and more transparently proxies /sessions and /socket
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-04-04 17:36:47 -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 4c1e6979b3 v1.4.4-beta.1: 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:19 -04:00
joshpatra 0738e2d588 Merge branch 'main' into beta 2026-03-25 16:28:27 -04:00
joshpatra 5e8cb13d1a v1.4.3-beta.1: fixed .env restarting from Admin UI, re-release of prev ver 2026-03-25 16:05:59 -04:00
joshpatra efdeef927a Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-24 11:12:49 -04:00
joshpatra 30f68729fc v1.4.2-beta.1: added an env migratino service, fixed DOWNLOAD_PATH requiring Subsonic settings in the backend 2026-03-24 11:10:29 -04:00
joshpatra 53f7b5e8b3 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-23 13:13:01 -04:00
joshpatra da33ba9fbd Updated funding sources in funding.yml 2026-03-23 13:07:32 -04:00
joshpatra 6c95cfd2d6 Merge branch 'main' into beta 2026-03-23 11:20:34 -04:00
joshpatra 50157db484 v1.4.1-beta.1: MAJOR FIX - Moved from Redis to Valkey, added migration service to support, Utilizing Hi-Fi API 2.7 with ISRC search, preserve local item json objects, add a quality fallback, added "transcoding" support that just reduces the fetched quality, while still downloading at the quality set in the .env, introduced real-time download visualizer on web-ui (not complete), move some stuff from json to redis, better retry logic, configurable timeouts per provider 2026-03-23 11:18:39 -04:00
joshpatra 2d11d913e8 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-12 19:14:27 -04:00
joshpatra f9e5b7f323 v1.3.3-beta.1: MAJOR FIX - fix auto logging out behavior, harden Jellyfin Auth, block bot probes earlier, let Jellyfin handle playback sessions, add [E] tag to explicit external tracks 2026-03-12 19:13:29 -04:00
joshpatra db714fee2d v1.3.1-beta.1: MAJOR FIX - fix auto logging out behavior, harden Jellyfin Auth, block bot probes earlier, let Jellyfin handle playback sessions, add [E] tag to explicit external tracks 2026-03-12 15:33:36 -04:00
joshpatra efe1660d81 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-06 02:18:29 -05:00
joshpatra 639070556a v1.3.0-beta.1: Fixed double scrobbling, inferring stops much better, fixed playlist cron rebuilding, stale injected playlist artwork, and search cache TTL 2026-03-06 01:54:58 -05:00
joshpatra 00a5d152a5 v1.2.1-beta.1: Massive WebUI cleanup, Fixed/Stabilized scrobbling, Significant security hardening, added user login to WebUI, refactored searching/interleaving to work MUCH better, Tidal Powered recommendations for SquidWTF provider, General bug fixes and optimizations
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-26 11:16:51 -05:00
joshpatra 1ba6135115 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-21 00:25:40 -05:00
joshpatra ec994773dd Merge branch 'main' into beta 2026-02-20 20:02:55 -05:00
joshpatra 39c8f16b59 v1.1.3-beta.1: version bump, removed duplicate method; this is why we run tests... 2026-02-20 20:01:22 -05:00
joshpatra a6a423d5a1 v1.1.1-beta-1: fix: redid logic for sync schedule in playlist injection, made a constant for versioning, fixed external artist album and track fetching
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-20 18:57:10 -05:00
joshpatra 899451d405 v1.1.0-beta.1: fix: Scrobbling to LastFM and Listenbrainz, fixed transparent proxying, added playlists to search (shown as albums), shows all libraries and only require library id for injected playlists; refactor: rewrote all the MD's basically, split up JellyfinController in separate files, dozens of other smaller changes
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-20 01:22:26 -05:00
joshpatra 8d6dd7ccf1 v1.0.3-beta.1: Refactored all large files, Fixed the cron schedule bug, hardened security, added global mapping for much more stable matchings
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-16 14:59:21 -05:00
joshpatra ebdd8d4e2a v1.0.2-beta.1: WebUI refactored for better understanding, gitignore updated
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 23:17:08 -05:00
joshpatra e4599a419e v1.0.1-beta.1: fixed and rewrote caching, WebUI fixes, logging fixes 2026-02-11 16:54:30 -05:00
joshpatra 86290dff0d v1.0.0-beta.1: initial beta release
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 10:16:09 -05:00
joshpatra 0a9e528418 v1.3.0: Bump version to 1.3.0
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 00:01:06 -05:00
joshpatra f74728fc73 fix: use MBID lookup for MusicBrainz genre enrichment
Search API doesn't return genres even with inc=genres parameter.
Now doing search to get MBID, then lookup by MBID to get genres.
2026-02-10 23:52:14 -05:00
joshpatra 87467be61b feat: add LyricsPlus API with modular orchestrator architecture
Add multi-source lyrics support with clean, modular architecture for easier debugging and maintenance.

New Features:
- LyricsPlusService: Multi-source lyrics API (Apple Music, Spotify, Musixmatch)
- LyricsOrchestrator: Priority-based coordinator for all lyrics sources
- Modular service architecture with independent error handling
- Word-level and line-level timing support with LRC conversion

Architecture:
- Priority chain: Spotify → LyricsPlus → LRCLib
- Each service logs independently (→ Trying, ✓ Found,  Not found)
- Fallback continues even if one service fails
- Easy to add new sources or modify priority

Benefits:
- Easier debugging with clear service-level logs
- Better maintainability with separated concerns
- More reliable with graceful fallback handling
- Extensible for future lyrics sources
2026-02-10 23:02:17 -05:00
joshpatra 713ecd4ec8 v1.2.6: fix search result ordering to prioritize local tracks
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-10 13:36:06 -05:00
joshpatra 0ff1e3a428 v1.2.5: fix genre enrichment blocking cover art loading 2026-02-10 12:56:43 -05:00
joshpatra cef18b9482 v1.2.5: prioritize local tracks and optimize genre enrichment
Local tracks now appear first in search results with +10 score boost. Genre enrichment is non-blocking for faster cover art and playback.
2026-02-10 12:50:52 -05:00
joshpatra 1bfe30b216 v1.2.4: stop racing SquidWTF endpoints for better throughput
Use round-robin instead of racing to enable parallel processing of 12 tracks simultaneously (one per endpoint) instead of racing all endpoints for each track.
2026-02-10 12:14:38 -05:00
joshpatra c9c82a650d v1.2.3: fix Spotify playlist metadata fields
Complete Jellyfin item structure for external tracks with all requested fields including PlaylistItemId, DateCreated, ParentId, Tags, People, and SortName.
2026-02-10 11:56:12 -05:00
joshpatra d0a7dbcc96 v1.2.2: fix metadata loss in Spotify playlists
Spotify playlist tracks were missing genres, composers, and other metadata because the proxy only requested MediaSources field instead of passing through all client-requested fields.
2026-02-10 11:01:38 -05:00
joshpatra 9c9a827a91 v1.2.1: MusicBrainz genre enrichment + cleanup
## Features
- Implement automatic MusicBrainz genre enrichment for all external sources
  - Deezer: Enriches when genre missing
  - Qobuz: Enriches when genre missing
  - SquidWTF/Tidal: Always enriches (Tidal doesn't provide genres)
- Use ISRC codes for exact matching, fallback to title/artist search
- Cache results in Redis (30 days) + file cache for performance
- Respect MusicBrainz rate limits (1 req/sec)

## Cleanup
- Remove unused Spotify API ClientId and ClientSecret settings
- Simplify Spotify API configuration

## Fixes
- Make GenreEnrichmentService optional to fix test failures
- All 225 tests passing

This ensures all external tracks have genre metadata for better
organization and filtering in music clients.
2026-02-10 10:29:49 -05:00
joshpatra 96889738df v1.2.1: MusicBrainz genre enrichment + cleanup
## Features
- Implement automatic MusicBrainz genre enrichment for all external sources
  - Deezer: Enriches when genre missing
  - Qobuz: Enriches when genre missing
  - SquidWTF/Tidal: Always enriches (Tidal doesn't provide genres)
- Use ISRC codes for exact matching, fallback to title/artist search
- Cache results in Redis (30 days) + file cache for performance
- Respect MusicBrainz rate limits (1 req/sec)

## Cleanup
- Remove unused Spotify API ClientId and ClientSecret settings
- Simplify Spotify API configuration

This ensures all external tracks have genre metadata for better
organization and filtering in music clients.
2026-02-10 10:25:41 -05:00
joshpatra f3c791496e v1.2.0: Spotify playlist improvements and admin UI fixes
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
Enhanced Spotify playlist integration with GraphQL API, fixed track counts and folder filtering, improved session IP tracking with X-Forwarded-For support, and added per-playlist cron scheduling.
2026-02-09 18:17:15 -05:00
29 changed files with 2430 additions and 439 deletions
+29 -19
View File
@@ -7,42 +7,52 @@ assignees: SoPat712
--- ---
**Describe the bug** ## Describe the bug
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
**To Reproduce** ## To Reproduce
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '...'
3. Scroll down to '....' 3. Scroll down to '...'
4. See error 4. See error
**Expected behavior** ## Expected behavior
A clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.
**Screenshots** ## Additional context
If applicable, add screenshots to help explain your problem.
**Details (please complete the following information):** Add any other context, screenshots, or surrounding details here.
- Version [e.g. v1.1.3]
- Client [e.g. Feishin]
<details> ## Safe diagnostics from Allstarr
<summary>Please paste your docker-compose.yaml in between the tickmarks</summary> - Sensitive values stay redacted in this block.
- Allstarr Version: [e.g. v1.5.3]
- Backend Type: [e.g. Jellyfin]
- Music Service: [e.g. SquidWTF]
- Storage Mode: [e.g. Cache]
- Download Mode: [e.g. Track]
- Redis Enabled: [e.g. Yes]
- Spotify Import Enabled: [e.g. Yes]
- Scrobbling Enabled: [e.g. Disabled]
- Spotify Status: [e.g. Spotify Ready]
- Jellyfin URL: [Configured (redacted) or Not configured]
- Client: [e.g. Firefox 149 on macOS]
- Generated At (UTC): [e.g. 2026-04-19T02:18:52.483Z]
- Browser Time Zone: [e.g. America/New_York]
## docker-compose.yaml (optional)
```yaml ```yaml
``` ```
</details>
<details> ## .env (redacted, optional)
<summary>Please paste your .env file REDACTED OF ALL PASSWORDS in between the tickmarks:</summary>
```env ```env
``` ```
</details>
**Additional context**
Add any other context about the problem here.
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Allstarr Documentation
url: https://github.com/SoPat712/allstarr#readme
about: Check the setup and usage docs before filing a new issue.
+27 -6
View File
@@ -7,14 +7,35 @@ assignees: SoPat712
--- ---
**Is your feature request related to a problem? Please describe.** ## Problem to solve
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
A clear and concise description of the problem this feature should solve.
## Solution you'd like
**Describe the solution you'd like**
A clear and concise description of what you want to happen. A clear and concise description of what you want to happen.
**Describe alternatives you've considered** ## Alternatives considered
A clear and concise description of any alternative solutions or features you've considered.
A clear and concise description of any alternative solutions or workarounds you've considered.
## Additional context
**Additional context**
Add any other context or screenshots about the feature request here. Add any other context or screenshots about the feature request here.
## Safe diagnostics from Allstarr (optional)
- Sensitive values stay redacted in this block.
- Allstarr Version: [e.g. v1.5.3]
- Backend Type: [e.g. Jellyfin]
- Music Service: [e.g. SquidWTF]
- Storage Mode: [e.g. Cache]
- Download Mode: [e.g. Track]
- Redis Enabled: [e.g. Yes]
- Spotify Import Enabled: [e.g. Yes]
- Scrobbling Enabled: [e.g. Disabled]
- Spotify Status: [e.g. Spotify Ready]
- Jellyfin URL: [Configured (redacted) or Not configured]
- Client: [e.g. Firefox 149 on macOS]
- Generated At (UTC): [e.g. 2026-04-19T02:18:52.483Z]
- Browser Time Zone: [e.g. America/New_York]
@@ -0,0 +1,180 @@
using System.IO.Compression;
using allstarr.Controllers;
using allstarr.Models.Domain;
using allstarr.Services.Lyrics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace allstarr.Tests;
public class DownloadsControllerLyricsArchiveTests
{
[Fact]
public async Task DownloadFile_WithLyricsSidecar_ReturnsZipContainingAudioAndLrc()
{
var testRoot = CreateTestRoot();
var downloadsRoot = Path.Combine(testRoot, "downloads");
var artistDir = Path.Combine(downloadsRoot, "kept", "Artist");
var audioPath = Path.Combine(artistDir, "track.mp3");
Directory.CreateDirectory(artistDir);
await File.WriteAllTextAsync(audioPath, "audio-data");
try
{
var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: true));
var result = await controller.DownloadFile("Artist/track.mp3");
var fileResult = Assert.IsType<FileStreamResult>(result);
Assert.Equal("application/zip", fileResult.ContentType);
Assert.Equal("track.zip", fileResult.FileDownloadName);
var entries = ReadArchiveEntries(fileResult.FileStream);
Assert.Contains("track.mp3", entries);
Assert.Contains("track.lrc", entries);
}
finally
{
DeleteTestRoot(testRoot);
}
}
[Fact]
public async Task DownloadAllFiles_BackfillsLyricsSidecarsIntoArchive()
{
var testRoot = CreateTestRoot();
var downloadsRoot = Path.Combine(testRoot, "downloads");
var artistDir = Path.Combine(downloadsRoot, "kept", "Artist", "Album");
var audioPath = Path.Combine(artistDir, "01 - track.mp3");
Directory.CreateDirectory(artistDir);
await File.WriteAllTextAsync(audioPath, "audio-data");
try
{
var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: true));
var result = await controller.DownloadAllFiles();
var fileResult = Assert.IsType<FileStreamResult>(result);
Assert.Equal("application/zip", fileResult.ContentType);
var entries = ReadArchiveEntries(fileResult.FileStream);
Assert.Contains(Path.Combine("Artist", "Album", "01 - track.mp3").Replace('\\', '/'), entries);
Assert.Contains(Path.Combine("Artist", "Album", "01 - track.lrc").Replace('\\', '/'), entries);
}
finally
{
DeleteTestRoot(testRoot);
}
}
[Fact]
public void DeleteDownload_RemovesAdjacentLyricsSidecar()
{
var testRoot = CreateTestRoot();
var downloadsRoot = Path.Combine(testRoot, "downloads");
var artistDir = Path.Combine(downloadsRoot, "kept", "Artist");
var audioPath = Path.Combine(artistDir, "track.mp3");
var sidecarPath = Path.Combine(artistDir, "track.lrc");
Directory.CreateDirectory(artistDir);
File.WriteAllText(audioPath, "audio-data");
File.WriteAllText(sidecarPath, "[00:00.00]lyrics");
try
{
var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: false));
var result = controller.DeleteDownload("Artist/track.mp3");
Assert.IsType<OkObjectResult>(result);
Assert.False(File.Exists(audioPath));
Assert.False(File.Exists(sidecarPath));
}
finally
{
DeleteTestRoot(testRoot);
}
}
private static DownloadsController CreateController(string downloadsRoot, IKeptLyricsSidecarService? keptLyricsSidecarService = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Library:DownloadPath"] = downloadsRoot
})
.Build();
return new DownloadsController(
NullLogger<DownloadsController>.Instance,
config,
keptLyricsSidecarService)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
}
private static HashSet<string> ReadArchiveEntries(Stream archiveStream)
{
archiveStream.Position = 0;
using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true);
return zip.Entries
.Select(entry => entry.FullName.Replace('\\', '/'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static string CreateTestRoot()
{
var root = Path.Combine(Path.GetTempPath(), "allstarr-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return root;
}
private static void DeleteTestRoot(string root)
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
private sealed class FakeKeptLyricsSidecarService : IKeptLyricsSidecarService
{
private readonly bool _createSidecar;
public FakeKeptLyricsSidecarService(bool createSidecar)
{
_createSidecar = createSidecar;
}
public string GetSidecarPath(string audioFilePath)
{
return Path.ChangeExtension(audioFilePath, ".lrc");
}
public Task<string?> EnsureSidecarAsync(
string audioFilePath,
Song? song = null,
string? externalProvider = null,
string? externalId = null,
CancellationToken cancellationToken = default)
{
var sidecarPath = GetSidecarPath(audioFilePath);
if (_createSidecar)
{
File.WriteAllText(sidecarPath, "[00:00.00]lyrics");
return Task.FromResult<string?>(sidecarPath);
}
return Task.FromResult<string?>(null);
}
}
}
@@ -9,7 +9,7 @@ namespace allstarr.Tests;
public class DownloadsControllerPathSecurityTests public class DownloadsControllerPathSecurityTests
{ {
[Fact] [Fact]
public void DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected() public async Task DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected()
{ {
var testRoot = CreateTestRoot(); var testRoot = CreateTestRoot();
var downloadsRoot = Path.Combine(testRoot, "downloads"); var downloadsRoot = Path.Combine(testRoot, "downloads");
@@ -23,7 +23,7 @@ public class DownloadsControllerPathSecurityTests
try try
{ {
var controller = CreateController(downloadsRoot); var controller = CreateController(downloadsRoot);
var result = controller.DownloadFile("../kept-malicious/attack.mp3"); var result = await controller.DownloadFile("../kept-malicious/attack.mp3");
var badRequest = Assert.IsType<BadRequestObjectResult>(result); var badRequest = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode);
@@ -63,7 +63,7 @@ public class DownloadsControllerPathSecurityTests
} }
[Fact] [Fact]
public void DownloadFile_ValidPathInsideKeptFolder_AllowsDownload() public async Task DownloadFile_ValidPathInsideKeptFolder_AllowsDownload()
{ {
var testRoot = CreateTestRoot(); var testRoot = CreateTestRoot();
var downloadsRoot = Path.Combine(testRoot, "downloads"); var downloadsRoot = Path.Combine(testRoot, "downloads");
@@ -76,7 +76,7 @@ public class DownloadsControllerPathSecurityTests
try try
{ {
var controller = CreateController(downloadsRoot); var controller = CreateController(downloadsRoot);
var result = controller.DownloadFile("Artist/track.mp3"); var result = await controller.DownloadFile("Artist/track.mp3");
Assert.IsType<FileStreamResult>(result); Assert.IsType<FileStreamResult>(result);
} }
@@ -97,7 +97,13 @@ public class DownloadsControllerPathSecurityTests
return new DownloadsController( return new DownloadsController(
NullLogger<DownloadsController>.Instance, NullLogger<DownloadsController>.Instance,
config); config)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
} }
private static string CreateTestRoot() private static string CreateTestRoot()
+5 -1
View File
@@ -114,7 +114,8 @@ public class AdminAuthController : ControllerBase
userName: userName, userName: userName,
isAdministrator: isAdministrator, isAdministrator: isAdministrator,
jellyfinAccessToken: accessToken, jellyfinAccessToken: accessToken,
jellyfinServerId: serverId); jellyfinServerId: serverId,
isPersistent: request.RememberMe);
SetSessionCookie(session.SessionId, session.ExpiresAtUtc); SetSessionCookie(session.SessionId, session.ExpiresAtUtc);
@@ -130,6 +131,7 @@ public class AdminAuthController : ControllerBase
name = session.UserName, name = session.UserName,
isAdministrator = session.IsAdministrator isAdministrator = session.IsAdministrator
}, },
rememberMe = session.IsPersistent,
expiresAtUtc = session.ExpiresAtUtc expiresAtUtc = session.ExpiresAtUtc
}); });
} }
@@ -159,6 +161,7 @@ public class AdminAuthController : ControllerBase
name = session.UserName, name = session.UserName,
isAdministrator = session.IsAdministrator isAdministrator = session.IsAdministrator
}, },
rememberMe = session.IsPersistent,
expiresAtUtc = session.ExpiresAtUtc expiresAtUtc = session.ExpiresAtUtc
}); });
} }
@@ -196,6 +199,7 @@ public class AdminAuthController : ControllerBase
{ {
public string? Username { get; set; } public string? Username { get; set; }
public string? Password { get; set; } public string? Password { get; set; }
public bool RememberMe { get; set; }
} }
private sealed class JellyfinAuthenticateRequest private sealed class JellyfinAuthenticateRequest
+88 -15
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using allstarr.Filters; using allstarr.Filters;
using allstarr.Services.Admin; using allstarr.Services.Admin;
using allstarr.Services.Lyrics;
namespace allstarr.Controllers; namespace allstarr.Controllers;
@@ -9,15 +10,20 @@ namespace allstarr.Controllers;
[ServiceFilter(typeof(AdminPortFilter))] [ServiceFilter(typeof(AdminPortFilter))]
public class DownloadsController : ControllerBase public class DownloadsController : ControllerBase
{ {
private static readonly string[] AudioExtensions = [".flac", ".mp3", ".m4a", ".opus"];
private readonly ILogger<DownloadsController> _logger; private readonly ILogger<DownloadsController> _logger;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService;
public DownloadsController( public DownloadsController(
ILogger<DownloadsController> logger, ILogger<DownloadsController> logger,
IConfiguration configuration) IConfiguration configuration,
IKeptLyricsSidecarService? keptLyricsSidecarService = null)
{ {
_logger = logger; _logger = logger;
_configuration = configuration; _configuration = configuration;
_keptLyricsSidecarService = keptLyricsSidecarService;
} }
[HttpGet("downloads")] [HttpGet("downloads")]
@@ -36,10 +42,8 @@ public class DownloadsController : ControllerBase
long totalSize = 0; long totalSize = 0;
// Recursively get all audio files from kept folder // Recursively get all audio files from kept folder
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories) var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) .Where(IsSupportedAudioFile)
.ToList(); .ToList();
foreach (var filePath in allFiles) foreach (var filePath in allFiles)
@@ -112,6 +116,11 @@ public class DownloadsController : ControllerBase
} }
System.IO.File.Delete(fullPath); System.IO.File.Delete(fullPath);
var sidecarPath = _keptLyricsSidecarService?.GetSidecarPath(fullPath) ?? Path.ChangeExtension(fullPath, ".lrc");
if (System.IO.File.Exists(sidecarPath))
{
System.IO.File.Delete(sidecarPath);
}
// Clean up empty directories (Album folder, then Artist folder if empty) // Clean up empty directories (Album folder, then Artist folder if empty)
var directory = Path.GetDirectoryName(fullPath); var directory = Path.GetDirectoryName(fullPath);
@@ -154,9 +163,8 @@ public class DownloadsController : ControllerBase
return Ok(new { success = true, deletedCount = 0, message = "No kept downloads found" }); return Ok(new { success = true, deletedCount = 0, message = "No kept downloads found" });
} }
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories) var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) .Where(IsSupportedAudioFile)
.ToList(); .ToList();
foreach (var filePath in allFiles) foreach (var filePath in allFiles)
@@ -164,6 +172,12 @@ public class DownloadsController : ControllerBase
System.IO.File.Delete(filePath); System.IO.File.Delete(filePath);
} }
var sidecarFiles = Directory.GetFiles(keptPath, "*.lrc", SearchOption.AllDirectories);
foreach (var sidecarFile in sidecarFiles)
{
System.IO.File.Delete(sidecarFile);
}
// Clean up empty directories under kept root (deepest first) // Clean up empty directories under kept root (deepest first)
var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories) var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories)
.OrderByDescending(d => d.Length); .OrderByDescending(d => d.Length);
@@ -194,7 +208,7 @@ public class DownloadsController : ControllerBase
/// Downloads a specific file from the kept folder /// Downloads a specific file from the kept folder
/// </summary> /// </summary>
[HttpGet("downloads/file")] [HttpGet("downloads/file")]
public IActionResult DownloadFile([FromQuery] string path) public async Task<IActionResult> DownloadFile([FromQuery] string path)
{ {
try try
{ {
@@ -216,8 +230,16 @@ public class DownloadsController : ControllerBase
} }
var fileName = Path.GetFileName(fullPath); var fileName = Path.GetFileName(fullPath);
var fileStream = System.IO.File.OpenRead(fullPath); if (IsSupportedAudioFile(fullPath))
{
var sidecarPath = await EnsureLyricsSidecarIfPossibleAsync(fullPath, HttpContext.RequestAborted);
if (System.IO.File.Exists(sidecarPath))
{
return await CreateSingleTrackArchiveAsync(fullPath, sidecarPath, fileName);
}
}
var fileStream = System.IO.File.OpenRead(fullPath);
return File(fileStream, "application/octet-stream", fileName); return File(fileStream, "application/octet-stream", fileName);
} }
catch (Exception ex) catch (Exception ex)
@@ -232,7 +254,7 @@ public class DownloadsController : ControllerBase
/// Downloads all kept files as a zip archive /// Downloads all kept files as a zip archive
/// </summary> /// </summary>
[HttpGet("downloads/all")] [HttpGet("downloads/all")]
public IActionResult DownloadAllFiles() public async Task<IActionResult> DownloadAllFiles()
{ {
try try
{ {
@@ -243,9 +265,8 @@ public class DownloadsController : ControllerBase
return NotFound(new { error = "No kept files found" }); return NotFound(new { error = "No kept files found" });
} }
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories) var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) .Where(IsSupportedAudioFile)
.ToList(); .ToList();
if (allFiles.Count == 0) if (allFiles.Count == 0)
@@ -259,14 +280,18 @@ public class DownloadsController : ControllerBase
var memoryStream = new MemoryStream(); var memoryStream = new MemoryStream();
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true)) using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
{ {
var addedEntries = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var filePath in allFiles) foreach (var filePath in allFiles)
{ {
var relativePath = Path.GetRelativePath(keptPath, filePath); var relativePath = Path.GetRelativePath(keptPath, filePath);
var entry = archive.CreateEntry(relativePath, System.IO.Compression.CompressionLevel.NoCompression); await AddFileToArchiveAsync(archive, filePath, relativePath, addedEntries);
using var entryStream = entry.Open(); var sidecarPath = await EnsureLyricsSidecarIfPossibleAsync(filePath, HttpContext.RequestAborted);
using var fileStream = System.IO.File.OpenRead(filePath); if (System.IO.File.Exists(sidecarPath))
fileStream.CopyTo(entryStream); {
var sidecarRelativePath = Path.GetRelativePath(keptPath, sidecarPath);
await AddFileToArchiveAsync(archive, sidecarPath, sidecarRelativePath, addedEntries);
}
} }
} }
@@ -330,6 +355,54 @@ public class DownloadsController : ControllerBase
: StringComparison.Ordinal; : StringComparison.Ordinal;
} }
private async Task<string> EnsureLyricsSidecarIfPossibleAsync(string audioFilePath, CancellationToken cancellationToken)
{
var sidecarPath = _keptLyricsSidecarService?.GetSidecarPath(audioFilePath) ?? Path.ChangeExtension(audioFilePath, ".lrc");
if (System.IO.File.Exists(sidecarPath) || _keptLyricsSidecarService == null)
{
return sidecarPath;
}
var generatedSidecar = await _keptLyricsSidecarService.EnsureSidecarAsync(audioFilePath, cancellationToken: cancellationToken);
return generatedSidecar ?? sidecarPath;
}
private async Task<IActionResult> CreateSingleTrackArchiveAsync(string audioFilePath, string sidecarPath, string fileName)
{
var archiveStream = new MemoryStream();
using (var archive = new System.IO.Compression.ZipArchive(archiveStream, System.IO.Compression.ZipArchiveMode.Create, true))
{
await AddFileToArchiveAsync(archive, audioFilePath, Path.GetFileName(audioFilePath), null);
await AddFileToArchiveAsync(archive, sidecarPath, Path.GetFileName(sidecarPath), null);
}
archiveStream.Position = 0;
var downloadName = $"{Path.GetFileNameWithoutExtension(fileName)}.zip";
return File(archiveStream, "application/zip", downloadName);
}
private static async Task AddFileToArchiveAsync(
System.IO.Compression.ZipArchive archive,
string filePath,
string entryPath,
HashSet<string>? addedEntries)
{
if (addedEntries != null && !addedEntries.Add(entryPath))
{
return;
}
var entry = archive.CreateEntry(entryPath, System.IO.Compression.CompressionLevel.NoCompression);
await using var entryStream = entry.Open();
await using var fileStream = System.IO.File.OpenRead(filePath);
await fileStream.CopyToAsync(entryStream);
}
private static bool IsSupportedAudioFile(string path)
{
return AudioExtensions.Contains(Path.GetExtension(path).ToLowerInvariant());
}
/// <summary> /// <summary>
/// Gets all Spotify track mappings (paginated) /// Gets all Spotify track mappings (paginated)
/// </summary> /// </summary>
@@ -1,5 +1,6 @@
using allstarr.Services.Common; using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Net;
namespace allstarr.Controllers; namespace allstarr.Controllers;
@@ -193,23 +194,75 @@ public partial class JellyfinController
} }
catch (Exception ex) catch (Exception ex)
{ {
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue) return HandleExternalStreamFailure(provider, externalId, ex);
{
_logger.LogError("Failed to stream external song {Provider}:{ExternalId}: {StatusCode}: {ReasonPhrase}",
provider,
externalId,
(int)httpRequestException.StatusCode.Value,
httpRequestException.StatusCode.Value);
_logger.LogDebug(ex, "Detailed streaming failure for external song {Provider}:{ExternalId}", provider, externalId);
}
else
{
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
}
return StatusCode(500, new { error = "Streaming failed" });
} }
} }
private IActionResult HandleExternalStreamFailure(string provider, string externalId, Exception ex)
{
if (HttpContext.RequestAborted.IsCancellationRequested && ex is OperationCanceledException)
{
_logger.LogInformation("Client aborted external stream request for {Provider}:{ExternalId}", provider, externalId);
return StatusCode(499);
}
var (statusCode, errorMessage) = MapExternalStreamException(ex);
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
{
_logger.LogError("Failed to stream external song {Provider}:{ExternalId}: responding {StatusCode}; upstream returned {UpstreamStatus}: {ReasonPhrase}",
provider,
externalId,
statusCode,
(int)httpRequestException.StatusCode.Value,
httpRequestException.StatusCode.Value);
_logger.LogDebug(ex, "Detailed streaming failure for external song {Provider}:{ExternalId}", provider, externalId);
}
else
{
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}: responding {StatusCode}",
provider, externalId, statusCode);
}
return StatusCode(statusCode, new { error = errorMessage });
}
private static (int statusCode, string errorMessage) MapExternalStreamException(Exception ex)
{
if (ex is TimeoutException || ex is TaskCanceledException)
{
return (StatusCodes.Status504GatewayTimeout, "External provider timed out");
}
if (ex is HttpRequestException httpRequestException)
{
return httpRequestException.StatusCode switch
{
HttpStatusCode.NotFound => (StatusCodes.Status404NotFound, "External track not found"),
HttpStatusCode.TooManyRequests => (StatusCodes.Status503ServiceUnavailable, "External provider is rate limiting requests"),
HttpStatusCode.BadGateway or
HttpStatusCode.ServiceUnavailable or
HttpStatusCode.GatewayTimeout or
HttpStatusCode.InternalServerError => (StatusCodes.Status503ServiceUnavailable, "External provider is unavailable"),
_ => (StatusCodes.Status502BadGateway, "External provider request failed")
};
}
if (ex is InvalidOperationException invalidOperationException &&
invalidOperationException.Message.Contains("endpoints", StringComparison.OrdinalIgnoreCase))
{
return (StatusCodes.Status503ServiceUnavailable, "External provider has no healthy endpoints");
}
if (ex.Message.Contains("endpoints failed", StringComparison.OrdinalIgnoreCase) ||
ex.Message.Contains("No SquidWTF endpoints", StringComparison.OrdinalIgnoreCase))
{
return (StatusCodes.Status503ServiceUnavailable, "External provider has no healthy endpoints");
}
return (StatusCodes.Status502BadGateway, "External stream failed");
}
/// <summary> /// <summary>
/// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming. /// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming.
/// This is the primary endpoint used by Jellyfin Web and most clients. /// This is the primary endpoint used by Jellyfin Web and most clients.
@@ -9,6 +9,8 @@ namespace allstarr.Controllers;
public partial class JellyfinController public partial class JellyfinController
{ {
private static readonly string[] KeptAudioExtensions = [".flac", ".mp3", ".m4a", ".opus"];
#region Spotify Playlist Injection #region Spotify Playlist Injection
/// <summary> /// <summary>
@@ -480,10 +482,13 @@ public partial class JellyfinController
if (Directory.Exists(keptAlbumPath)) if (Directory.Exists(keptAlbumPath))
{ {
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title); var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*"); var existingAudioFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*")
if (existingFiles.Length > 0) .Where(IsKeptAudioFile)
.ToArray();
if (existingAudioFiles.Length > 0)
{ {
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]); _logger.LogInformation("Track already exists in kept folder: {Path}", existingAudioFiles[0]);
await EnsureLyricsSidecarForKeptTrackAsync(existingAudioFiles[0], song, provider, externalId);
// Mark as favorited even if we didn't download it // Mark as favorited even if we didn't download it
await MarkTrackAsFavoritedAsync(itemId, song); await MarkTrackAsFavoritedAsync(itemId, song);
return; return;
@@ -572,6 +577,7 @@ public partial class JellyfinController
{ {
// Race condition - file was created by another request // Race condition - file was created by another request
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath); _logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
await MarkTrackAsFavoritedAsync(itemId, song); await MarkTrackAsFavoritedAsync(itemId, song);
return; return;
} }
@@ -589,6 +595,7 @@ public partial class JellyfinController
{ {
// Race condition on copy fallback // Race condition on copy fallback
_logger.LogInformation("Track already exists in kept folder (race condition on copy): {Path}", keptFilePath); _logger.LogInformation("Track already exists in kept folder (race condition on copy): {Path}", keptFilePath);
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
await MarkTrackAsFavoritedAsync(itemId, song); await MarkTrackAsFavoritedAsync(itemId, song);
return; return;
} }
@@ -650,6 +657,8 @@ public partial class JellyfinController
} }
} }
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
// Mark as favorited in persistent storage // Mark as favorited in persistent storage
await MarkTrackAsFavoritedAsync(itemId, song); await MarkTrackAsFavoritedAsync(itemId, song);
} }
@@ -903,6 +912,33 @@ public partial class JellyfinController
} }
} }
private async Task EnsureLyricsSidecarForKeptTrackAsync(string keptFilePath, Song song, string provider, string externalId)
{
if (_keptLyricsSidecarService == null)
{
return;
}
try
{
await _keptLyricsSidecarService.EnsureSidecarAsync(
keptFilePath,
song,
provider,
externalId,
CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to create kept lyrics sidecar for {Path}", keptFilePath);
}
}
private static bool IsKeptAudioFile(string path)
{
return KeptAudioExtensions.Contains(Path.GetExtension(path).ToLowerInvariant());
}
#endregion #endregion
/// <summary> /// <summary>
+9 -1
View File
@@ -47,6 +47,7 @@ public partial class JellyfinController : ControllerBase
private readonly LyricsPlusService? _lyricsPlusService; private readonly LyricsPlusService? _lyricsPlusService;
private readonly LrclibService? _lrclibService; private readonly LrclibService? _lrclibService;
private readonly LyricsOrchestrator? _lyricsOrchestrator; private readonly LyricsOrchestrator? _lyricsOrchestrator;
private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService;
private readonly ScrobblingOrchestrator? _scrobblingOrchestrator; private readonly ScrobblingOrchestrator? _scrobblingOrchestrator;
private readonly ScrobblingHelper? _scrobblingHelper; private readonly ScrobblingHelper? _scrobblingHelper;
private readonly OdesliService _odesliService; private readonly OdesliService _odesliService;
@@ -77,6 +78,7 @@ public partial class JellyfinController : ControllerBase
LyricsPlusService? lyricsPlusService = null, LyricsPlusService? lyricsPlusService = null,
LrclibService? lrclibService = null, LrclibService? lrclibService = null,
LyricsOrchestrator? lyricsOrchestrator = null, LyricsOrchestrator? lyricsOrchestrator = null,
IKeptLyricsSidecarService? keptLyricsSidecarService = null,
ScrobblingOrchestrator? scrobblingOrchestrator = null, ScrobblingOrchestrator? scrobblingOrchestrator = null,
ScrobblingHelper? scrobblingHelper = null) ScrobblingHelper? scrobblingHelper = null)
{ {
@@ -98,6 +100,7 @@ public partial class JellyfinController : ControllerBase
_lyricsPlusService = lyricsPlusService; _lyricsPlusService = lyricsPlusService;
_lrclibService = lrclibService; _lrclibService = lrclibService;
_lyricsOrchestrator = lyricsOrchestrator; _lyricsOrchestrator = lyricsOrchestrator;
_keptLyricsSidecarService = keptLyricsSidecarService;
_scrobblingOrchestrator = scrobblingOrchestrator; _scrobblingOrchestrator = scrobblingOrchestrator;
_scrobblingHelper = scrobblingHelper; _scrobblingHelper = scrobblingHelper;
_odesliService = odesliService; _odesliService = odesliService;
@@ -817,9 +820,14 @@ public partial class JellyfinController : ControllerBase
{ {
try try
{ {
var (itemResult, statusCode) = await _proxyService.GetJsonAsyncInternal($"Items/{itemId}"); var (itemResult, statusCode) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers);
if (itemResult == null || statusCode != 200) if (itemResult == null || statusCode != 200)
{ {
_logger.LogDebug(
"Skipping Jellyfin {ImageType} image tag resolution for Spotify playlist {PlaylistId}: upstream returned {StatusCode}",
imageType,
itemId,
statusCode);
return null; return null;
} }
+28 -1
View File
@@ -173,8 +173,35 @@ public class SubsonicController : ControllerBase
} }
catch (Exception ex) catch (Exception ex)
{ {
if (HttpContext.RequestAborted.IsCancellationRequested && ex is OperationCanceledException)
{
_logger.LogInformation("Client aborted external Subsonic stream request for {Id}", id);
return StatusCode(499);
}
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
{
var statusCode = httpRequestException.StatusCode == System.Net.HttpStatusCode.NotFound ? 404 : 503;
_logger.LogError(ex, "Failed to stream external Subsonic item {Id}: responding {StatusCode}; upstream returned {UpstreamStatus}",
id, statusCode, (int)httpRequestException.StatusCode.Value);
return StatusCode(statusCode, new { error = statusCode == 404 ? "External track not found" : "External provider unavailable" });
}
if (ex is TimeoutException || ex is TaskCanceledException)
{
_logger.LogError(ex, "Timed out streaming external Subsonic item {Id}", id);
return StatusCode(504, new { error = "External provider timed out" });
}
if (ex is InvalidOperationException invalidOperationException &&
invalidOperationException.Message.Contains("endpoints", StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(ex, "No healthy endpoints available for external Subsonic item {Id}", id);
return StatusCode(503, new { error = "External provider has no healthy endpoints" });
}
_logger.LogError(ex, "Failed to stream external Subsonic item {Id}", id); _logger.LogError(ex, "Failed to stream external Subsonic item {Id}", id);
return StatusCode(500, new { error = "Failed to stream" }); return StatusCode(502, new { error = "External stream failed" });
} }
} }
+11 -2
View File
@@ -12,8 +12,10 @@ using allstarr.Services.Lyrics;
using allstarr.Services.Scrobbling; using allstarr.Services.Scrobbling;
using allstarr.Middleware; using allstarr.Middleware;
using allstarr.Filters; using allstarr.Filters;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Http; using Microsoft.Extensions.Http;
using System.Net; using System.Net;
using System.IO;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out); RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
@@ -161,6 +163,7 @@ builder.Services.AddControllers()
}); });
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddHttpClient("SquidWTF");
builder.Services.ConfigureAll<HttpClientFactoryOptions>(options => builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
{ {
options.HttpMessageHandlerBuilderActions.Add(builder => options.HttpMessageHandlerBuilderActions.Add(builder =>
@@ -198,6 +201,11 @@ builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName)
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
var dataProtectionKeysDirectory = new DirectoryInfo("/app/cache/data-protection");
dataProtectionKeysDirectory.Create();
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(dataProtectionKeysDirectory)
.SetApplicationName("allstarr-admin");
// Exception handling // Exception handling
builder.Services.AddExceptionHandler<GlobalExceptionHandler>(); builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
@@ -636,7 +644,7 @@ builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
builder.Services.AddSingleton<IStartupValidator>(sp => builder.Services.AddSingleton<IStartupValidator>(sp =>
new SquidWTFStartupValidator( new SquidWTFStartupValidator(
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(), sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient(), sp.GetRequiredService<IHttpClientFactory>().CreateClient("SquidWTF"),
squidWtfApiUrls, squidWtfApiUrls,
squidWtfStreamingUrls, squidWtfStreamingUrls,
sp.GetRequiredService<EndpointBenchmarkService>(), sp.GetRequiredService<EndpointBenchmarkService>(),
@@ -712,6 +720,7 @@ builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
// Register Lyrics Orchestrator (manages priority-based lyrics fetching) // Register Lyrics Orchestrator (manages priority-based lyrics fetching)
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>(); builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
builder.Services.AddSingleton<allstarr.Services.Lyrics.IKeptLyricsSidecarService, allstarr.Services.Lyrics.KeptLyricsSidecarService>();
// Register Spotify mapping service (global Spotify ID → Local/External mappings) // Register Spotify mapping service (global Spotify ID → Local/External mappings)
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyMappingService>(); builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyMappingService>();
@@ -945,7 +954,7 @@ app.UseMiddleware<BotProbeBlockMiddleware>();
// Request logging middleware (when DEBUG_LOG_ALL_REQUESTS=true) // Request logging middleware (when DEBUG_LOG_ALL_REQUESTS=true)
app.UseMiddleware<RequestLoggingMiddleware>(); app.UseMiddleware<RequestLoggingMiddleware>();
app.UseExceptionHandler(_ => { }); // Global exception handler app.UseExceptionHandler(); // Use registered GlobalExceptionHandler
// Enable response compression EARLY in the pipeline // Enable response compression EARLY in the pipeline
app.UseResponseCompression(); app.UseResponseCompression();
@@ -1,5 +1,8 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Logging.Abstractions;
namespace allstarr.Services.Admin; namespace allstarr.Services.Admin;
@@ -11,27 +14,83 @@ public sealed class AdminAuthSession
public required bool IsAdministrator { get; init; } public required bool IsAdministrator { get; init; }
public required string JellyfinAccessToken { get; init; } public required string JellyfinAccessToken { get; init; }
public string? JellyfinServerId { get; init; } public string? JellyfinServerId { get; init; }
public required DateTime ExpiresAtUtc { get; init; } public bool IsPersistent { get; init; }
public required DateTime ExpiresAtUtc { get; set; }
public DateTime LastSeenUtc { get; set; } public DateTime LastSeenUtc { get; set; }
} }
/// <summary> /// <summary>
/// In-memory authenticated admin sessions for the local Web UI. /// Cookie-backed admin sessions for the local Web UI.
/// Session IDs stay in the browser cookie, while the authenticated Jellyfin
/// session details are protected and persisted on disk so brief app restarts
/// do not force a relogin.
/// </summary> /// </summary>
public class AdminAuthSessionService public class AdminAuthSessionService
{ {
public const string SessionCookieName = "allstarr_admin_session"; public const string SessionCookieName = "allstarr_admin_session";
public const string HttpContextSessionItemKey = "__allstarr_admin_auth_session"; public const string HttpContextSessionItemKey = "__allstarr_admin_auth_session";
private static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(12); public static readonly TimeSpan DefaultSessionLifetime = TimeSpan.FromHours(12);
public static readonly TimeSpan PersistentSessionLifetime = TimeSpan.FromDays(30);
private readonly ConcurrentDictionary<string, AdminAuthSession> _sessions = new(); private readonly ConcurrentDictionary<string, AdminAuthSession> _sessions = new();
private readonly IDataProtector _protector;
private readonly ILogger<AdminAuthSessionService> _logger;
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private readonly object _persistLock = new();
private readonly string _sessionStoreFilePath;
public AdminAuthSessionService(
IDataProtectionProvider dataProtectionProvider,
ILogger<AdminAuthSessionService> logger)
: this(
dataProtectionProvider,
logger,
"/app/cache/admin-auth/sessions.protected")
{
}
private AdminAuthSessionService(
IDataProtectionProvider dataProtectionProvider,
ILogger<AdminAuthSessionService> logger,
string sessionStoreFilePath)
{
_protector = dataProtectionProvider.CreateProtector("allstarr.admin.auth.sessions.v1");
_logger = logger;
_sessionStoreFilePath = sessionStoreFilePath;
var directory = Path.GetDirectoryName(_sessionStoreFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
LoadSessionsFromDisk();
}
public AdminAuthSessionService(ILogger<AdminAuthSessionService> logger)
: this(
CreateFallbackDataProtectionProvider(),
logger,
Path.Combine(Path.GetTempPath(), "allstarr-admin-auth", "sessions.protected"))
{
}
public AdminAuthSessionService()
: this(
CreateFallbackDataProtectionProvider(),
NullLogger<AdminAuthSessionService>.Instance,
Path.Combine(Path.GetTempPath(), "allstarr-admin-auth", "sessions.protected"))
{
}
public AdminAuthSession CreateSession( public AdminAuthSession CreateSession(
string userId, string userId,
string userName, string userName,
bool isAdministrator, bool isAdministrator,
string jellyfinAccessToken, string jellyfinAccessToken,
string? jellyfinServerId) string? jellyfinServerId,
bool isPersistent = false)
{ {
RemoveExpiredSessions(); RemoveExpiredSessions();
@@ -44,11 +103,13 @@ public class AdminAuthSessionService
IsAdministrator = isAdministrator, IsAdministrator = isAdministrator,
JellyfinAccessToken = jellyfinAccessToken, JellyfinAccessToken = jellyfinAccessToken,
JellyfinServerId = jellyfinServerId, JellyfinServerId = jellyfinServerId,
ExpiresAtUtc = now.Add(SessionLifetime), IsPersistent = isPersistent,
ExpiresAtUtc = now.Add(isPersistent ? PersistentSessionLifetime : DefaultSessionLifetime),
LastSeenUtc = now LastSeenUtc = now
}; };
_sessions[session.SessionId] = session; _sessions[session.SessionId] = session;
PersistSessions();
return session; return session;
} }
@@ -69,6 +130,7 @@ public class AdminAuthSessionService
if (existing.ExpiresAtUtc <= DateTime.UtcNow) if (existing.ExpiresAtUtc <= DateTime.UtcNow)
{ {
_sessions.TryRemove(sessionId, out _); _sessions.TryRemove(sessionId, out _);
PersistSessions();
return false; return false;
} }
@@ -84,17 +146,117 @@ public class AdminAuthSessionService
return; return;
} }
_sessions.TryRemove(sessionId, out _); if (_sessions.TryRemove(sessionId, out _))
{
PersistSessions();
}
} }
private void RemoveExpiredSessions() private void RemoveExpiredSessions()
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var removedAny = false;
foreach (var kvp in _sessions) foreach (var kvp in _sessions)
{ {
if (kvp.Value.ExpiresAtUtc <= now) if (kvp.Value.ExpiresAtUtc <= now &&
_sessions.TryRemove(kvp.Key, out _))
{ {
_sessions.TryRemove(kvp.Key, out _); removedAny = true;
}
}
if (removedAny)
{
PersistSessions();
}
}
private void LoadSessionsFromDisk()
{
try
{
if (!File.Exists(_sessionStoreFilePath))
{
return;
}
var protectedPayload = File.ReadAllText(_sessionStoreFilePath);
if (string.IsNullOrWhiteSpace(protectedPayload))
{
return;
}
var json = _protector.Unprotect(protectedPayload);
var sessions = JsonSerializer.Deserialize<List<PersistedAdminAuthSession>>(json, _jsonOptions)
?? [];
var now = DateTime.UtcNow;
foreach (var persisted in sessions)
{
if (string.IsNullOrWhiteSpace(persisted.SessionId) ||
string.IsNullOrWhiteSpace(persisted.UserId) ||
string.IsNullOrWhiteSpace(persisted.UserName) ||
string.IsNullOrWhiteSpace(persisted.JellyfinAccessToken) ||
persisted.ExpiresAtUtc <= now)
{
continue;
}
_sessions[persisted.SessionId] = new AdminAuthSession
{
SessionId = persisted.SessionId,
UserId = persisted.UserId,
UserName = persisted.UserName,
IsAdministrator = persisted.IsAdministrator,
JellyfinAccessToken = persisted.JellyfinAccessToken,
JellyfinServerId = persisted.JellyfinServerId,
IsPersistent = persisted.IsPersistent,
ExpiresAtUtc = persisted.ExpiresAtUtc,
LastSeenUtc = persisted.LastSeenUtc
};
}
if (_sessions.Count > 0)
{
_logger.LogInformation("Loaded {Count} persisted admin auth sessions", _sessions.Count);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load persisted admin auth sessions; starting with an empty session store");
_sessions.Clear();
}
}
private void PersistSessions()
{
lock (_persistLock)
{
try
{
var activeSessions = _sessions.Values
.Where(session => session.ExpiresAtUtc > DateTime.UtcNow)
.Select(session => new PersistedAdminAuthSession
{
SessionId = session.SessionId,
UserId = session.UserId,
UserName = session.UserName,
IsAdministrator = session.IsAdministrator,
JellyfinAccessToken = session.JellyfinAccessToken,
JellyfinServerId = session.JellyfinServerId,
IsPersistent = session.IsPersistent,
ExpiresAtUtc = session.ExpiresAtUtc,
LastSeenUtc = session.LastSeenUtc
})
.ToList();
var json = JsonSerializer.Serialize(activeSessions, _jsonOptions);
var protectedPayload = _protector.Protect(json);
File.WriteAllText(_sessionStoreFilePath, protectedPayload);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to persist admin auth sessions");
} }
} }
} }
@@ -105,4 +267,27 @@ public class AdminAuthSessionService
RandomNumberGenerator.Fill(bytes); RandomNumberGenerator.Fill(bytes);
return Convert.ToHexString(bytes).ToLowerInvariant(); return Convert.ToHexString(bytes).ToLowerInvariant();
} }
private static IDataProtectionProvider CreateFallbackDataProtectionProvider()
{
var keysDirectory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "allstarr-admin-auth-keys"));
keysDirectory.Create();
return DataProtectionProvider.Create(keysDirectory, configuration =>
{
configuration.SetApplicationName("allstarr-admin");
});
}
private sealed class PersistedAdminAuthSession
{
public required string SessionId { get; init; }
public required string UserId { get; init; }
public required string UserName { get; init; }
public required bool IsAdministrator { get; init; }
public required string JellyfinAccessToken { get; init; }
public string? JellyfinServerId { get; init; }
public required bool IsPersistent { get; init; }
public required DateTime ExpiresAtUtc { get; init; }
public required DateTime LastSeenUtc { get; init; }
}
} }
@@ -27,16 +27,16 @@ public class RoundRobinFallbackHelper
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
_serviceName = serviceName ?? "Service"; _serviceName = serviceName ?? "Service";
if (_apiUrls.Count == 0)
{
throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls));
}
// Create a dedicated HttpClient for health checks with short timeout // Create a dedicated HttpClient for health checks with short timeout
_healthCheckClient = new HttpClient _healthCheckClient = new HttpClient
{ {
Timeout = TimeSpan.FromSeconds(3) // Quick health check timeout Timeout = TimeSpan.FromSeconds(3) // Quick health check timeout
}; };
if (_apiUrls.Count == 0)
{
_logger.LogWarning("{Service} initialized with zero endpoints; external provider is currently unavailable", _serviceName);
}
} }
/// <summary> /// <summary>
@@ -124,6 +124,11 @@ public class RoundRobinFallbackHelper
/// </summary> /// </summary>
private async Task<List<string>> GetHealthyEndpointsAsync() private async Task<List<string>> GetHealthyEndpointsAsync()
{ {
if (_apiUrls.Count == 0)
{
return new List<string>();
}
var healthCheckTasks = _apiUrls.Select(async url => new var healthCheckTasks = _apiUrls.Select(async url => new
{ {
Url = url, Url = url,
@@ -212,6 +217,11 @@ public class RoundRobinFallbackHelper
/// </summary> /// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action) public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
{ {
if (_apiUrls.Count == 0)
{
throw new InvalidOperationException($"No {_serviceName} endpoints are configured");
}
// Get healthy endpoints first (with caching to avoid excessive checks) // Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync(); var healthyEndpoints = await GetHealthyEndpointsAsync();
@@ -254,72 +264,78 @@ public class RoundRobinFallbackHelper
} }
/// <summary> /// <summary>
/// Races all endpoints in parallel and returns the first successful result. /// Races the top N fastest endpoints in parallel and returns the first successful result.
/// Cancels remaining requests once one succeeds. Great for latency-sensitive operations. /// Cancels remaining requests once one succeeds. Used for latency-sensitive operations like search.
/// </summary> /// </summary>
/// <summary> public async Task<T> RaceTopEndpointsAsync<T>(int topN, Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
/// Races the top N fastest endpoints in parallel and returns the first successful result. {
/// Cancels remaining requests once one succeeds. Used for latency-sensitive operations like search. if (_apiUrls.Count == 0)
/// </summary>
public async Task<T> RaceTopEndpointsAsync<T>(int topN, Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
{ {
if (_apiUrls.Count == 1 || topN <= 1) throw new InvalidOperationException($"No {_serviceName} endpoints are configured");
{
// No point racing with one endpoint - use fallback instead
return await TryWithFallbackAsync(baseUrl => action(baseUrl, cancellationToken));
}
// Get top N fastest healthy endpoints
var endpointsToRace = _apiUrls.Take(Math.Min(topN, _apiUrls.Count)).ToList();
if (endpointsToRace.Count == 1)
{
return await action(endpointsToRace[0], cancellationToken);
}
using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var tasks = new List<Task<(T result, string endpoint, bool success)>>();
// Start racing the top N endpoints
foreach (var baseUrl in endpointsToRace)
{
var task = Task.Run(async () =>
{
try
{
_logger.LogDebug("🏁 Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl);
var result = await action(baseUrl, raceCts.Token);
return (result, baseUrl, true);
}
catch (Exception ex)
{
_logger.LogDebug("{Service} race failed for endpoint {Endpoint}: {Message}", _serviceName, baseUrl, ex.Message);
return (default(T)!, baseUrl, false);
}
}, raceCts.Token);
tasks.Add(task);
}
// Wait for first successful completion
while (tasks.Count > 0)
{
var completedTask = await Task.WhenAny(tasks);
var (result, endpoint, success) = await completedTask;
if (success)
{
_logger.LogDebug("🏆 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
raceCts.Cancel(); // Cancel all other requests
return result;
}
tasks.Remove(completedTask);
}
throw new Exception($"All {topN} {_serviceName} endpoints failed in race");
} }
if (_apiUrls.Count == 1 || topN <= 1)
{
// No point racing with one endpoint - use fallback instead
return await TryWithFallbackAsync(baseUrl => action(baseUrl, cancellationToken));
}
// Get top N fastest healthy endpoints
var endpointsToRace = _apiUrls.Take(Math.Min(topN, _apiUrls.Count)).ToList();
if (endpointsToRace.Count == 1)
{
return await action(endpointsToRace[0], cancellationToken);
}
_logger.LogInformation("Racing {Count} {Service} endpoints: {Endpoints}",
endpointsToRace.Count, _serviceName, string.Join(", ", endpointsToRace));
using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var tasks = new List<Task<(T result, string endpoint, bool success)>>();
// Start racing the top N endpoints
foreach (var baseUrl in endpointsToRace)
{
var task = Task.Run(async () =>
{
try
{
_logger.LogDebug("🏁 Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl);
var result = await action(baseUrl, raceCts.Token);
return (result, baseUrl, true);
}
catch (Exception ex)
{
_logger.LogDebug("{Service} race failed for endpoint {Endpoint}: {Message}", _serviceName, baseUrl, ex.Message);
return (default(T)!, baseUrl, false);
}
}, raceCts.Token);
tasks.Add(task);
}
// Wait for first successful completion
while (tasks.Count > 0)
{
var completedTask = await Task.WhenAny(tasks);
var (result, endpoint, success) = await completedTask;
if (success)
{
_logger.LogInformation("{Service} race won by {Endpoint}", _serviceName, endpoint);
raceCts.Cancel(); // Cancel all other requests
return result;
}
tasks.Remove(completedTask);
}
_logger.LogError("All raced {Service} endpoints failed: {Endpoints}",
_serviceName, string.Join(", ", endpointsToRace));
throw new Exception($"All {topN} {_serviceName} endpoints failed in race");
}
/// <summary> /// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure. /// Tries the request with the next provider in round-robin, then falls back to others on failure.
/// Performs quick health checks first to avoid wasting time on dead endpoints. /// Performs quick health checks first to avoid wasting time on dead endpoints.
@@ -327,6 +343,12 @@ public class RoundRobinFallbackHelper
/// </summary> /// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue) public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
{ {
if (_apiUrls.Count == 0)
{
_logger.LogWarning("No {Service} endpoints are configured, returning default value", _serviceName);
return defaultValue;
}
// Get healthy endpoints first (with caching to avoid excessive checks) // Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync(); var healthyEndpoints = await GetHealthyEndpointsAsync();
@@ -383,6 +405,12 @@ public class RoundRobinFallbackHelper
throw new ArgumentNullException(nameof(isAcceptableResult)); throw new ArgumentNullException(nameof(isAcceptableResult));
} }
if (_apiUrls.Count == 0)
{
_logger.LogWarning("No {Service} endpoints are configured, returning default value", _serviceName);
return defaultValue;
}
// Get healthy endpoints first (with caching to avoid excessive checks) // Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync(); var healthyEndpoints = await GetHealthyEndpointsAsync();
@@ -483,6 +511,12 @@ public class RoundRobinFallbackHelper
return new List<TResult>(); return new List<TResult>();
} }
if (_apiUrls.Count == 0)
{
_logger.LogWarning("No {Service} endpoints are configured, skipping parallel processing", _serviceName);
return new List<TResult>();
}
var results = new List<TResult>(); var results = new List<TResult>();
var resultsLock = new object(); var resultsLock = new object();
var itemQueue = new Queue<TItem>(items); var itemQueue = new Queue<TItem>(items);
@@ -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; }
}
}
@@ -73,7 +73,7 @@ public class SquidWTFDownloadService : BaseDownloadService
List<string> apiUrls) List<string> apiUrls)
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger) : base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
{ {
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient("SquidWTF");
_squidwtfSettings = SquidWTFSettings.Value; _squidwtfSettings = SquidWTFSettings.Value;
_odesliService = odesliService; _odesliService = odesliService;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF"); _fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
@@ -99,87 +99,96 @@ public class SquidWTFDownloadService : BaseDownloadService
private async Task<string> RunDownloadWithFallbackAsync(string trackId, Song song, string quality, string basePath, CancellationToken cancellationToken) private async Task<string> RunDownloadWithFallbackAsync(string trackId, Song song, string quality, string basePath, CancellationToken cancellationToken)
{ {
return await _fallbackHelper.TryWithFallbackAsync(async baseUrl => var songId = BuildTrackedSongId(trackId);
var raceCount = Math.Min(3, _fallbackHelper.EndpointCount);
if (raceCount > 1)
{ {
var songId = BuildTrackedSongId(trackId);
var downloadInfo = await FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken);
Logger.LogInformation( Logger.LogInformation(
"Track download info resolved via {Endpoint} (Format: {Format}, Quality: {Quality})", "Racing top {EndpointCount} SquidWTF endpoints for track {TrackId} manifest resolution",
downloadInfo.Endpoint, downloadInfo.MimeType, downloadInfo.AudioQuality); raceCount, trackId);
Logger.LogDebug("Resolved SquidWTF CDN download URL: {Url}", downloadInfo.DownloadUrl); }
var extension = downloadInfo.MimeType?.ToLower() switch var downloadInfo = await _fallbackHelper.RaceTopEndpointsAsync(
{ Math.Max(1, raceCount),
"audio/flac" => ".flac", "audio/mpeg" => ".mp3", "audio/mp4" => ".m4a", _ => ".flac" (baseUrl, ct) => FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, ct),
}; cancellationToken);
var artistForPath = song.AlbumArtist ?? song.Artist; Logger.LogInformation(
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId); "Track download info resolved via {Endpoint} (Format: {Format}, Quality: {Quality})",
downloadInfo.Endpoint, downloadInfo.MimeType, downloadInfo.AudioQuality);
Logger.LogDebug("Resolved SquidWTF CDN download URL: {Url}", downloadInfo.DownloadUrl);
var albumFolder = Path.GetDirectoryName(outputPath)!; var extension = downloadInfo.MimeType?.ToLower() switch
EnsureDirectoryExists(albumFolder); {
"audio/flac" => ".flac", "audio/mpeg" => ".mp3", "audio/mp4" => ".m4a", _ => ".flac"
};
if (basePath.EndsWith("transcoded") && IOFile.Exists(outputPath)) var artistForPath = song.AlbumArtist ?? song.Artist;
{ var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId);
IOFile.SetLastWriteTime(outputPath, DateTime.UtcNow);
Logger.LogInformation("Quality override cache hit: {Path}", outputPath);
return outputPath;
}
outputPath = PathHelper.ResolveUniquePath(outputPath); var albumFolder = Path.GetDirectoryName(outputPath)!;
EnsureDirectoryExists(albumFolder);
using var req = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl); if (basePath.EndsWith("transcoded") && IOFile.Exists(outputPath))
req.Headers.Add("User-Agent", "Mozilla/5.0"); {
req.Headers.Add("Accept", "*/*"); IOFile.SetLastWriteTime(outputPath, DateTime.UtcNow);
var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken); Logger.LogInformation("Quality override cache hit: {Path}", outputPath);
res.EnsureSuccessStatusCode();
await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
var totalBytes = res.Content.Headers.ContentLength;
var buffer = new byte[81920];
long totalBytesRead = 0;
while (true)
{
var bytesRead = await responseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (bytesRead <= 0)
{
break;
}
await outputFile.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
totalBytesRead += bytesRead;
if (totalBytes.HasValue && totalBytes.Value > 0)
{
SetDownloadProgress(songId, (double)totalBytesRead / totalBytes.Value);
}
}
await outputFile.DisposeAsync();
SetDownloadProgress(songId, 1.0);
_ = Task.Run(async () =>
{
try
{
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId))
{
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId);
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId);
}
});
await WriteMetadataAsync(outputPath, song, cancellationToken);
return outputPath; return outputPath;
}
outputPath = PathHelper.ResolveUniquePath(outputPath);
using var req = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
req.Headers.Add("User-Agent", "Mozilla/5.0");
req.Headers.Add("Accept", "*/*");
var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
var totalBytes = res.Content.Headers.ContentLength;
var buffer = new byte[81920];
long totalBytesRead = 0;
while (true)
{
var bytesRead = await responseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (bytesRead <= 0)
{
break;
}
await outputFile.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
totalBytesRead += bytesRead;
if (totalBytes.HasValue && totalBytes.Value > 0)
{
SetDownloadProgress(songId, (double)totalBytesRead / totalBytes.Value);
}
}
await outputFile.DisposeAsync();
SetDownloadProgress(songId, 1.0);
_ = Task.Run(async () =>
{
try
{
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId))
{
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId);
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId);
}
}); });
await WriteMetadataAsync(outputPath, song, cancellationToken);
return outputPath;
} }
protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken) protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
@@ -315,7 +324,9 @@ public class SquidWTFDownloadService : BaseDownloadService
{ {
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}"; var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
Logger.LogDebug("Fetching track download info from: {Url}", url); Logger.LogInformation("Requesting SquidWTF track manifest for track {TrackId} from {Endpoint} at quality {Quality}",
trackId, baseUrl, quality);
Logger.LogDebug("Fetching SquidWTF track download info from: {Url}", url);
using var response = await _httpClient.GetAsync(url, cancellationToken); using var response = await _httpClient.GetAsync(url, cancellationToken);
@@ -357,6 +368,9 @@ public class SquidWTFDownloadService : BaseDownloadService
? audioQualityEl.GetString() ? audioQualityEl.GetString()
: quality; : quality;
Logger.LogInformation("SquidWTF track manifest resolved for track {TrackId} via {Endpoint} (mimeType={MimeType}, audioQuality={AudioQuality})",
trackId, baseUrl, mimeType ?? "audio/flac", audioQuality ?? quality);
return new DownloadResult return new DownloadResult
{ {
Endpoint = baseUrl, Endpoint = baseUrl,
@@ -76,7 +76,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
List<string> apiUrls, List<string> apiUrls,
GenreEnrichmentService? genreEnrichment = null) GenreEnrichmentService? genreEnrichment = null)
{ {
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient("SquidWTF");
_settings = settings.Value; _settings = settings.Value;
_logger = logger; _logger = logger;
_cache = cache; _cache = cache;
@@ -583,50 +583,78 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
{ {
if (externalProvider != "squidwtf") return null; if (externalProvider != "squidwtf") return null;
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => var raceCount = Math.Min(3, _fallbackHelper.EndpointCount);
if (raceCount > 1)
{ {
// Per hifi-api spec: GET /info/?id={trackId} returns track metadata _logger.LogInformation(
var url = $"{baseUrl}/info/?id={externalId}"; "Racing top {EndpointCount} SquidWTF endpoints for track {TrackId} metadata resolution",
raceCount,
externalId);
var response = await _httpClient.GetAsync(url, cancellationToken); try
if (!response.IsSuccessStatusCode)
{ {
throw new HttpRequestException($"HTTP {response.StatusCode}"); return await _fallbackHelper.RaceTopEndpointsAsync(
raceCount,
(baseUrl, ct) => FetchSongAsync(baseUrl, externalId, ct),
cancellationToken);
} }
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Raced SquidWTF metadata lookup failed for track {TrackId}; falling back to sequential failover",
externalId);
}
}
var json = await response.Content.ReadAsStringAsync(cancellationToken); return await _fallbackHelper.TryWithFallbackAsync(
var result = JsonDocument.Parse(json); baseUrl => FetchSongAsync(baseUrl, externalId, cancellationToken),
(Song?)null);
}
// Per hifi-api spec: response is { "version": "2.0", "data": { track object } } private async Task<Song> FetchSongAsync(string baseUrl, string externalId, CancellationToken cancellationToken)
if (!result.RootElement.TryGetProperty("data", out var track)) {
{ var url = $"{baseUrl}/info/?id={externalId}";
throw new InvalidOperationException($"SquidWTF /info response for track {externalId} did not contain data");
}
var song = ParseTidalTrackFull(track); _logger.LogInformation(
"Requesting SquidWTF track metadata for track {TrackId} from {Endpoint}",
externalId,
baseUrl);
_logger.LogDebug("Fetching SquidWTF track metadata from: {Url}", url);
// Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres) using var response = await _httpClient.GetAsync(url, cancellationToken);
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre)) if (!response.IsSuccessStatusCode)
{ {
// Fire-and-forget: don't block the response waiting for genre enrichment throw new HttpRequestException($"HTTP {response.StatusCode}", null, response.StatusCode);
_ = Task.Run(async () => }
{
try
{
await _genreEnrichment.EnrichSongGenreAsync(song);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to enrich genre for {Title}", song.Title);
}
});
}
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService) var json = await response.Content.ReadAsStringAsync(cancellationToken);
// This avoids redundant conversions and ensures it's done in parallel with the download using var result = JsonDocument.Parse(json);
return song; if (!result.RootElement.TryGetProperty("data", out var track))
}, (Song?)null); {
throw new InvalidOperationException($"SquidWTF /info response for track {externalId} did not contain data");
}
var song = ParseTidalTrackFull(track);
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
{
_ = Task.Run(async () =>
{
try
{
await _genreEnrichment.EnrichSongGenreAsync(song);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to enrich genre for {Title}", song.Title);
}
});
}
return song;
} }
public async Task<List<Song>> GetTrackRecommendationsAsync(string externalId, int limit = 20, CancellationToken cancellationToken = default) public async Task<List<Song>> GetTrackRecommendationsAsync(string externalId, int limit = 20, CancellationToken cancellationToken = default)
@@ -57,50 +57,79 @@ public class SquidWTFStartupValidator : BaseStartupValidator
WriteStatus("SquidWTF API Endpoints", _apiUrls.Count.ToString(), ConsoleColor.Cyan); WriteStatus("SquidWTF API Endpoints", _apiUrls.Count.ToString(), ConsoleColor.Cyan);
WriteStatus("SquidWTF Streaming Endpoints", _streamingUrls.Count.ToString(), ConsoleColor.Cyan); WriteStatus("SquidWTF Streaming Endpoints", _streamingUrls.Count.ToString(), ConsoleColor.Cyan);
await BenchmarkEndpointPoolAsync("API", _apiUrls, _apiFallbackHelper, cancellationToken); if (_apiUrls.Count == 0)
await BenchmarkEndpointPoolAsync("streaming", _streamingUrls, _streamingFallbackHelper, cancellationToken); {
WriteStatus("SquidWTF API", "UNAVAILABLE", ConsoleColor.Yellow);
WriteDetail("No API endpoints were discovered from the uptime feeds");
}
else
{
await BenchmarkEndpointPoolAsync("API", _apiUrls, _apiFallbackHelper, cancellationToken);
}
if (_streamingUrls.Count == 0)
{
WriteStatus("SquidWTF Streaming", "UNAVAILABLE", ConsoleColor.Yellow);
WriteDetail("No streaming endpoints were discovered from the uptime feeds");
}
else
{
await BenchmarkEndpointPoolAsync("streaming", _streamingUrls, _streamingFallbackHelper, cancellationToken);
}
if (_apiUrls.Count == 0 && _streamingUrls.Count == 0)
{
return ValidationResult.Failure(
"UNAVAILABLE",
"SquidWTF uptime feeds did not return any usable endpoints",
ConsoleColor.Yellow);
}
// Validate API endpoints and search functionality. // Validate API endpoints and search functionality.
var apiResult = await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) => var apiResult = _apiUrls.Count == 0
{ ? ValidationResult.Failure("-1", "No SquidWTF API endpoints are currently available", ConsoleColor.Yellow)
var response = await _httpClient.GetAsync(baseUrl, cancellationToken); : await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
if (response.IsSuccessStatusCode)
{ {
WriteStatus("SquidWTF API", $"REACHABLE ({baseUrl})", ConsoleColor.Green); var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
WriteDetail("No authentication required - powered by Tidal");
// Try a test search to verify functionality if (response.IsSuccessStatusCode)
await ValidateSearchFunctionality(baseUrl, cancellationToken); {
WriteStatus("SquidWTF API", $"REACHABLE ({baseUrl})", ConsoleColor.Green);
WriteDetail("No authentication required - powered by Tidal");
return ValidationResult.Success("SquidWTF validation completed"); // Try a test search to verify functionality
} await ValidateSearchFunctionality(baseUrl, cancellationToken);
else
{
throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
}
}, ValidationResult.Failure("-1", "All SquidWTF API endpoints failed"));
if (!apiResult.IsValid) return ValidationResult.Success("SquidWTF validation completed");
}
else
{
throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
}
}, ValidationResult.Failure("-1", "All SquidWTF API endpoints failed"));
if (_apiUrls.Count > 0 && !apiResult.IsValid)
{ {
return apiResult; return apiResult;
} }
// Validate streaming endpoints independently to avoid API-only endpoints for streaming. // Validate streaming endpoints independently to avoid API-only endpoints for streaming.
var streamingResult = await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) => var streamingResult = _streamingUrls.Count == 0
{ ? ValidationResult.Failure("-2", "No SquidWTF streaming endpoints are currently available", ConsoleColor.Yellow)
var response = await _httpClient.GetAsync(baseUrl, cancellationToken); : await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
if (response.IsSuccessStatusCode)
{ {
WriteStatus("SquidWTF Streaming", $"REACHABLE ({baseUrl})", ConsoleColor.Green); var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
return ValidationResult.Success("SquidWTF streaming endpoint validation completed");
}
throw new HttpRequestException($"HTTP {(int)response.StatusCode}"); if (response.IsSuccessStatusCode)
}, ValidationResult.Failure("-2", "All SquidWTF streaming endpoints failed")); {
WriteStatus("SquidWTF Streaming", $"REACHABLE ({baseUrl})", ConsoleColor.Green);
return ValidationResult.Success("SquidWTF streaming endpoint validation completed");
}
if (!streamingResult.IsValid) throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
}, ValidationResult.Failure("-2", "All SquidWTF streaming endpoints failed"));
if (_streamingUrls.Count > 0 && !streamingResult.IsValid)
{ {
return streamingResult; return streamingResult;
} }
@@ -19,16 +19,6 @@ public sealed class SquidWtfEndpointCatalog
throw new ArgumentNullException(nameof(streamingUrls)); throw new ArgumentNullException(nameof(streamingUrls));
} }
if (apiUrls.Count == 0)
{
throw new ArgumentException("API URL list cannot be empty.", nameof(apiUrls));
}
if (streamingUrls.Count == 0)
{
throw new ArgumentException("Streaming URL list cannot be empty.", nameof(streamingUrls));
}
ApiUrls = apiUrls; ApiUrls = apiUrls;
StreamingUrls = streamingUrls; StreamingUrls = streamingUrls;
LoadedAtUtc = DateTime.UtcNow; LoadedAtUtc = DateTime.UtcNow;
@@ -6,9 +6,9 @@ public static class SquidWtfEndpointDiscovery
{ {
public static readonly IReadOnlyList<string> SourceUrls = new[] public static readonly IReadOnlyList<string> SourceUrls = new[]
{ {
"https://tidal-uptime.geeked.wtf/",
"https://tidal-uptime.jiffy-puffs-1j.workers.dev/", "https://tidal-uptime.jiffy-puffs-1j.workers.dev/",
"https://tidal-uptime.props-76styles.workers.dev/", "https://tidal-uptime.props-76styles.workers.dev/"
"https://tidal-uptime.geeked.wtf/"
}; };
public static async Task<SquidWtfEndpointCatalog> DiscoverAsync(CancellationToken cancellationToken = default) public static async Task<SquidWtfEndpointCatalog> DiscoverAsync(CancellationToken cancellationToken = default)
@@ -24,8 +24,12 @@ public static class SquidWtfEndpointDiscovery
{ {
try try
{ {
Console.WriteLine($"Loading SquidWTF uptime feed: {sourceUrl}");
var json = await httpClient.GetStringAsync(sourceUrl, cancellationToken); var json = await httpClient.GetStringAsync(sourceUrl, cancellationToken);
feeds.Add(ParseFeed(json)); var feed = ParseFeed(json);
feeds.Add(feed);
Console.WriteLine(
$"Loaded SquidWTF uptime feed {sourceUrl}: api={feed.ApiUrls.Count}, streaming={feed.StreamingUrls.Count}, down={feed.DownUrls.Count}, lastUpdated={feed.LastUpdated:O}");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -35,7 +39,9 @@ public static class SquidWtfEndpointDiscovery
if (feeds.Count == 0) if (feeds.Count == 0)
{ {
throw new InvalidOperationException("Could not load SquidWTF endpoint feeds from any source URL."); Console.WriteLine(
"⚠️ No SquidWTF uptime feeds could be loaded. Starting with SquidWTF external features unavailable; local Jellyfin content will still work.");
return new SquidWtfEndpointCatalog(new List<string>(), new List<string>());
} }
var orderedFeeds = feeds var orderedFeeds = feeds
@@ -61,12 +67,12 @@ public static class SquidWtfEndpointDiscovery
if (apiUrls.Count == 0) if (apiUrls.Count == 0)
{ {
throw new InvalidOperationException("SquidWTF endpoint feed returned zero API endpoints."); Console.WriteLine("⚠️ SquidWTF uptime feeds returned zero API endpoints.");
} }
if (streamingUrls.Count == 0) if (streamingUrls.Count == 0)
{ {
throw new InvalidOperationException("SquidWTF endpoint feed returned zero streaming endpoints."); Console.WriteLine("⚠️ SquidWTF uptime feeds returned zero streaming endpoints.");
} }
Console.WriteLine($"Loaded SquidWTF endpoints from uptime feeds: api={apiUrls.Count}, streaming={streamingUrls.Count}"); Console.WriteLine($"Loaded SquidWTF endpoints from uptime feeds: api={apiUrls.Count}, streaming={streamingUrls.Count}");
+94 -15
View File
@@ -28,6 +28,12 @@
<label for="auth-password">Password</label> <label for="auth-password">Password</label>
<input id="auth-password" type="password" required> <input id="auth-password" type="password" required>
<label class="auth-checkbox" for="auth-remember-me">
<input id="auth-remember-me" type="checkbox">
<span>Keep me signed in for 30 days on this browser</span>
</label>
<small class="auth-note">Use only on a device you trust.</small>
<button class="primary" type="submit">Sign In</button> <button class="primary" type="submit">Sign In</button>
<div class="auth-error" id="auth-error" role="alert"></div> <div class="auth-error" id="auth-error" role="alert"></div>
</form> </form>
@@ -65,8 +71,17 @@
<div class="app-shell"> <div class="app-shell">
<aside class="sidebar" aria-label="Admin navigation"> <aside class="sidebar" aria-label="Admin navigation">
<div class="sidebar-brand"> <div class="sidebar-brand">
<div class="sidebar-title">Allstarr</div> <div class="sidebar-title">
<a class="title-link" href="https://github.com/SoPat712/allstarr" target="_blank"
rel="noopener noreferrer">Allstarr</a>
</div>
<div class="sidebar-subtitle" id="sidebar-version">Loading...</div> <div class="sidebar-subtitle" id="sidebar-version">Loading...</div>
<div class="sidebar-status" id="status-indicator">
<span class="status-badge" id="spotify-status">
<span class="status-dot"></span>
<span>Loading...</span>
</span>
</div>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button> <button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button>
@@ -75,6 +90,7 @@
<button class="sidebar-link" type="button" data-tab="kept">Kept Downloads</button> <button class="sidebar-link" type="button" data-tab="kept">Kept Downloads</button>
<button class="sidebar-link" type="button" data-tab="scrobbling">Scrobbling</button> <button class="sidebar-link" type="button" data-tab="scrobbling">Scrobbling</button>
<button class="sidebar-link" type="button" data-tab="config">Configuration</button> <button class="sidebar-link" type="button" data-tab="config">Configuration</button>
<button class="sidebar-link" type="button" data-tab="report-issues">Report Issues</button>
<button class="sidebar-link" type="button" data-tab="endpoints">API Analytics</button> <button class="sidebar-link" type="button" data-tab="endpoints">API Analytics</button>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -86,20 +102,6 @@
</aside> </aside>
<main class="app-main"> <main class="app-main">
<header class="app-header">
<h1>
Allstarr <span class="version" id="version">Loading...</span>
</h1>
<div class="header-actions">
<div id="status-indicator">
<span class="status-badge" id="spotify-status">
<span class="status-dot"></span>
<span>Loading...</span>
</span>
</div>
</div>
</header>
<div class="tabs top-tabs" aria-hidden="true"> <div class="tabs top-tabs" aria-hidden="true">
<div class="tab active" data-tab="dashboard">Dashboard</div> <div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div> <div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
@@ -107,6 +109,7 @@
<div class="tab" data-tab="kept">Kept Downloads</div> <div class="tab" data-tab="kept">Kept Downloads</div>
<div class="tab" data-tab="scrobbling">Scrobbling</div> <div class="tab" data-tab="scrobbling">Scrobbling</div>
<div class="tab" data-tab="config">Configuration</div> <div class="tab" data-tab="config">Configuration</div>
<div class="tab" data-tab="report-issues">Report Issues</div>
<div class="tab" data-tab="endpoints">API Analytics</div> <div class="tab" data-tab="endpoints">API Analytics</div>
</div> </div>
@@ -900,6 +903,82 @@
</div> </div>
</div> </div>
<!-- Report Issues Tab -->
<div class="tab-content" id="tab-report-issues">
<div class="card">
<h2>Report Issues</h2>
<div class="guidance-banner info mb-16">
<span></span>
<div class="guidance-content">
<div class="guidance-title">Draft a GitHub issue from inside Allstarr.</div>
<div class="guidance-detail">Allstarr includes only safe diagnostics here. Sensitive values stay redacted, and the final submit still happens on GitHub.</div>
</div>
</div>
<div class="report-issue-layout">
<div class="report-issue-panel">
<div class="form-group">
<label for="issue-report-type">Report Type</label>
<select id="issue-report-type">
<option value="bug">Bug Report</option>
<option value="feature">Feature Request</option>
</select>
</div>
<div class="form-group">
<label for="issue-report-title">Title</label>
<input type="text" id="issue-report-title" placeholder="Short summary of the issue">
</div>
<div class="form-group">
<label for="issue-report-primary" id="issue-report-primary-label">Describe the bug</label>
<textarea id="issue-report-primary" rows="5"
placeholder="What happened? What looked wrong?"></textarea>
</div>
<div class="form-group">
<label for="issue-report-secondary" id="issue-report-secondary-label">To Reproduce</label>
<textarea id="issue-report-secondary" rows="5"
placeholder="List the steps needed to reproduce the issue"></textarea>
</div>
<div class="form-group">
<label for="issue-report-tertiary" id="issue-report-tertiary-label">Expected behavior</label>
<textarea id="issue-report-tertiary" rows="4"
placeholder="What did you expect to happen instead?"></textarea>
</div>
<div class="form-group">
<label for="issue-report-context" id="issue-report-context-label">Additional context</label>
<textarea id="issue-report-context" rows="4"
placeholder="Anything else that might help, including screenshots or surrounding context"></textarea>
</div>
<div class="card-actions-row">
<button class="primary" type="button" id="open-github-issue-btn">Open Bug Report on GitHub</button>
<button type="button" id="copy-issue-report-btn">Copy Report</button>
<button type="button" id="clear-issue-report-btn">Clear Report</button>
</div>
</div>
<div class="report-preview-panel">
<div class="guidance-banner compact">
Safe diagnostics only: version, runtime config, service state, and a concise client summary. Sensitive values stay redacted.
</div>
<div class="form-group">
<label for="issue-report-preview">GitHub Issue Preview</label>
<textarea id="issue-report-preview" rows="22" readonly></textarea>
</div>
<div class="report-preview-help" id="issue-report-preview-help">
GitHub drafts that exceed the URL size limit will open with a shorter body. The full report will also be copied to your clipboard.
</div>
</div>
</div>
</div>
</div>
<!-- API Analytics Tab --> <!-- API Analytics Tab -->
<div class="tab-content" id="tab-endpoints"> <div class="tab-content" id="tab-endpoints">
<div class="card"> <div class="card">
+2 -2
View File
@@ -56,10 +56,10 @@ export async function fetchAdminSession() {
); );
} }
export async function loginAdminSession(username, password) { export async function loginAdminSession(username, password, rememberMe = false) {
return requestJson( return requestJson(
"/api/admin/auth/login", "/api/admin/auth/login",
asJsonBody({ username, password }), asJsonBody({ username, password, rememberMe }),
"Authentication failed", "Authentication failed",
); );
} }
+4 -1
View File
@@ -72,6 +72,7 @@ function applyAuthorizationScope() {
"kept", "kept",
"scrobbling", "scrobbling",
"config", "config",
"report-issues",
"endpoints", "endpoints",
]; ];
@@ -196,9 +197,11 @@ function wireLoginForm() {
const usernameInput = document.getElementById("auth-username"); const usernameInput = document.getElementById("auth-username");
const passwordInput = document.getElementById("auth-password"); const passwordInput = document.getElementById("auth-password");
const rememberMeInput = document.getElementById("auth-remember-me");
const authError = document.getElementById("auth-error"); const authError = document.getElementById("auth-error");
const username = usernameInput?.value?.trim() || ""; const username = usernameInput?.value?.trim() || "";
const password = passwordInput?.value || ""; const password = passwordInput?.value || "";
const rememberMe = Boolean(rememberMeInput?.checked);
if (!username || !password) { if (!username || !password) {
if (authError) { if (authError) {
@@ -212,7 +215,7 @@ function wireLoginForm() {
authError.textContent = ""; authError.textContent = "";
} }
const result = await API.loginAdminSession(username, password); const result = await API.loginAdminSession(username, password, rememberMe);
if (passwordInput) { if (passwordInput) {
passwordInput.value = ""; passwordInput.value = "";
} }
+11
View File
@@ -15,6 +15,7 @@ let onCookieNeedsInit = async () => {};
let setCurrentConfigState = () => {}; let setCurrentConfigState = () => {};
let syncConfigUiExtras = () => {}; let syncConfigUiExtras = () => {};
let loadScrobblingConfig = () => {}; let loadScrobblingConfig = () => {};
let injectedPlaylistRequestToken = 0;
let jellyfinPlaylistRequestToken = 0; let jellyfinPlaylistRequestToken = 0;
async function fetchStatus() { async function fetchStatus() {
@@ -39,10 +40,20 @@ async function fetchStatus() {
} }
async function fetchPlaylists(silent = false) { async function fetchPlaylists(silent = false) {
const requestToken = ++injectedPlaylistRequestToken;
try { try {
const data = await API.fetchPlaylists(); const data = await API.fetchPlaylists();
if (requestToken !== injectedPlaylistRequestToken) {
return;
}
UI.updatePlaylistsUI(data); UI.updatePlaylistsUI(data);
} catch (error) { } catch (error) {
if (requestToken !== injectedPlaylistRequestToken) {
return;
}
if (!silent) { if (!silent) {
console.error("Failed to fetch playlists:", error); console.error("Failed to fetch playlists:", error);
showToast("Failed to fetch playlists", "error"); showToast("Failed to fetch playlists", "error");
+501
View File
@@ -0,0 +1,501 @@
import { showToast } from "./utils.js";
const GITHUB_NEW_ISSUE_URL = "https://github.com/SoPat712/allstarr/issues/new";
const MAX_PREFILL_URL_LENGTH = 6500;
const ISSUE_TEMPLATES = {
bug: {
template: "bug-report.md",
titlePrefix: "[BUG] ",
openLabel: "Open Bug Report on GitHub",
primaryLabel: "Describe the bug",
primaryPlaceholder: "What happened? What looked wrong?",
secondaryLabel: "To Reproduce",
secondaryPlaceholder: "List the steps needed to reproduce the issue",
tertiaryLabel: "Expected behavior",
tertiaryPlaceholder: "What did you expect to happen instead?",
contextLabel: "Additional context",
contextPlaceholder:
"Anything else that might help, including screenshots or surrounding context",
},
feature: {
template: "feature-request.md",
titlePrefix: "[FEATURE] ",
openLabel: "Open Feature Request on GitHub",
primaryLabel: "Problem to solve",
primaryPlaceholder: "What problem are you trying to solve?",
secondaryLabel: "Solution you'd like",
secondaryPlaceholder: "What should Allstarr do instead?",
tertiaryLabel: "Alternatives considered",
tertiaryPlaceholder: "What alternatives or workarounds have you considered?",
contextLabel: "Additional context",
contextPlaceholder:
"Extra examples, mockups, or screenshots that explain the request",
},
};
const DIAGNOSTIC_SOURCE_IDS = [
"sidebar-version",
"backend-type",
"spotify-status",
"jellyfin-url",
"config-music-service",
"config-storage-mode",
"config-download-mode",
"config-redis-enabled",
"config-spotify-import-enabled",
"config-deezer-quality",
"config-squid-quality",
"config-qobuz-quality",
"scrobbling-enabled-value",
];
function getElement(id) {
return document.getElementById(id);
}
function normalizeText(value, fallback = "Unavailable") {
const normalized = String(value ?? "").trim();
if (!normalized || normalized === "-" || /^loading/i.test(normalized)) {
return fallback;
}
return normalized;
}
function getIssueType() {
return getElement("issue-report-type")?.value === "feature" ? "feature" : "bug";
}
function getIssueConfig(type = getIssueType()) {
return ISSUE_TEMPLATES[type] || ISSUE_TEMPLATES.bug;
}
function sanitizeTitle(title, type) {
const prefix = getIssueConfig(type).titlePrefix;
const trimmed = String(title ?? "").trim();
if (!trimmed) {
return prefix + (type === "feature" ? "Please add a short request title" : "Please add a short bug title");
}
if (trimmed.toUpperCase().startsWith(prefix.trim())) {
return trimmed;
}
return prefix + trimmed;
}
function getElementText(id, fallback = "Unavailable") {
return normalizeText(getElement(id)?.textContent, fallback);
}
function getMusicServiceQuality(musicService) {
const normalized = String(musicService ?? "").trim().toLowerCase();
if (normalized === "deezer") {
return getElementText("config-deezer-quality");
}
if (normalized === "qobuz") {
return getElementText("config-qobuz-quality");
}
if (normalized === "squidwtf") {
return getElementText("config-squid-quality");
}
return "";
}
function getClientSummary() {
const ua = String(window.navigator?.userAgent ?? "");
const browser =
ua.match(/Firefox\/(\d+)/)?.[0]?.replace("/", " ") ||
ua.match(/Edg\/(\d+)/)?.[0]?.replace("/", " ") ||
ua.match(/Chrome\/(\d+)/)?.[0]?.replace("/", " ") ||
(ua.includes("Safari/") && ua.match(/Version\/(\d+)/)?.[0]?.replace("/", " ")) ||
"Unknown browser";
let platform = "Unknown OS";
if (/Mac OS X/i.test(ua)) {
platform = "macOS";
} else if (/Windows/i.test(ua)) {
platform = "Windows";
} else if (/Android/i.test(ua)) {
platform = "Android";
} else if (/iPhone|iPad|iPod/i.test(ua)) {
platform = "iOS";
} else if (/Linux/i.test(ua)) {
platform = "Linux";
}
return `${browser} on ${platform}`;
}
function getRedactedUrlState() {
const jellyfinUrl = normalizeText(getElement("jellyfin-url")?.textContent, "");
return jellyfinUrl ? "Configured (redacted)" : "Not configured";
}
function getDiagnostics() {
const timezone =
Intl.DateTimeFormat().resolvedOptions().timeZone || "Unavailable";
const musicService = getElementText("config-music-service");
return {
version: getElementText("sidebar-version"),
backendType: normalizeText(
getElement("backend-type")?.textContent ||
getElement("config-backend-type")?.textContent,
),
musicService,
musicServiceQuality: getMusicServiceQuality(musicService),
storageMode: getElementText("config-storage-mode"),
downloadMode: getElementText("config-download-mode"),
redisEnabled: getElementText("config-redis-enabled"),
spotifyImportEnabled: getElementText("config-spotify-import-enabled"),
scrobblingEnabled: getElementText("scrobbling-enabled-value"),
spotifyStatus: getElementText("spotify-status"),
jellyfinUrl: getRedactedUrlState(),
client: getClientSummary(),
generatedAt: new Date().toISOString(),
timezone,
};
}
function getReportState() {
const type = getIssueType();
return {
type,
titleInput: String(getElement("issue-report-title")?.value ?? "").trim(),
primary: String(getElement("issue-report-primary")?.value ?? "").trim(),
secondary: String(getElement("issue-report-secondary")?.value ?? "").trim(),
tertiary: String(getElement("issue-report-tertiary")?.value ?? "").trim(),
context: String(getElement("issue-report-context")?.value ?? "").trim(),
};
}
function renderIssueBody(state, includeDiagnostics = true) {
const diagnostics = getDiagnostics();
const diagnosticsLines = [
"- Sensitive values stay redacted in this block.",
`- Allstarr Version: ${diagnostics.version}`,
`- Backend Type: ${diagnostics.backendType}`,
`- Music Service: ${diagnostics.musicService}`,
diagnostics.musicServiceQuality
? `- Music Service Quality: ${diagnostics.musicServiceQuality}`
: null,
`- Storage Mode: ${diagnostics.storageMode}`,
`- Download Mode: ${diagnostics.downloadMode}`,
`- Redis Enabled: ${diagnostics.redisEnabled}`,
`- Spotify Import Enabled: ${diagnostics.spotifyImportEnabled}`,
`- Scrobbling Enabled: ${diagnostics.scrobblingEnabled}`,
`- Spotify Status: ${diagnostics.spotifyStatus}`,
`- Jellyfin URL: ${diagnostics.jellyfinUrl}`,
`- Client: ${diagnostics.client}`,
`- Generated At (UTC): ${diagnostics.generatedAt}`,
`- Browser Time Zone: ${diagnostics.timezone}`,
];
const diagnosticsMarkdown = diagnosticsLines.filter(Boolean).join("\n");
if (state.type === "feature") {
const sections = [
[
"## Problem to solve",
state.primary || "_Please describe the problem you want to solve._",
],
[
"## Solution you'd like",
state.secondary || "_Please describe the solution you want._",
],
[
"## Alternatives considered",
state.tertiary || "_Please describe alternatives or workarounds you've considered._",
],
[
"## Additional context",
state.context || "_Add any other context, screenshots, or examples here._",
],
];
if (includeDiagnostics) {
sections.push(["## Safe diagnostics from Allstarr", diagnosticsMarkdown]);
}
return sections.map(([heading, content]) => `${heading}\n${content}`).join("\n\n");
}
const sections = [
[
"## Describe the bug",
state.primary || "_Please describe the bug._",
],
[
"## To Reproduce",
state.secondary ||
"_Please list the steps needed to reproduce the issue._",
],
[
"## Expected behavior",
state.tertiary || "_Please describe what you expected to happen._",
],
[
"## Additional context",
state.context || "_Add any other context, screenshots, or examples here._",
],
];
if (includeDiagnostics) {
sections.push(["## Safe diagnostics from Allstarr", diagnosticsMarkdown]);
}
return sections.map(([heading, content]) => `${heading}\n${content}`).join("\n\n");
}
function buildIssuePayload() {
const state = getReportState();
const config = getIssueConfig(state.type);
const title = sanitizeTitle(state.titleInput, state.type);
const fullBody = renderIssueBody(state, true);
const fullUrl = new URL(GITHUB_NEW_ISSUE_URL);
fullUrl.searchParams.set("template", config.template);
fullUrl.searchParams.set("title", title);
fullUrl.searchParams.set("body", fullBody);
if (fullUrl.toString().length <= MAX_PREFILL_URL_LENGTH) {
return {
title,
fullBody,
url: fullUrl.toString(),
truncated: false,
};
}
const shortenedBody = [
renderIssueBody(state, false),
"> Full safe diagnostics were copied to your clipboard by Allstarr.",
"> Paste them below if GitHub opens with a shorter draft.",
].join("\n\n");
const shortenedUrl = new URL(GITHUB_NEW_ISSUE_URL);
shortenedUrl.searchParams.set("template", config.template);
shortenedUrl.searchParams.set("title", title);
shortenedUrl.searchParams.set("body", shortenedBody);
return {
title,
fullBody,
url: shortenedUrl.toString(),
truncated: true,
};
}
async function copyTextToClipboard(text) {
if (!text) {
return false;
}
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall back to a hidden textarea if direct clipboard access fails.
}
}
const helper = document.createElement("textarea");
helper.value = text;
helper.setAttribute("readonly", "");
helper.style.position = "absolute";
helper.style.left = "-9999px";
document.body.appendChild(helper);
helper.select();
let copied = false;
try {
copied = document.execCommand("copy");
} catch {
copied = false;
}
document.body.removeChild(helper);
return copied;
}
async function copyIssueReport({ silent = false } = {}) {
const payload = buildIssuePayload();
const copied = await copyTextToClipboard(`${payload.title}\n\n${payload.fullBody}`);
if (!silent) {
showToast(
copied
? "Issue draft copied to clipboard"
: "Could not copy the report. You can still copy it from the preview.",
copied ? "success" : "warning",
4000,
);
}
return copied;
}
function clearIssueReport() {
const titleInput = getElement("issue-report-title");
const primaryInput = getElement("issue-report-primary");
const secondaryInput = getElement("issue-report-secondary");
const tertiaryInput = getElement("issue-report-tertiary");
const contextInput = getElement("issue-report-context");
const hasDraft = [
titleInput?.value,
primaryInput?.value,
secondaryInput?.value,
tertiaryInput?.value,
contextInput?.value,
].some((value) => String(value ?? "").trim().length > 0);
if (hasDraft && !window.confirm("Clear the current report draft?")) {
return;
}
if (titleInput) titleInput.value = "";
if (primaryInput) primaryInput.value = "";
if (secondaryInput) secondaryInput.value = "";
if (tertiaryInput) tertiaryInput.value = "";
if (contextInput) contextInput.value = "";
refreshIssueReportPreview();
titleInput?.focus();
showToast("Report draft cleared", "success", 2500);
}
function validateTitle() {
const titleInput = getElement("issue-report-title");
if (!titleInput?.value?.trim()) {
titleInput?.focus();
showToast("Add a short title before opening the GitHub draft.", "warning");
return false;
}
return true;
}
async function openGithubIssueDraft() {
if (!validateTitle()) {
return;
}
const copied = await copyIssueReport({ silent: true });
const payload = buildIssuePayload();
const openedWindow = window.open(payload.url, "_blank", "noopener,noreferrer");
if (!openedWindow) {
showToast(
"GitHub draft popup was blocked. Allow popups for this site, then try again.",
"warning",
5000,
);
return;
}
const message = payload.truncated
? "Opened a shorter GitHub draft and copied the full report to your clipboard."
: copied
? "Opened the GitHub draft and copied the report to your clipboard."
: "Opened the GitHub draft. If anything is missing, use Copy Report.";
showToast(message, payload.truncated ? "warning" : "success", 5000);
}
function updateIssueReporterCopy() {
const type = getIssueType();
const config = getIssueConfig(type);
getElement("issue-report-primary-label").textContent = config.primaryLabel;
getElement("issue-report-primary").placeholder = config.primaryPlaceholder;
getElement("issue-report-secondary-label").textContent = config.secondaryLabel;
getElement("issue-report-secondary").placeholder = config.secondaryPlaceholder;
getElement("issue-report-tertiary-label").textContent = config.tertiaryLabel;
getElement("issue-report-tertiary").placeholder = config.tertiaryPlaceholder;
getElement("issue-report-context-label").textContent = config.contextLabel;
getElement("issue-report-context").placeholder = config.contextPlaceholder;
getElement("open-github-issue-btn").textContent = config.openLabel;
getElement("issue-report-title").placeholder =
type === "feature"
? "Short summary of the feature request"
: "Short summary of the issue";
}
export function refreshIssueReportPreview() {
const preview = getElement("issue-report-preview");
const previewHelp = getElement("issue-report-preview-help");
if (!preview || !previewHelp) {
return;
}
updateIssueReporterCopy();
const payload = buildIssuePayload();
preview.value = `${payload.title}\n\n${payload.fullBody}`;
previewHelp.textContent = payload.truncated
? "This report is long enough that Allstarr will open GitHub with a shorter draft and copy the full report to your clipboard."
: "This draft fits in a normal GitHub issue URL. Allstarr will still copy the full report to your clipboard when you open it.";
}
export function initIssueReporter() {
const typeSelect = getElement("issue-report-type");
const titleInput = getElement("issue-report-title");
const primaryInput = getElement("issue-report-primary");
const secondaryInput = getElement("issue-report-secondary");
const tertiaryInput = getElement("issue-report-tertiary");
const contextInput = getElement("issue-report-context");
const copyButton = getElement("copy-issue-report-btn");
const clearButton = getElement("clear-issue-report-btn");
const openButton = getElement("open-github-issue-btn");
if (
!typeSelect ||
!titleInput ||
!primaryInput ||
!secondaryInput ||
!tertiaryInput ||
!contextInput ||
!copyButton ||
!clearButton ||
!openButton
) {
return;
}
[typeSelect, titleInput, primaryInput, secondaryInput, tertiaryInput, contextInput].forEach(
(input) => {
input.addEventListener("input", refreshIssueReportPreview);
input.addEventListener("change", refreshIssueReportPreview);
},
);
copyButton.addEventListener("click", () => {
copyIssueReport();
});
clearButton.addEventListener("click", () => {
clearIssueReport();
});
openButton.addEventListener("click", () => {
openGithubIssueDraft();
});
const diagnosticsObserver = new MutationObserver(() => {
refreshIssueReportPreview();
});
DIAGNOSTIC_SOURCE_IDS.forEach((id) => {
const source = getElement(id);
if (!source) {
return;
}
diagnosticsObserver.observe(source, {
childList: true,
subtree: true,
characterData: true,
});
});
window.addEventListener("hashchange", refreshIssueReportPreview);
refreshIssueReportPreview();
}
+3
View File
@@ -37,6 +37,7 @@ import { initAuthSession } from "./auth-session.js";
import { initActionDispatcher } from "./action-dispatcher.js"; import { initActionDispatcher } from "./action-dispatcher.js";
import { initNavigationView } from "./views/navigation-view.js"; import { initNavigationView } from "./views/navigation-view.js";
import { initScrobblingView } from "./views/scrobbling-view.js"; import { initScrobblingView } from "./views/scrobbling-view.js";
import { initIssueReporter } from "./issue-reporter.js";
let cookieDateInitialized = false; let cookieDateInitialized = false;
let restartRequired = false; let restartRequired = false;
@@ -137,6 +138,8 @@ initPlaylistAdmin({
fetchJellyfinPlaylists: dashboard.fetchJellyfinPlaylists, fetchJellyfinPlaylists: dashboard.fetchJellyfinPlaylists,
}); });
initIssueReporter();
const authSession = initAuthSession({ const authSession = initAuthSession({
stopDashboardRefresh: dashboard.stopDashboardRefresh, stopDashboardRefresh: dashboard.stopDashboardRefresh,
loadDashboardData: dashboard.loadDashboardData, loadDashboardData: dashboard.loadDashboardData,
+357 -85
View File
@@ -5,6 +5,7 @@ import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
let rowMenuHandlersBound = false; let rowMenuHandlersBound = false;
let tableRowHandlersBound = false; let tableRowHandlersBound = false;
const expandedInjectedPlaylistDetails = new Set(); const expandedInjectedPlaylistDetails = new Set();
let openInjectedPlaylistMenuKey = null;
function bindRowMenuHandlers() { function bindRowMenuHandlers() {
if (rowMenuHandlersBound) { if (rowMenuHandlersBound) {
@@ -57,8 +58,16 @@ function closeAllRowMenus(exceptId = null) {
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => { document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
if (!exceptId || menu.id !== exceptId) { if (!exceptId || menu.id !== exceptId) {
menu.classList.remove("open"); menu.classList.remove("open");
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
if (trigger) {
trigger.setAttribute("aria-expanded", "false");
}
} }
}); });
if (!exceptId) {
openInjectedPlaylistMenuKey = null;
}
} }
function closeRowMenu(event, menuId) { function closeRowMenu(event, menuId) {
@@ -69,6 +78,13 @@ function closeRowMenu(event, menuId) {
const menu = document.getElementById(menuId); const menu = document.getElementById(menuId);
if (menu) { if (menu) {
menu.classList.remove("open"); menu.classList.remove("open");
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
if (trigger) {
trigger.setAttribute("aria-expanded", "false");
}
if (menu.dataset.menuKey) {
openInjectedPlaylistMenuKey = null;
}
} }
} }
@@ -85,6 +101,14 @@ function toggleRowMenu(event, menuId) {
const isOpen = menu.classList.contains("open"); const isOpen = menu.classList.contains("open");
closeAllRowMenus(menuId); closeAllRowMenus(menuId);
menu.classList.toggle("open", !isOpen); menu.classList.toggle("open", !isOpen);
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
if (trigger) {
trigger.setAttribute("aria-expanded", String(!isOpen));
}
if (menu.dataset.menuKey) {
openInjectedPlaylistMenuKey = isOpen ? null : menu.dataset.menuKey;
}
} }
function toggleDetailsRow(event, detailsRowId) { function toggleDetailsRow(event, detailsRowId) {
@@ -224,6 +248,275 @@ function getPlaylistStatusSummary(playlist) {
}; };
} }
function syncElementAttributes(target, source) {
if (!target || !source) {
return;
}
const sourceAttributes = new Map(
Array.from(source.attributes || []).map((attribute) => [
attribute.name,
attribute.value,
]),
);
Array.from(target.attributes || []).forEach((attribute) => {
if (!sourceAttributes.has(attribute.name)) {
target.removeAttribute(attribute.name);
}
});
sourceAttributes.forEach((value, name) => {
target.setAttribute(name, value);
});
}
function syncPlaylistRowActionsWrap(existingWrap, nextWrap) {
if (!existingWrap || !nextWrap) {
return;
}
syncElementAttributes(existingWrap, nextWrap);
const activeElement = document.activeElement;
let focusTarget = null;
if (activeElement && existingWrap.contains(activeElement)) {
if (activeElement.classList.contains("menu-trigger")) {
focusTarget = { type: "trigger" };
} else if (activeElement.tagName === "BUTTON") {
focusTarget = {
type: "menu-item",
action: activeElement.getAttribute("data-action") || "",
text: activeElement.textContent || "",
};
}
}
const existingTrigger = existingWrap.querySelector(".menu-trigger");
const nextTrigger = nextWrap.querySelector(".menu-trigger");
if (existingTrigger && nextTrigger) {
syncElementAttributes(existingTrigger, nextTrigger);
existingTrigger.textContent = nextTrigger.textContent;
} else if (nextTrigger && !existingTrigger) {
existingWrap.prepend(nextTrigger.cloneNode(true));
} else if (existingTrigger && !nextTrigger) {
existingTrigger.remove();
}
const existingMenu = existingWrap.querySelector(".row-actions-menu");
const nextMenu = nextWrap.querySelector(".row-actions-menu");
if (existingMenu && nextMenu) {
syncElementAttributes(existingMenu, nextMenu);
existingMenu.replaceChildren(
...Array.from(nextMenu.children).map((child) => child.cloneNode(true)),
);
} else if (nextMenu && !existingMenu) {
existingWrap.append(nextMenu.cloneNode(true));
} else if (existingMenu && !nextMenu) {
existingMenu.remove();
}
if (!focusTarget) {
return;
}
if (focusTarget.type === "trigger") {
existingWrap.querySelector(".menu-trigger")?.focus();
return;
}
const matchingButton =
Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find(
(button) =>
(button.getAttribute("data-action") || "") === focusTarget.action &&
button.textContent === focusTarget.text,
) ||
Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find(
(button) =>
(button.getAttribute("data-action") || "") === focusTarget.action,
);
matchingButton?.focus();
}
function syncPlaylistControlsCell(
existingControlsCell,
nextControlsCell,
preserveOpenMenu = false,
) {
if (!existingControlsCell || !nextControlsCell) {
return;
}
syncElementAttributes(existingControlsCell, nextControlsCell);
if (!preserveOpenMenu) {
existingControlsCell.innerHTML = nextControlsCell.innerHTML;
return;
}
const existingDetailsTrigger =
existingControlsCell.querySelector(".details-trigger");
const nextDetailsTrigger = nextControlsCell.querySelector(".details-trigger");
const existingWrap = existingControlsCell.querySelector(".row-actions-wrap");
const nextWrap = nextControlsCell.querySelector(".row-actions-wrap");
if (
!existingDetailsTrigger ||
!nextDetailsTrigger ||
!existingWrap ||
!nextWrap
) {
existingControlsCell.innerHTML = nextControlsCell.innerHTML;
return;
}
syncElementAttributes(existingDetailsTrigger, nextDetailsTrigger);
existingDetailsTrigger.textContent = nextDetailsTrigger.textContent;
syncPlaylistRowActionsWrap(existingWrap, nextWrap);
}
function syncPlaylistMainRow(
existingMainRow,
nextMainRow,
preserveOpenMenu = false,
) {
if (!existingMainRow || !nextMainRow) {
return;
}
syncElementAttributes(existingMainRow, nextMainRow);
const nextCells = Array.from(nextMainRow.children);
const existingCells = Array.from(existingMainRow.children);
if (!preserveOpenMenu || nextCells.length !== existingCells.length) {
existingMainRow.innerHTML = nextMainRow.innerHTML;
return;
}
nextCells.forEach((nextCell, index) => {
const existingCell = existingCells[index];
if (!existingCell) {
existingMainRow.append(nextCell.cloneNode(true));
return;
}
if (index === nextCells.length - 1) {
syncPlaylistControlsCell(existingCell, nextCell, preserveOpenMenu);
return;
}
existingCell.replaceWith(nextCell.cloneNode(true));
});
while (existingMainRow.children.length > nextCells.length) {
existingMainRow.lastElementChild?.remove();
}
}
function syncPlaylistDetailsRow(existingDetailsRow, nextDetailsRow) {
if (!existingDetailsRow || !nextDetailsRow) {
return;
}
syncElementAttributes(existingDetailsRow, nextDetailsRow);
existingDetailsRow.innerHTML = nextDetailsRow.innerHTML;
}
function renderPlaylistRowPairMarkup(playlist, index) {
const summary = getPlaylistStatusSummary(playlist);
const detailsRowId = `playlist-details-${index}`;
const menuId = `playlist-menu-${index}`;
const detailsKey = `${playlist.id || playlist.name || index}`;
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
const isMenuOpen = openInjectedPlaylistMenuKey === detailsKey;
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
const escapedPlaylistName = escapeHtml(playlist.name);
const escapedSyncSchedule = escapeHtml(syncSchedule);
const escapedDetailsKey = escapeHtml(detailsKey);
const breakdownBadges = [
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
`<span class="status-pill info">${summary.externalMatched} External</span>`,
];
if (summary.externalMissing > 0) {
breakdownBadges.push(
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
);
}
return `
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
<td>
<div class="name-cell">
<strong>${escapeHtml(playlist.name)}</strong>
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
</div>
</td>
<td>
<span class="track-count">${summary.totalPlayable}/${summary.spotifyTotal}</span>
<div class="meta-text">${summary.completionPct}% playable</div>
</td>
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
<td class="row-controls">
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button>
<div class="row-actions-wrap">
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="${isMenuOpen ? "true" : "false"}"
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
<div class="row-actions-menu ${isMenuOpen ? "open" : ""}" id="${menuId}" data-menu-key="${escapedDetailsKey}" role="menu">
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button>
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button>
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button>
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button>
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button>
<hr>
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button>
</div>
</div>
</td>
</tr>
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
<td colspan="4">
<div class="details-panel">
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">Sync Schedule</span>
<span class="detail-value mono">
${escapeHtml(syncSchedule)}
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button>
</span>
</div>
<div class="detail-item">
<span class="detail-label">Cache Age</span>
<span class="detail-value">${escapeHtml(playlist.cacheAge || "-")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Track Breakdown</span>
<span class="detail-value">${breakdownBadges.join(" ")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Completion</span>
<div class="completion-bar">
<div class="completion-fill ${summary.completionClass}" style="width:${Math.max(0, Math.min(summary.completionPct, 100))}%;"></div>
</div>
</div>
</div>
</div>
</td>
</tr>
`;
}
function createPlaylistRowPair(playlist, index) {
const template = document.createElement("template");
template.innerHTML = renderPlaylistRowPairMarkup(playlist, index).trim();
const [mainRow, detailsRow] = template.content.querySelectorAll("tr");
return { mainRow, detailsRow };
}
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.toggleRowMenu = toggleRowMenu; window.toggleRowMenu = toggleRowMenu;
window.closeRowMenu = closeRowMenu; window.closeRowMenu = closeRowMenu;
@@ -235,9 +528,6 @@ bindRowMenuHandlers();
bindTableRowHandlers(); bindTableRowHandlers();
export function updateStatusUI(data) { export function updateStatusUI(data) {
const versionEl = document.getElementById("version");
if (versionEl) versionEl.textContent = "v" + data.version;
const sidebarVersionEl = document.getElementById("sidebar-version"); const sidebarVersionEl = document.getElementById("sidebar-version");
if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version; if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version;
@@ -321,10 +611,15 @@ export function updateStatusUI(data) {
export function updatePlaylistsUI(data) { export function updatePlaylistsUI(data) {
const tbody = document.getElementById("playlist-table-body"); const tbody = document.getElementById("playlist-table-body");
if (!tbody) {
return;
}
const playlists = data.playlists || []; const playlists = data.playlists || [];
if (playlists.length === 0) { if (playlists.length === 0) {
expandedInjectedPlaylistDetails.clear(); expandedInjectedPlaylistDetails.clear();
openInjectedPlaylistMenuKey = null;
tbody.innerHTML = tbody.innerHTML =
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>'; '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
renderGuidance("playlists-guidance", [ renderGuidance("playlists-guidance", [
@@ -378,91 +673,68 @@ export function updatePlaylistsUI(data) {
}); });
renderGuidance("playlists-guidance", guidance); renderGuidance("playlists-guidance", guidance);
tbody.innerHTML = playlists const existingPairs = new Map();
.map((playlist, index) => { Array.from(
const summary = getPlaylistStatusSummary(playlist); tbody.querySelectorAll("tr.compact-row[data-details-key]"),
const detailsRowId = `playlist-details-${index}`; ).forEach((mainRow) => {
const menuId = `playlist-menu-${index}`; const detailsKey = mainRow.getAttribute("data-details-key");
const detailsKey = `${playlist.id || playlist.name || index}`; if (!detailsKey || existingPairs.has(detailsKey)) {
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey); return;
const syncSchedule = playlist.syncSchedule || "0 8 * * *"; }
const escapedPlaylistName = escapeHtml(playlist.name);
const escapedSyncSchedule = escapeHtml(syncSchedule);
const escapedDetailsKey = escapeHtml(detailsKey);
const breakdownBadges = [ const detailsRowId = mainRow.getAttribute("data-details-row");
`<span class="status-pill neutral">${summary.localCount} Local</span>`, const detailsRow =
`<span class="status-pill info">${summary.externalMatched} External</span>`, (detailsRowId && document.getElementById(detailsRowId)) ||
]; mainRow.nextElementSibling;
if (!detailsRow) {
return;
}
if (summary.externalMissing > 0) { existingPairs.set(detailsKey, { mainRow, detailsRow });
breakdownBadges.push( });
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
);
}
return ` const orderedRows = [];
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}"> playlists.forEach((playlist, index) => {
<td> const detailsKey = `${playlist.id || playlist.name || index}`;
<div class="name-cell"> const { mainRow: nextMainRow, detailsRow: nextDetailsRow } =
<strong>${escapeHtml(playlist.name)}</strong> createPlaylistRowPair(playlist, index);
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span> const existingPair = existingPairs.get(detailsKey);
</div>
</td> if (!existingPair) {
<td> orderedRows.push(nextMainRow, nextDetailsRow);
<span class="track-count">${summary.totalPlayable}/${summary.spotifyTotal}</span> return;
<div class="meta-text">${summary.completionPct}% playable</div> }
</td>
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td> syncPlaylistMainRow(
<td class="row-controls"> existingPair.mainRow,
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button> nextMainRow,
<div class="row-actions-wrap"> detailsKey === openInjectedPlaylistMenuKey,
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false" );
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button> syncPlaylistDetailsRow(existingPair.detailsRow, nextDetailsRow);
<div class="row-actions-menu" id="${menuId}" role="menu">
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button> orderedRows.push(existingPair.mainRow, existingPair.detailsRow);
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button> existingPairs.delete(detailsKey);
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button> });
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button>
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button> const activeRows = new Set(orderedRows);
<hr> orderedRows.forEach((row) => {
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button> tbody.append(row);
</div> });
</div> Array.from(tbody.children).forEach((row) => {
</td> if (!activeRows.has(row)) {
</tr> row.remove();
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}> }
<td colspan="4"> });
<div class="details-panel">
<div class="details-grid"> if (
<div class="detail-item"> openInjectedPlaylistMenuKey &&
<span class="detail-label">Sync Schedule</span> !playlists.some(
<span class="detail-value mono"> (playlist, index) =>
${escapeHtml(syncSchedule)} `${playlist.id || playlist.name || index}` === openInjectedPlaylistMenuKey,
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button> )
</span> ) {
</div> openInjectedPlaylistMenuKey = null;
<div class="detail-item"> }
<span class="detail-label">Cache Age</span>
<span class="detail-value">${escapeHtml(playlist.cacheAge || "-")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Track Breakdown</span>
<span class="detail-value">${breakdownBadges.join(" ")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Completion</span>
<div class="completion-bar">
<div class="completion-fill ${summary.completionClass}" style="width:${Math.max(0, Math.min(summary.completionPct, 100))}%;"></div>
</div>
</div>
</div>
</div>
</td>
</tr>
`;
})
.join("");
} }
export function updateTrackMappingsUI(data) { export function updateTrackMappingsUI(data) {
+106 -36
View File
@@ -58,6 +58,26 @@ body {
gap: 10px; gap: 10px;
} }
.auth-checkbox {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-secondary);
font-size: 0.88rem;
margin-top: 4px;
}
.auth-checkbox input {
width: auto;
margin: 0;
}
.auth-note {
color: var(--text-secondary);
font-size: 0.8rem;
margin-top: -4px;
}
.auth-card label { .auth-card label {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.85rem; font-size: 0.85rem;
@@ -146,9 +166,9 @@ body {
} }
.container { .container {
max-width: 1200px; max-width: 1280px;
margin: 0 auto; margin: 0 auto 0 0;
padding: 20px; padding: 20px 20px 20px 8px;
} }
.app-shell { .app-shell {
@@ -182,6 +202,15 @@ body {
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }
.title-link {
color: inherit;
text-decoration: none;
}
.title-link:hover {
color: var(--accent-hover);
}
.sidebar-subtitle { .sidebar-subtitle {
margin-top: 2px; margin-top: 2px;
color: var(--text-secondary); color: var(--text-secondary);
@@ -190,6 +219,18 @@ body {
monospace; monospace;
} }
.sidebar-status {
margin-top: 12px;
}
.sidebar-status .status-badge {
display: flex;
width: 100%;
justify-content: center;
padding: 8px 12px;
border-radius: 8px;
}
.sidebar-nav { .sidebar-nav {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -232,15 +273,6 @@ body {
min-width: 0; min-width: 0;
} }
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0 16px;
border-bottom: 1px solid var(--border);
margin-bottom: 18px;
}
.top-tabs, .top-tabs,
.tabs.top-tabs { .tabs.top-tabs {
display: none !important; display: none !important;
@@ -255,21 +287,6 @@ body {
text-align: center; text-align: center;
} }
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 30px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.auth-user { .auth-user {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.85rem; font-size: 0.85rem;
@@ -283,12 +300,6 @@ h1 {
gap: 10px; gap: 10px;
} }
h1 .version {
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: normal;
}
.status-badge { .status-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -789,7 +800,8 @@ button.danger:hover {
} }
input, input,
select { select,
textarea {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text-primary); color: var(--text-primary);
@@ -799,15 +811,24 @@ select {
} }
input:focus, input:focus,
select:focus { select:focus,
textarea:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
} }
input::placeholder { input::placeholder,
textarea::placeholder {
color: var(--text-secondary); color: var(--text-secondary);
} }
textarea {
width: 100%;
resize: vertical;
line-height: 1.5;
font-family: inherit;
}
.form-row { .form-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 120px auto; grid-template-columns: 1fr 1fr 120px auto;
@@ -1040,6 +1061,10 @@ input::placeholder {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.container {
padding: 12px;
}
.app-shell { .app-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 12px; gap: 12px;
@@ -1050,6 +1075,14 @@ input::placeholder {
max-height: none; max-height: none;
} }
.report-issue-layout {
grid-template-columns: 1fr;
}
.report-preview-panel textarea {
min-height: 360px;
}
.support-badge { .support-badge {
right: 12px; right: 12px;
bottom: 12px; bottom: 12px;
@@ -1072,6 +1105,43 @@ input::placeholder {
display: block; display: block;
} }
.report-issue-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.95fr);
gap: 20px;
align-items: start;
}
.report-issue-panel,
.report-preview-panel {
display: grid;
gap: 16px;
}
.report-issue-panel .form-group,
.report-preview-panel .form-group {
margin-bottom: 0;
}
.report-issue-panel label,
.report-preview-panel label {
display: block;
margin-bottom: 6px;
color: var(--text-secondary);
}
.report-preview-panel textarea {
min-height: 520px;
font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
monospace;
font-size: 0.84rem;
}
.report-preview-help {
color: var(--text-secondary);
font-size: 0.84rem;
}
/* Utility classes to reduce inline styles in index.html */ /* Utility classes to reduce inline styles in index.html */
.hidden { .hidden {
display: none; display: none;