Compare commits

..

81 Commits

Author SHA1 Message Date
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 f8a355f97e v1.5.3: feat: completely overhaul search to much better respect the respective search orderings, treats it as fifo, and also entirely transparently proxies Syncplay endpoints and Sessions moreso to allow for syncplay to work
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-04-09 16:59:27 -04:00
joshpatra 5a97573e58 Merge branch 'beta' into dev 2026-04-09 16:55:23 -04:00
joshpatra 3cd4560406 v1.5.3-beta.1: small version bump, includes some UI updates and optimizations, and updated links, etc 2026-04-09 16:55:12 -04:00
joshpatra 993a750008 chore: version bump 2026-04-09 16:54:16 -04:00
joshpatra 6737b2e0f4 feat(ui): add funding icons for Ko-fi, GitHub Sponsors, and BMC 2026-04-09 16:50:24 -04:00
joshpatra 24811909b2 Merge branch 'beta' into dev
CI / build-and-test (push) Has been cancelled
2026-04-07 17:34:36 -04:00
joshpatra 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 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 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
77 changed files with 3801 additions and 3890 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
buy_me_a_coffee: treeman183
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+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.
**To Reproduce**
## To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
2. Click on '...'
3. Scroll down to '...'
4. See error
**Expected behavior**
## Expected behavior
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
## Additional context
**Details (please complete the following information):**
- Version [e.g. v1.1.3]
- Client [e.g. Feishin]
Add any other context, screenshots, or surrounding details here.
<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
```
</details>
<details>
<summary>Please paste your .env file REDACTED OF ALL PASSWORDS in between the tickmarks:</summary>
## .env (redacted, optional)
```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.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Problem to solve
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.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
## Alternatives 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.
## 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]
-35
View File
@@ -100,39 +100,4 @@ public class AuthHeaderHelperTests
Assert.Contains("Version=\"1.0\"", header);
Assert.Contains("Token=\"abc\"", header);
}
[Fact]
public void ExtractAccessToken_ShouldReadMediaBrowserToken()
{
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
};
Assert.Equal("abc", AuthHeaderHelper.ExtractAccessToken(headers));
}
[Fact]
public void ExtractAccessToken_ShouldReadBearerToken()
{
var headers = new HeaderDictionary
{
["Authorization"] = "Bearer xyz"
};
Assert.Equal("xyz", AuthHeaderHelper.ExtractAccessToken(headers));
}
[Fact]
public void ExtractUserId_ShouldReadMediaBrowserUserId()
{
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Feishin\", UserId=\"user-123\", Token=\"abc\""
};
Assert.Equal("user-123", AuthHeaderHelper.ExtractUserId(headers));
}
}
@@ -122,8 +122,7 @@ public class ConfigControllerAuthorizationTests
Enabled = false,
ConnectionString = "localhost:6379"
}),
redisLogger.Object,
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
redisLogger.Object);
var spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
var spotifySessionCookieService = new SpotifySessionCookieService(
Options.Create(new SpotifyApiSettings()),
@@ -1,220 +0,0 @@
using System.Net;
using System.Net.Http;
using System.Text;
using allstarr.Controllers;
using allstarr.Models.Admin;
using allstarr.Models.Settings;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using allstarr.Services.Spotify;
using allstarr.Services.SquidWTF;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
namespace allstarr.Tests;
public class DiagnosticsControllerTests
{
[Fact]
public async Task TestSquidWtfEndpoints_WithoutAdministratorSession_ReturnsForbidden()
{
var controller = CreateController(
CreateHttpContextWithSession(isAdmin: false),
_ => new HttpResponseMessage(HttpStatusCode.OK));
var result = await controller.TestSquidWtfEndpoints(CancellationToken.None);
var forbidden = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
}
[Fact]
public async Task TestSquidWtfEndpoints_ReturnsIndependentApiAndStreamingResults()
{
var controller = CreateController(
CreateHttpContextWithSession(isAdmin: true),
request =>
{
var uri = request.RequestUri!;
if (uri.Host == "node-one.example" && uri.AbsolutePath == "/search/")
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""
{"data":{"items":[{"id":227242909,"title":"Monica Lewinsky"}]}}
""",
Encoding.UTF8,
"application/json")
};
}
if (uri.Host == "node-one.example" && uri.AbsolutePath == "/track/")
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(
"""
{"data":{"manifest":"ZmFrZS1tYW5pZmVzdA=="}}
""",
Encoding.UTF8,
"application/json")
};
}
if (uri.Host == "node-two.example" && uri.AbsolutePath == "/search/")
{
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
}
if (uri.Host == "node-two.example" && uri.AbsolutePath == "/track/")
{
return new HttpResponseMessage(HttpStatusCode.GatewayTimeout);
}
throw new InvalidOperationException($"Unexpected request URI: {uri}");
});
var result = await controller.TestSquidWtfEndpoints(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsType<SquidWtfEndpointHealthResponse>(ok.Value);
Assert.Equal(2, payload.TotalRows);
var nodeOne = Assert.Single(payload.Endpoints, e => e.Host == "node-one.example");
Assert.True(nodeOne.Api.Configured);
Assert.True(nodeOne.Api.IsUp);
Assert.Equal("up", nodeOne.Api.State);
Assert.Equal(200, nodeOne.Api.StatusCode);
Assert.True(nodeOne.Streaming.Configured);
Assert.True(nodeOne.Streaming.IsUp);
Assert.Equal("up", nodeOne.Streaming.State);
Assert.Equal(200, nodeOne.Streaming.StatusCode);
var nodeTwo = Assert.Single(payload.Endpoints, e => e.Host == "node-two.example");
Assert.True(nodeTwo.Api.Configured);
Assert.False(nodeTwo.Api.IsUp);
Assert.Equal("down", nodeTwo.Api.State);
Assert.Equal(503, nodeTwo.Api.StatusCode);
Assert.True(nodeTwo.Streaming.Configured);
Assert.False(nodeTwo.Streaming.IsUp);
Assert.Equal("down", nodeTwo.Streaming.State);
Assert.Equal(504, nodeTwo.Streaming.StatusCode);
}
private static HttpContext CreateHttpContextWithSession(bool isAdmin)
{
var context = new DefaultHttpContext();
context.Connection.LocalPort = 5275;
context.Items[AdminAuthSessionService.HttpContextSessionItemKey] = new AdminAuthSession
{
SessionId = "session-id",
UserId = "user-id",
UserName = "user",
IsAdministrator = isAdmin,
JellyfinAccessToken = "token",
JellyfinServerId = "server-id",
ExpiresAtUtc = DateTime.UtcNow.AddHours(1),
LastSeenUtc = DateTime.UtcNow
};
return context;
}
private static DiagnosticsController CreateController(
HttpContext httpContext,
Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
var logger = new Mock<ILogger<DiagnosticsController>>();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var webHostEnvironment = new Mock<IWebHostEnvironment>();
webHostEnvironment.SetupGet(e => e.EnvironmentName).Returns(Environments.Development);
webHostEnvironment.SetupGet(e => e.ContentRootPath).Returns(Directory.GetCurrentDirectory());
var helperLogger = new Mock<ILogger<AdminHelperService>>();
var helperService = new AdminHelperService(
helperLogger.Object,
Options.Create(new JellyfinSettings()),
webHostEnvironment.Object);
var spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
var spotifySessionCookieService = new SpotifySessionCookieService(
Options.Create(new SpotifyApiSettings()),
helperService,
spotifyCookieLogger.Object);
var redisLogger = new Mock<ILogger<RedisCacheService>>();
var redisCache = new RedisCacheService(
Options.Create(new RedisSettings
{
Enabled = false,
ConnectionString = "localhost:6379"
}),
redisLogger.Object,
new Microsoft.Extensions.Caching.Memory.MemoryCache(
new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
var httpClientFactory = new Mock<IHttpClientFactory>();
httpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>()))
.Returns(() => new HttpClient(new StubHttpMessageHandler(responseFactory)));
var controller = new DiagnosticsController(
logger.Object,
configuration,
Options.Create(new SpotifyApiSettings()),
Options.Create(new SpotifyImportSettings()),
Options.Create(new JellyfinSettings()),
Options.Create(new DeezerSettings()),
Options.Create(new QobuzSettings()),
Options.Create(new SquidWTFSettings()),
spotifySessionCookieService,
new SquidWtfEndpointCatalog(
new List<string>
{
"https://node-one.example",
"https://node-two.example"
},
new List<string>
{
"https://node-one.example",
"https://node-two.example"
}),
redisCache,
httpClientFactory.Object)
{
ControllerContext = new ControllerContext
{
HttpContext = httpContext
}
};
return controller;
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
_responseFactory = responseFactory;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(_responseFactory(request));
}
}
}
@@ -0,0 +1,180 @@
using System.IO.Compression;
using allstarr.Controllers;
using allstarr.Models.Domain;
using allstarr.Services.Lyrics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace allstarr.Tests;
public class DownloadsControllerLyricsArchiveTests
{
[Fact]
public async Task DownloadFile_WithLyricsSidecar_ReturnsZipContainingAudioAndLrc()
{
var testRoot = CreateTestRoot();
var downloadsRoot = Path.Combine(testRoot, "downloads");
var artistDir = Path.Combine(downloadsRoot, "kept", "Artist");
var audioPath = Path.Combine(artistDir, "track.mp3");
Directory.CreateDirectory(artistDir);
await File.WriteAllTextAsync(audioPath, "audio-data");
try
{
var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: true));
var result = await controller.DownloadFile("Artist/track.mp3");
var fileResult = Assert.IsType<FileStreamResult>(result);
Assert.Equal("application/zip", fileResult.ContentType);
Assert.Equal("track.zip", fileResult.FileDownloadName);
var entries = ReadArchiveEntries(fileResult.FileStream);
Assert.Contains("track.mp3", entries);
Assert.Contains("track.lrc", entries);
}
finally
{
DeleteTestRoot(testRoot);
}
}
[Fact]
public async Task DownloadAllFiles_BackfillsLyricsSidecarsIntoArchive()
{
var testRoot = CreateTestRoot();
var downloadsRoot = Path.Combine(testRoot, "downloads");
var artistDir = Path.Combine(downloadsRoot, "kept", "Artist", "Album");
var audioPath = Path.Combine(artistDir, "01 - track.mp3");
Directory.CreateDirectory(artistDir);
await File.WriteAllTextAsync(audioPath, "audio-data");
try
{
var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: true));
var result = await controller.DownloadAllFiles();
var fileResult = Assert.IsType<FileStreamResult>(result);
Assert.Equal("application/zip", fileResult.ContentType);
var entries = ReadArchiveEntries(fileResult.FileStream);
Assert.Contains(Path.Combine("Artist", "Album", "01 - track.mp3").Replace('\\', '/'), entries);
Assert.Contains(Path.Combine("Artist", "Album", "01 - track.lrc").Replace('\\', '/'), entries);
}
finally
{
DeleteTestRoot(testRoot);
}
}
[Fact]
public void DeleteDownload_RemovesAdjacentLyricsSidecar()
{
var testRoot = CreateTestRoot();
var downloadsRoot = Path.Combine(testRoot, "downloads");
var artistDir = Path.Combine(downloadsRoot, "kept", "Artist");
var audioPath = Path.Combine(artistDir, "track.mp3");
var sidecarPath = Path.Combine(artistDir, "track.lrc");
Directory.CreateDirectory(artistDir);
File.WriteAllText(audioPath, "audio-data");
File.WriteAllText(sidecarPath, "[00:00.00]lyrics");
try
{
var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: false));
var result = controller.DeleteDownload("Artist/track.mp3");
Assert.IsType<OkObjectResult>(result);
Assert.False(File.Exists(audioPath));
Assert.False(File.Exists(sidecarPath));
}
finally
{
DeleteTestRoot(testRoot);
}
}
private static DownloadsController CreateController(string downloadsRoot, IKeptLyricsSidecarService? keptLyricsSidecarService = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Library:DownloadPath"] = downloadsRoot
})
.Build();
return new DownloadsController(
NullLogger<DownloadsController>.Instance,
config,
keptLyricsSidecarService)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
}
private static HashSet<string> ReadArchiveEntries(Stream archiveStream)
{
archiveStream.Position = 0;
using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true);
return zip.Entries
.Select(entry => entry.FullName.Replace('\\', '/'))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static string CreateTestRoot()
{
var root = Path.Combine(Path.GetTempPath(), "allstarr-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
return root;
}
private static void DeleteTestRoot(string root)
{
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
}
private sealed class FakeKeptLyricsSidecarService : IKeptLyricsSidecarService
{
private readonly bool _createSidecar;
public FakeKeptLyricsSidecarService(bool createSidecar)
{
_createSidecar = createSidecar;
}
public string GetSidecarPath(string audioFilePath)
{
return Path.ChangeExtension(audioFilePath, ".lrc");
}
public Task<string?> EnsureSidecarAsync(
string audioFilePath,
Song? song = null,
string? externalProvider = null,
string? externalId = null,
CancellationToken cancellationToken = default)
{
var sidecarPath = GetSidecarPath(audioFilePath);
if (_createSidecar)
{
File.WriteAllText(sidecarPath, "[00:00.00]lyrics");
return Task.FromResult<string?>(sidecarPath);
}
return Task.FromResult<string?>(null);
}
}
}
@@ -9,7 +9,7 @@ namespace allstarr.Tests;
public class DownloadsControllerPathSecurityTests
{
[Fact]
public void DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected()
public async Task DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected()
{
var testRoot = CreateTestRoot();
var downloadsRoot = Path.Combine(testRoot, "downloads");
@@ -23,7 +23,7 @@ public class DownloadsControllerPathSecurityTests
try
{
var controller = CreateController(downloadsRoot);
var result = controller.DownloadFile("../kept-malicious/attack.mp3");
var result = await controller.DownloadFile("../kept-malicious/attack.mp3");
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode);
@@ -63,7 +63,7 @@ public class DownloadsControllerPathSecurityTests
}
[Fact]
public void DownloadFile_ValidPathInsideKeptFolder_AllowsDownload()
public async Task DownloadFile_ValidPathInsideKeptFolder_AllowsDownload()
{
var testRoot = CreateTestRoot();
var downloadsRoot = Path.Combine(testRoot, "downloads");
@@ -76,7 +76,7 @@ public class DownloadsControllerPathSecurityTests
try
{
var controller = CreateController(downloadsRoot);
var result = controller.DownloadFile("Artist/track.mp3");
var result = await controller.DownloadFile("Artist/track.mp3");
Assert.IsType<FileStreamResult>(result);
}
@@ -97,7 +97,13 @@ public class DownloadsControllerPathSecurityTests
return new DownloadsController(
NullLogger<DownloadsController>.Instance,
config);
config)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
}
private static string CreateTestRoot()
@@ -1,193 +0,0 @@
using Moq;
using Microsoft.Extensions.Logging;
using allstarr.Models.Domain;
using allstarr.Services;
using allstarr.Services.Jellyfin;
namespace allstarr.Tests;
public class ExternalArtistAppearancesServiceTests
{
private readonly Mock<IMusicMetadataService> _metadataService = new();
private readonly ExternalArtistAppearancesService _service;
public ExternalArtistAppearancesServiceTests()
{
_service = new ExternalArtistAppearancesService(
_metadataService.Object,
Mock.Of<ILogger<ExternalArtistAppearancesService>>());
}
[Fact]
public async Task GetAppearsOnAlbumsAsync_FiltersPrimaryAlbumsAndDeduplicatesTrackDerivedAlbums()
{
var artist = new Artist
{
Id = "ext-squidwtf-artist-artist-a",
Name = "Artist A"
};
_metadataService
.Setup(service => service.GetArtistAsync("squidwtf", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(artist);
_metadataService
.Setup(service => service.GetArtistAlbumsAsync("squidwtf", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Album>
{
new()
{
Id = "ext-squidwtf-album-own",
Title = "Own Album",
Artist = "Artist A",
ArtistId = artist.Id,
Year = 2024,
IsLocal = false,
ExternalProvider = "squidwtf",
ExternalId = "own"
},
new()
{
Id = "ext-squidwtf-album-feature",
Title = "Feature Album",
Artist = "Artist B",
ArtistId = "ext-squidwtf-artist-artist-b",
Year = 2023,
IsLocal = false,
ExternalProvider = "squidwtf",
ExternalId = "feature"
}
});
_metadataService
.Setup(service => service.GetArtistTracksAsync("squidwtf", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Song>
{
new()
{
AlbumId = "ext-squidwtf-album-own",
Album = "Own Album",
AlbumArtist = "Artist A",
Title = "Own Song",
Year = 2024
},
new()
{
AlbumId = "ext-squidwtf-album-feature",
Album = "Feature Album",
AlbumArtist = "Artist B",
Title = "Feature Song 1",
Year = 2023
},
new()
{
AlbumId = "ext-squidwtf-album-feature",
Album = "Feature Album",
AlbumArtist = "Artist B",
Title = "Feature Song 2",
Year = 2023
},
new()
{
AlbumId = "ext-squidwtf-album-comp",
Album = "Compilation",
AlbumArtist = "Various Artists",
Title = "Compilation Song",
Year = 2022,
CoverArtUrl = "https://example.com/cover.jpg",
TotalTracks = 10
}
});
var result = await _service.GetAppearsOnAlbumsAsync("squidwtf", "artist-a");
Assert.Collection(
result,
album =>
{
Assert.Equal("Feature Album", album.Title);
Assert.Equal("Artist B", album.Artist);
Assert.Equal("feature", album.ExternalId);
},
album =>
{
Assert.Equal("Compilation", album.Title);
Assert.Equal("Various Artists", album.Artist);
Assert.Equal("comp", album.ExternalId);
Assert.Equal(10, album.SongCount);
});
}
[Fact]
public async Task GetAppearsOnAlbumsAsync_WhenTrackDataIsUnavailable_FallsBackToNonPrimaryAlbumsFromAlbumList()
{
var artist = new Artist
{
Id = "ext-qobuz-artist-artist-a",
Name = "Artist A"
};
_metadataService
.Setup(service => service.GetArtistAsync("qobuz", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(artist);
_metadataService
.Setup(service => service.GetArtistAlbumsAsync("qobuz", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Album>
{
new()
{
Id = "ext-qobuz-album-own",
Title = "Own Album",
Artist = "Artist A",
ArtistId = artist.Id,
Year = 2024,
IsLocal = false,
ExternalProvider = "qobuz",
ExternalId = "own"
},
new()
{
Id = "ext-qobuz-album-feature",
Title = "Feature Album",
Artist = "Artist C",
ArtistId = "ext-qobuz-artist-artist-c",
Year = 2021,
IsLocal = false,
ExternalProvider = "qobuz",
ExternalId = "feature"
}
});
_metadataService
.Setup(service => service.GetArtistTracksAsync("qobuz", "artist-a", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Song>());
var result = await _service.GetAppearsOnAlbumsAsync("qobuz", "artist-a");
var album = Assert.Single(result);
Assert.Equal("Feature Album", album.Title);
Assert.Equal("Artist C", album.Artist);
Assert.Equal("feature", album.ExternalId);
}
[Fact]
public async Task GetAppearsOnAlbumsAsync_WhenArtistLookupFails_ReturnsEmpty()
{
_metadataService
.Setup(service => service.GetArtistAsync("squidwtf", "missing", It.IsAny<CancellationToken>()))
.ReturnsAsync((Artist?)null);
_metadataService
.Setup(service => service.GetArtistAlbumsAsync("squidwtf", "missing", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Album>());
_metadataService
.Setup(service => service.GetArtistTracksAsync("squidwtf", "missing", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Song>());
var result = await _service.GetAppearsOnAlbumsAsync("squidwtf", "missing");
Assert.Empty(result);
}
}
+5 -74
View File
@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection;
@@ -21,7 +20,6 @@ public class JellyfinProxyServiceTests
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
private readonly RedisCacheService _cache;
private readonly JellyfinSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor;
public JellyfinProxyServiceTests()
{
@@ -33,7 +31,7 @@ public class JellyfinProxyServiceTests
var redisSettings = new RedisSettings { Enabled = false };
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
_cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
_cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object);
_settings = new JellyfinSettings
{
@@ -47,21 +45,19 @@ public class JellyfinProxyServiceTests
};
var httpContext = new DefaultHttpContext();
_httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var userResolver = CreateUserContextResolver(_httpContextAccessor);
// Initialize cache settings for tests
var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults
var serviceProvider = serviceCollection.BuildServiceProvider();
allstarr.Services.Common.CacheExtensions.InitializeCacheSettings(serviceProvider);
CacheExtensions.InitializeCacheSettings(serviceProvider);
_service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(_settings),
_httpContextAccessor,
userResolver,
httpContextAccessor,
mockLogger.Object,
_cache);
}
@@ -97,21 +93,6 @@ public class JellyfinProxyServiceTests
Assert.Equal(500, statusCode);
}
[Fact]
public async Task GetJsonAsync_ServerErrorWithJsonBody_ReturnsParsedErrorDocument()
{
// Arrange
SetupMockResponse(HttpStatusCode.Unauthorized, "{\"Message\":\"Token expired\"}", "application/json");
// Act
var (body, statusCode) = await _service.GetJsonAsync("Items");
// Assert
Assert.NotNull(body);
Assert.Equal(401, statusCode);
Assert.Equal("Token expired", body.RootElement.GetProperty("Message").GetString());
}
[Fact]
public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth()
{
@@ -247,44 +228,6 @@ public class JellyfinProxyServiceTests
Assert.Equal("test query", searchTermValue);
}
[Fact]
public async Task SearchAsync_WithClientToken_ResolvesAndAppendsRequestUserId()
{
var requestedUris = new List<string>();
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
{
requestedUris.Add(req.RequestUri!.ToString());
if (req.RequestUri!.AbsolutePath.EndsWith("/Users/Me", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Id\":\"resolved-user\"}")
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[],\"TotalRecordCount\":0}")
};
});
var headers = new HeaderDictionary
{
["X-Emby-Token"] = "token-123"
};
await _service.SearchAsync("test query", new[] { "Audio" }, 25, clientHeaders: headers);
Assert.Contains(requestedUris, uri => uri.EndsWith("/Users/Me"));
Assert.Contains(requestedUris, uri => uri.Contains("userId=resolved-user", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GetItemAsync_RequestsCorrectEndpoint()
{
@@ -686,14 +629,12 @@ public class JellyfinProxyServiceTests
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
var redisSettings = new RedisSettings { Enabled = false };
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
var cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
var userResolver = CreateUserContextResolver(httpContextAccessor);
var cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object);
var service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(_settings),
httpContextAccessor,
userResolver,
mockLogger.Object,
cache);
@@ -719,14 +660,4 @@ public class JellyfinProxyServiceTests
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
}
private JellyfinUserContextResolver CreateUserContextResolver(IHttpContextAccessor httpContextAccessor)
{
return new JellyfinUserContextResolver(
httpContextAccessor,
_mockHttpClientFactory.Object,
Options.Create(_settings),
new MemoryCache(new MemoryCacheOptions()),
new Mock<ILogger<JellyfinUserContextResolver>>().Object);
}
}
@@ -1,7 +1,5 @@
using System.Reflection;
using System.Text.Json;
using allstarr.Controllers;
using allstarr.Models.Jellyfin;
namespace allstarr.Tests;
@@ -10,16 +8,16 @@ public class JellyfinSearchResponseSerializationTests
[Fact]
public void SerializeSearchResponseJson_PreservesPascalCaseShape()
{
var payload = new JellyfinItemsResponse
var payload = new
{
Items =
[
Items = new[]
{
new Dictionary<string, object?>
{
["Name"] = "BTS",
["Type"] = "MusicAlbum"
}
],
},
TotalRecordCount = 1,
StartIndex = 0
};
@@ -30,64 +28,11 @@ public class JellyfinSearchResponseSerializationTests
Assert.NotNull(method);
var json = (string)method!.Invoke(null, new object?[] { payload })!;
var closedMethod = method!.MakeGenericMethod(payload.GetType());
var json = (string)closedMethod.Invoke(null, new object?[] { payload })!;
Assert.Equal(
"{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}",
json);
}
[Fact]
public void SerializeSearchResponseJson_FallsBackForMixedRuntimeShapes()
{
using var rawDoc = JsonDocument.Parse("""
{
"ServerId": "c17d351d3af24c678a6d8049c212d522",
"RunTimeTicks": 2234068710
}
""");
var payload = new JellyfinItemsResponse
{
Items =
[
new Dictionary<string, object?>
{
["Name"] = "Harleys in Hawaii",
["Type"] = "MusicAlbum",
["MediaSources"] = new Dictionary<string, object?>[]
{
new Dictionary<string, object?>
{
["RunTimeTicks"] = 2234068710L,
["MediaAttachments"] = new List<object>(),
["Formats"] = new List<string>(),
["RequiredHttpHeaders"] = new Dictionary<string, string>()
}
},
["ArtistItems"] = new List<object>
{
new Dictionary<string, object?> { ["Name"] = "Katy Perry" }
},
["RawItem"] = rawDoc.RootElement.Clone()
}
],
TotalRecordCount = 1,
StartIndex = 0
};
var method = typeof(JellyfinController).GetMethod(
"SerializeSearchResponseJson",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
var json = (string)method!.Invoke(null, new object?[] { payload })!;
Assert.Contains("\"Items\":[{", json);
Assert.Contains("\"MediaAttachments\":[]", json);
Assert.Contains("\"ArtistItems\":[{\"Name\":\"Katy Perry\"}]", json);
Assert.Contains("\"RawItem\":{\"ServerId\":\"c17d351d3af24c678a6d8049c212d522\",\"RunTimeTicks\":2234068710}", json);
Assert.Contains("\"TotalRecordCount\":1", json);
}
}
+2 -23
View File
@@ -52,17 +52,9 @@ public class JellyfinSessionManagerTests
public async Task RemoveSessionAsync_ReportsPlaybackStopButDoesNotLogoutUserSession()
{
var requestedPaths = new ConcurrentBag<string>();
var requestBodies = new ConcurrentDictionary<string, string>();
var handler = new DelegateHttpMessageHandler((request, _) =>
{
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
requestedPaths.Add(path);
if (request.Content != null)
{
requestBodies[path] = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
}
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
});
@@ -97,12 +89,6 @@ public class JellyfinSessionManagerTests
Assert.Contains("/Sessions/Capabilities/Full", requestedPaths);
Assert.Contains("/Sessions/Playing/Stopped", requestedPaths);
Assert.DoesNotContain("/Sessions/Logout", requestedPaths);
Assert.Equal(
"{\"PlayableMediaTypes\":[\"Audio\"],\"SupportedCommands\":[\"Play\",\"Playstate\",\"PlayNext\"],\"SupportsMediaControl\":true,\"SupportsPersistentIdentifier\":true,\"SupportsSync\":false}",
requestBodies["/Sessions/Capabilities/Full"]);
Assert.Equal(
"{\"ItemId\":\"item-123\",\"PositionTicks\":42}",
requestBodies["/Sessions/Playing/Stopped"]);
}
[Fact]
@@ -196,19 +182,12 @@ public class JellyfinSessionManagerTests
var cache = new RedisCacheService(
Options.Create(new RedisSettings { Enabled = false }),
NullLogger<RedisCacheService>.Instance,
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
NullLogger<RedisCacheService>.Instance);
return new JellyfinProxyService(
httpClientFactory,
Options.Create(settings),
httpContextAccessor,
new JellyfinUserContextResolver(
httpContextAccessor,
httpClientFactory,
Options.Create(settings),
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()),
NullLogger<JellyfinUserContextResolver>.Instance),
NullLogger<JellyfinProxyService>.Instance,
cache);
}
+1 -2
View File
@@ -1,6 +1,5 @@
using Xunit;
using Moq;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using allstarr.Services.Lyrics;
using allstarr.Services.Common;
@@ -24,7 +23,7 @@ public class LrclibServiceTests
// Create mock Redis cache
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object, new MemoryCache(new MemoryCacheOptions()));
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
_httpClient = new HttpClient
{
+16 -200
View File
@@ -1,12 +1,8 @@
using Xunit;
using Moq;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using allstarr.Models.Settings;
@@ -27,19 +23,11 @@ public class RedisCacheServiceTests
});
}
private RedisCacheService CreateService(IMemoryCache? memoryCache = null, IOptions<RedisSettings>? settings = null)
{
return new RedisCacheService(
settings ?? _settings,
_mockLogger.Object,
memoryCache ?? new MemoryCache(new MemoryCacheOptions()));
}
[Fact]
public void Constructor_InitializesWithSettings()
{
// Act
var service = CreateService();
var service = new RedisCacheService(_settings, _mockLogger.Object);
// Assert
Assert.NotNull(service);
@@ -57,7 +45,7 @@ public class RedisCacheServiceTests
});
// Act - Constructor will try to connect but should handle failure gracefully
var service = CreateService(settings: enabledSettings);
var service = new RedisCacheService(enabledSettings, _mockLogger.Object);
// Assert - Service should be created even if connection fails
Assert.NotNull(service);
@@ -67,7 +55,7 @@ public class RedisCacheServiceTests
public async Task GetStringAsync_WhenDisabled_ReturnsNull()
{
// Arrange
var service = CreateService();
var service = new RedisCacheService(_settings, _mockLogger.Object);
// Act
var result = await service.GetStringAsync("test:key");
@@ -80,7 +68,7 @@ public class RedisCacheServiceTests
public async Task GetAsync_WhenDisabled_ReturnsNull()
{
// Arrange
var service = CreateService();
var service = new RedisCacheService(_settings, _mockLogger.Object);
// Act
var result = await service.GetAsync<TestObject>("test:key");
@@ -93,7 +81,7 @@ public class RedisCacheServiceTests
public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = CreateService();
var service = new RedisCacheService(_settings, _mockLogger.Object);
// Act
var result = await service.SetStringAsync("test:key", "test value");
@@ -106,7 +94,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = CreateService();
var service = new RedisCacheService(_settings, _mockLogger.Object);
var testObj = new TestObject { Id = 1, Name = "Test" };
// Act
@@ -120,7 +108,7 @@ public class RedisCacheServiceTests
public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = CreateService();
var service = new RedisCacheService(_settings, _mockLogger.Object);
// Act
var result = await service.DeleteAsync("test:key");
@@ -133,7 +121,7 @@ public class RedisCacheServiceTests
public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
{
// Arrange
var service = CreateService();
var service = new RedisCacheService(_settings, _mockLogger.Object);
// Act
var result = await service.ExistsAsync("test:key");
@@ -146,7 +134,7 @@ public class RedisCacheServiceTests
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
{
// Arrange
var service = CreateService();
var service = new RedisCacheService(_settings, _mockLogger.Object);
// Act
var result = await service.DeleteByPatternAsync("test:*");
@@ -159,7 +147,7 @@ public class RedisCacheServiceTests
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
{
// Arrange
var service = CreateService();
var service = new RedisCacheService(_settings, _mockLogger.Object);
var expiry = TimeSpan.FromHours(1);
// Act
@@ -173,7 +161,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
{
// Arrange
var service = CreateService();
var service = new RedisCacheService(_settings, _mockLogger.Object);
var testObj = new TestObject { Id = 1, Name = "Test" };
var expiry = TimeSpan.FromDays(30);
@@ -188,14 +176,14 @@ public class RedisCacheServiceTests
public void IsEnabled_ReflectsSettings()
{
// Arrange
var disabledService = CreateService();
var disabledService = new RedisCacheService(_settings, _mockLogger.Object);
var enabledSettings = Options.Create(new RedisSettings
{
Enabled = true,
ConnectionString = "localhost:6379"
});
var enabledService = CreateService(settings: enabledSettings);
var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object);
// Assert
Assert.False(disabledService.IsEnabled);
@@ -206,7 +194,7 @@ public class RedisCacheServiceTests
public async Task GetAsync_DeserializesComplexObjects()
{
// Arrange
var service = CreateService();
var service = new RedisCacheService(_settings, _mockLogger.Object);
// Act
var result = await service.GetAsync<ComplexTestObject>("test:complex");
@@ -219,7 +207,7 @@ public class RedisCacheServiceTests
public async Task SetAsync_SerializesComplexObjects()
{
// Arrange
var service = CreateService();
var service = new RedisCacheService(_settings, _mockLogger.Object);
var complexObj = new ComplexTestObject
{
Id = 1,
@@ -250,184 +238,12 @@ public class RedisCacheServiceTests
});
// Act
var service = CreateService(settings: customSettings);
var service = new RedisCacheService(customSettings, _mockLogger.Object);
// Assert
Assert.NotNull(service);
}
[Fact]
public async Task SetStringAsync_WhenDisabled_CachesValueInMemory()
{
var service = CreateService();
var setResult = await service.SetStringAsync("test:key", "test value");
var cachedValue = await service.GetStringAsync("test:key");
Assert.False(setResult);
Assert.Equal("test value", cachedValue);
}
[Fact]
public async Task SetAsync_WhenDisabled_CachesSerializedObjectInMemory()
{
var service = CreateService();
var expected = new TestObject { Id = 42, Name = "Tiered" };
var setResult = await service.SetAsync("test:object", expected);
var cachedValue = await service.GetAsync<TestObject>("test:object");
Assert.False(setResult);
Assert.NotNull(cachedValue);
Assert.Equal(expected.Id, cachedValue.Id);
Assert.Equal(expected.Name, cachedValue.Name);
}
[Fact]
public async Task ExistsAsync_WhenValueOnlyExistsInMemory_ReturnsTrue()
{
var service = CreateService();
await service.SetStringAsync("test:key", "test value");
var exists = await service.ExistsAsync("test:key");
Assert.True(exists);
}
[Fact]
public async Task DeleteAsync_WhenValueOnlyExistsInMemory_EvictsEntry()
{
var service = CreateService();
await service.SetStringAsync("test:key", "test value");
var deleted = await service.DeleteAsync("test:key");
var cachedValue = await service.GetStringAsync("test:key");
Assert.False(deleted);
Assert.Null(cachedValue);
}
[Fact]
public async Task DeleteByPatternAsync_WhenValuesOnlyExistInMemory_RemovesMatchingEntries()
{
var service = CreateService();
await service.SetStringAsync("search:one", "1");
await service.SetStringAsync("search:two", "2");
await service.SetStringAsync("other:one", "3");
var deletedCount = await service.DeleteByPatternAsync("search:*");
Assert.Equal(2, deletedCount);
Assert.Null(await service.GetStringAsync("search:one"));
Assert.Null(await service.GetStringAsync("search:two"));
Assert.Equal("3", await service.GetStringAsync("other:one"));
}
[Fact]
public async Task SetStringAsync_ImageKeysDoNotUseMemoryCache()
{
var service = CreateService();
await service.SetStringAsync("image:test:key", "binary-ish");
var cachedValue = await service.GetStringAsync("image:test:key");
var exists = await service.ExistsAsync("image:test:key");
Assert.Null(cachedValue);
Assert.False(exists);
}
[Fact]
public async Task SetAsync_WhenSongContainsRawJellyfinMetadata_CachesSerializedValueInMemory()
{
var service = CreateService();
var songs = new List<Song> { CreateLocalSongWithRawJellyfinMetadata() };
var setResult = await service.SetAsync("test:songs:raw-jellyfin", songs);
var cachedValue = await service.GetAsync<List<Song>>("test:songs:raw-jellyfin");
Assert.False(setResult);
Assert.NotNull(cachedValue);
var roundTrippedSong = Assert.Single(cachedValue!);
Assert.True(JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTrippedSong, out var rawItem));
Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString());
var mediaSources = Assert.IsType<JsonElement>(roundTrippedSong.JellyfinMetadata!["MediaSources"]);
Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind);
Assert.Equal(2234068710L, mediaSources[0].GetProperty("RunTimeTicks").GetInt64());
}
[Fact]
public async Task SetAsync_WhenMatchedTracksContainRawJellyfinMetadata_CachesSerializedValueInMemory()
{
var service = CreateService();
var matchedTracks = new List<MatchedTrack>
{
new()
{
Position = 0,
SpotifyId = "spotify-1",
SpotifyTitle = "Track",
SpotifyArtist = "Artist",
MatchType = "fuzzy",
MatchedSong = CreateLocalSongWithRawJellyfinMetadata()
}
};
var setResult = await service.SetAsync("test:matched:raw-jellyfin", matchedTracks);
var cachedValue = await service.GetAsync<List<MatchedTrack>>("test:matched:raw-jellyfin");
Assert.False(setResult);
Assert.NotNull(cachedValue);
var roundTrippedMatch = Assert.Single(cachedValue!);
Assert.True(
JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTrippedMatch.MatchedSong, out var rawItem));
Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString());
var mediaSources =
Assert.IsType<JsonElement>(roundTrippedMatch.MatchedSong.JellyfinMetadata!["MediaSources"]);
Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind);
Assert.Equal("song-1", mediaSources[0].GetProperty("Id").GetString());
}
private static Song CreateLocalSongWithRawJellyfinMetadata()
{
var song = new Song
{
Id = "song-1",
Title = "Track",
Artist = "Artist",
Album = "Album",
IsLocal = true
};
using var doc = JsonDocument.Parse("""
{
"Id": "song-1",
"ServerId": "c17d351d3af24c678a6d8049c212d522",
"RunTimeTicks": 2234068710,
"MediaSources": [
{
"Id": "song-1",
"RunTimeTicks": 2234068710
}
]
}
""");
JellyfinItemSnapshotHelper.StoreRawItemSnapshot(song, doc.RootElement);
song.JellyfinMetadata ??= new Dictionary<string, object?>();
song.JellyfinMetadata["MediaSources"] =
JsonSerializer.Deserialize<object>(doc.RootElement.GetProperty("MediaSources").GetRawText());
return song;
}
private class TestObject
{
public int Id { get; set; }
+25
View File
@@ -157,6 +157,31 @@ public class SpotifyApiClientTests
Assert.Equal(new DateTime(2026, 2, 16, 5, 0, 0, DateTimeKind.Utc), track.AddedAt);
}
[Fact]
public void TryGetSpotifyPlaylistItemCount_ParsesAttributesArrayEntries()
{
// Arrange
using var doc = JsonDocument.Parse("""
{
"attributes": [
{ "key": "core:item_count", "value": "42" }
]
}
""");
var method = typeof(SpotifyApiClient).GetMethod(
"TryGetSpotifyPlaylistItemCount",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act
var result = (int)method!.Invoke(null, new object?[] { doc.RootElement })!;
// Assert
Assert.Equal(42, result);
}
private static T InvokePrivateMethod<T>(object instance, string methodName, params object?[] args)
{
var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
+1 -2
View File
@@ -2,7 +2,6 @@ using Xunit;
using Moq;
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using allstarr.Services.Spotify;
@@ -31,7 +30,7 @@ public class SpotifyMappingServiceTests
ConnectionString = "localhost:6379"
});
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object);
_service = new SpotifyMappingService(_cache, _mockLogger.Object);
}
@@ -1,90 +0,0 @@
using allstarr.Models.Settings;
using allstarr.Services.Common;
namespace allstarr.Tests;
public class SpotifyPlaylistScopeResolverTests
{
[Fact]
public void ResolveConfig_PrefersExactJellyfinIdOverDuplicatePlaylistName()
{
var settings = new SpotifyImportSettings
{
Playlists =
{
new SpotifyPlaylistConfig
{
Name = "Discover Weekly",
Id = "spotify-a",
JellyfinId = "jellyfin-a",
UserId = "user-a"
},
new SpotifyPlaylistConfig
{
Name = "Discover Weekly",
Id = "spotify-b",
JellyfinId = "jellyfin-b",
UserId = "user-b"
}
}
};
var resolved = SpotifyPlaylistScopeResolver.ResolveConfig(
settings,
"Discover Weekly",
userId: "user-a",
jellyfinPlaylistId: "jellyfin-b");
Assert.NotNull(resolved);
Assert.Equal("spotify-b", resolved!.Id);
Assert.Equal("jellyfin-b", resolved.JellyfinId);
Assert.Equal("user-b", resolved.UserId);
}
[Fact]
public void GetUserId_PrefersConfiguredValueAndTrimsFallback()
{
var configured = new SpotifyPlaylistConfig
{
UserId = " configured-user "
};
Assert.Equal("configured-user", SpotifyPlaylistScopeResolver.GetUserId(configured, "fallback"));
Assert.Equal("fallback-user", SpotifyPlaylistScopeResolver.GetUserId(null, " fallback-user "));
Assert.Null(SpotifyPlaylistScopeResolver.GetUserId(null, " "));
}
[Fact]
public void GetUserId_DoesNotApplyRequestFallbackToGlobalConfiguredPlaylist()
{
var globalConfiguredPlaylist = new SpotifyPlaylistConfig
{
Name = "On Repeat",
JellyfinId = "7c2b218bd69b00e24c986363ba71852f"
};
Assert.Null(SpotifyPlaylistScopeResolver.GetUserId(
globalConfiguredPlaylist,
"1635cd7d23144ba08251ebe22a56119e"));
}
[Fact]
public void GetScopeId_PrefersJellyfinIdThenSpotifyIdThenFallback()
{
var jellyfinScoped = new SpotifyPlaylistConfig
{
Id = "spotify-id",
JellyfinId = " jellyfin-id "
};
var spotifyScoped = new SpotifyPlaylistConfig
{
Id = " spotify-id "
};
Assert.Equal("jellyfin-id", SpotifyPlaylistScopeResolver.GetScopeId(jellyfinScoped, "fallback"));
Assert.Equal("spotify-id", SpotifyPlaylistScopeResolver.GetScopeId(spotifyScoped, "fallback"));
Assert.Equal("fallback-id", SpotifyPlaylistScopeResolver.GetScopeId(null, " fallback-id "));
Assert.Null(SpotifyPlaylistScopeResolver.GetScopeId(null, " "));
}
}
@@ -85,8 +85,7 @@ public class SquidWTFDownloadServiceTests : IDisposable
var cache = new RedisCacheService(
Options.Create(new RedisSettings { Enabled = false }),
_redisLoggerMock.Object,
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
_redisLoggerMock.Object);
var odesliService = new OdesliService(_httpClientFactoryMock.Object, _odesliLoggerMock.Object, cache);
@@ -1,6 +1,5 @@
using Xunit;
using Moq;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using allstarr.Services.SquidWTF;
@@ -43,10 +42,7 @@ public class SquidWTFMetadataServiceTests
// Create mock Redis cache
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
_mockCache = new Mock<RedisCacheService>(
mockRedisSettings,
mockRedisLogger.Object,
new MemoryCache(new MemoryCacheOptions()));
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
_apiUrls = new List<string>
{
+1 -1
View File
@@ -9,5 +9,5 @@ public static class AppVersion
/// <summary>
/// Current application version.
/// </summary>
public const string Version = "1.4.7";
public const string Version = "1.5.3";
}
+5 -1
View File
@@ -114,7 +114,8 @@ public class AdminAuthController : ControllerBase
userName: userName,
isAdministrator: isAdministrator,
jellyfinAccessToken: accessToken,
jellyfinServerId: serverId);
jellyfinServerId: serverId,
isPersistent: request.RememberMe);
SetSessionCookie(session.SessionId, session.ExpiresAtUtc);
@@ -130,6 +131,7 @@ public class AdminAuthController : ControllerBase
name = session.UserName,
isAdministrator = session.IsAdministrator
},
rememberMe = session.IsPersistent,
expiresAtUtc = session.ExpiresAtUtc
});
}
@@ -159,6 +161,7 @@ public class AdminAuthController : ControllerBase
name = session.UserName,
isAdministrator = session.IsAdministrator
},
rememberMe = session.IsPersistent,
expiresAtUtc = session.ExpiresAtUtc
});
}
@@ -196,6 +199,7 @@ public class AdminAuthController : ControllerBase
{
public string? Username { get; set; }
public string? Password { get; set; }
public bool RememberMe { get; set; }
}
private sealed class JellyfinAuthenticateRequest
+4 -4
View File
@@ -637,11 +637,11 @@ public class ConfigController : ControllerBase
{
var keysToDelete = new[]
{
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
$"spotify:matched:{playlist.Name}", // Legacy key
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name)
};
foreach (var key in keysToDelete)
+1 -293
View File
@@ -9,9 +9,7 @@ using allstarr.Services.Admin;
using allstarr.Services.Spotify;
using allstarr.Services.Scrobbling;
using allstarr.Services.SquidWTF;
using System.Diagnostics;
using System.Runtime;
using System.Text.Json;
namespace allstarr.Controllers;
@@ -20,9 +18,6 @@ namespace allstarr.Controllers;
[ServiceFilter(typeof(AdminPortFilter))]
public class DiagnosticsController : ControllerBase
{
private const string SquidWtfProbeSearchQuery = "22 Taylor Swift";
private const string SquidWtfProbeTrackId = "227242909";
private const string SquidWtfProbeQuality = "LOW";
private readonly ILogger<DiagnosticsController> _logger;
private readonly IConfiguration _configuration;
private readonly SpotifyApiSettings _spotifyApiSettings;
@@ -34,8 +29,6 @@ public class DiagnosticsController : ControllerBase
private readonly RedisCacheService _cache;
private readonly SpotifySessionCookieService _spotifySessionCookieService;
private readonly List<string> _squidWtfApiUrls;
private readonly List<string> _squidWtfStreamingUrls;
private readonly IHttpClientFactory _httpClientFactory;
private static int _urlIndex = 0;
private static readonly object _urlIndexLock = new();
@@ -50,8 +43,7 @@ public class DiagnosticsController : ControllerBase
IOptions<SquidWTFSettings> squidWtfSettings,
SpotifySessionCookieService spotifySessionCookieService,
SquidWtfEndpointCatalog squidWtfEndpointCatalog,
RedisCacheService cache,
IHttpClientFactory httpClientFactory)
RedisCacheService cache)
{
_logger = logger;
_configuration = configuration;
@@ -64,8 +56,6 @@ public class DiagnosticsController : ControllerBase
_spotifySessionCookieService = spotifySessionCookieService;
_cache = cache;
_squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls;
_squidWtfStreamingUrls = squidWtfEndpointCatalog.StreamingUrls;
_httpClientFactory = httpClientFactory;
}
[HttpGet("status")]
@@ -171,61 +161,6 @@ public class DiagnosticsController : ControllerBase
return Ok(new { baseUrl });
}
[HttpPost("squidwtf/endpoints/test")]
public async Task<IActionResult> TestSquidWtfEndpoints(CancellationToken cancellationToken)
{
var forbidden = RequireAdministratorForSensitiveOperation("squidwtf endpoint diagnostics");
if (forbidden != null)
{
return forbidden;
}
try
{
var rows = BuildSquidWtfEndpointRows();
_logger.LogInformation(
"Starting SquidWTF endpoint diagnostics for {RowCount} hosts ({ApiCount} API URLs, {StreamingCount} streaming URLs)",
rows.Count,
_squidWtfApiUrls.Count,
_squidWtfStreamingUrls.Count);
var probeTasks = rows.Select(row => PopulateProbeResultsAsync(row, cancellationToken));
await Task.WhenAll(probeTasks);
var apiUpCount = rows.Count(row => row.Api.Configured && row.Api.IsUp);
var streamingUpCount = rows.Count(row => row.Streaming.Configured && row.Streaming.IsUp);
_logger.LogInformation(
"Completed SquidWTF endpoint diagnostics: API up {ApiUp}/{ApiConfigured}, streaming up {StreamingUp}/{StreamingConfigured}",
apiUpCount,
rows.Count(row => row.Api.Configured),
streamingUpCount,
rows.Count(row => row.Streaming.Configured));
var response = new SquidWtfEndpointHealthResponse
{
TestedAtUtc = DateTime.UtcNow,
TotalRows = rows.Count,
Endpoints = rows
.OrderBy(r => r.Host, StringComparer.OrdinalIgnoreCase)
.ToList()
};
return Ok(response);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test SquidWTF endpoints");
return StatusCode(StatusCodes.Status500InternalServerError, new
{
error = "Failed to test SquidWTF endpoints"
});
}
}
/// <summary>
/// Get current configuration including cache settings
/// </summary>
@@ -488,233 +423,6 @@ public class DiagnosticsController : ControllerBase
}
}
private IActionResult? RequireAdministratorForSensitiveOperation(string operationName)
{
if (HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) &&
sessionObj is AdminAuthSession session &&
session.IsAdministrator)
{
return null;
}
_logger.LogWarning("Blocked sensitive admin operation '{Operation}' due to missing administrator session", operationName);
return StatusCode(StatusCodes.Status403Forbidden, new
{
error = "Administrator permissions required",
message = "This operation is restricted to Jellyfin administrators."
});
}
private List<SquidWtfEndpointHealthRow> BuildSquidWtfEndpointRows()
{
var rows = new Dictionary<string, SquidWtfEndpointHealthRow>(StringComparer.OrdinalIgnoreCase);
foreach (var apiUrl in _squidWtfApiUrls)
{
var key = GetEndpointKey(apiUrl);
if (!rows.TryGetValue(key, out var row))
{
row = new SquidWtfEndpointHealthRow
{
Host = key
};
rows[key] = row;
}
row.ApiUrl = apiUrl;
}
foreach (var streamingUrl in _squidWtfStreamingUrls)
{
var key = GetEndpointKey(streamingUrl);
if (!rows.TryGetValue(key, out var row))
{
row = new SquidWtfEndpointHealthRow
{
Host = key
};
rows[key] = row;
}
row.StreamingUrl = streamingUrl;
}
return rows.Values.ToList();
}
private async Task PopulateProbeResultsAsync(SquidWtfEndpointHealthRow row, CancellationToken cancellationToken)
{
var apiTask = ProbeApiEndpointAsync(row.ApiUrl, cancellationToken);
var streamingTask = ProbeStreamingEndpointAsync(row.StreamingUrl, cancellationToken);
await Task.WhenAll(apiTask, streamingTask);
row.Api = await apiTask;
row.Streaming = await streamingTask;
var anyFailure = (row.Api.Configured && !row.Api.IsUp) ||
(row.Streaming.Configured && !row.Streaming.IsUp);
_logger.Log(
anyFailure ? LogLevel.Warning : LogLevel.Information,
"SquidWTF probe {Host}: API {ApiState} ({ApiStatusCode}, {ApiLatencyMs}ms{ApiErrorSuffix}) | streaming {StreamingState} ({StreamingStatusCode}, {StreamingLatencyMs}ms{StreamingErrorSuffix})",
row.Host,
row.Api.State,
row.Api.StatusCode?.ToString() ?? "n/a",
row.Api.LatencyMs?.ToString() ?? "n/a",
string.IsNullOrWhiteSpace(row.Api.Error) ? string.Empty : $", {row.Api.Error}",
row.Streaming.State,
row.Streaming.StatusCode?.ToString() ?? "n/a",
row.Streaming.LatencyMs?.ToString() ?? "n/a",
string.IsNullOrWhiteSpace(row.Streaming.Error) ? string.Empty : $", {row.Streaming.Error}");
}
private async Task<SquidWtfEndpointProbeResult> ProbeApiEndpointAsync(string? baseUrl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
return new SquidWtfEndpointProbeResult
{
Configured = false,
State = "missing",
Error = "No API URL configured"
};
}
var requestUrl = $"{baseUrl}/search/?s={Uri.EscapeDataString(SquidWtfProbeSearchQuery)}&limit=1&offset=0";
return await ProbeEndpointAsync(
requestUrl,
response => ResponseContainsSearchItemsAsync(response, cancellationToken),
cancellationToken);
}
private async Task<SquidWtfEndpointProbeResult> ProbeStreamingEndpointAsync(string? baseUrl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
return new SquidWtfEndpointProbeResult
{
Configured = false,
State = "missing",
Error = "No streaming URL configured"
};
}
var requestUrl = $"{baseUrl}/track/?id={Uri.EscapeDataString(SquidWtfProbeTrackId)}&quality={Uri.EscapeDataString(SquidWtfProbeQuality)}";
return await ProbeEndpointAsync(
requestUrl,
response => ResponseContainsTrackManifestAsync(response, cancellationToken),
cancellationToken);
}
private async Task<SquidWtfEndpointProbeResult> ProbeEndpointAsync(
string requestUrl,
Func<HttpResponseMessage, Task<bool>> isHealthyResponse,
CancellationToken cancellationToken)
{
using var client = CreateDiagnosticsHttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
var stopwatch = Stopwatch.StartNew();
try
{
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
stopwatch.Stop();
var isHealthy = response.IsSuccessStatusCode && await isHealthyResponse(response);
return new SquidWtfEndpointProbeResult
{
Configured = true,
IsUp = isHealthy,
State = isHealthy ? "up" : "down",
StatusCode = (int)response.StatusCode,
LatencyMs = stopwatch.ElapsedMilliseconds,
RequestUrl = requestUrl,
Error = isHealthy ? null : $"Unexpected {(int)response.StatusCode} response"
};
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
stopwatch.Stop();
return new SquidWtfEndpointProbeResult
{
Configured = true,
State = "timeout",
LatencyMs = stopwatch.ElapsedMilliseconds,
RequestUrl = requestUrl,
Error = "Timed out"
};
}
catch (HttpRequestException ex)
{
stopwatch.Stop();
return new SquidWtfEndpointProbeResult
{
Configured = true,
State = "down",
StatusCode = ex.StatusCode.HasValue ? (int)ex.StatusCode.Value : null,
LatencyMs = stopwatch.ElapsedMilliseconds,
RequestUrl = requestUrl,
Error = ex.Message
};
}
catch (Exception ex)
{
stopwatch.Stop();
return new SquidWtfEndpointProbeResult
{
Configured = true,
State = "down",
LatencyMs = stopwatch.ElapsedMilliseconds,
RequestUrl = requestUrl,
Error = ex.Message
};
}
}
private HttpClient CreateDiagnosticsHttpClient()
{
var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
if (!client.DefaultRequestHeaders.UserAgent.Any())
{
client.DefaultRequestHeaders.UserAgent.ParseAdd("allstarr-admin-diagnostics/1.0");
}
return client;
}
private static async Task<bool> ResponseContainsSearchItemsAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
return document.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items) &&
items.ValueKind == JsonValueKind.Array;
}
private static async Task<bool> ResponseContainsTrackManifestAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
return document.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("manifest", out var manifest) &&
manifest.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(manifest.GetString());
}
private static string GetEndpointKey(string url)
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}";
}
return url.Trim();
}
/// <summary>
+136 -13
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using allstarr.Filters;
using allstarr.Services.Admin;
using allstarr.Services.Lyrics;
namespace allstarr.Controllers;
@@ -9,15 +10,20 @@ namespace allstarr.Controllers;
[ServiceFilter(typeof(AdminPortFilter))]
public class DownloadsController : ControllerBase
{
private static readonly string[] AudioExtensions = [".flac", ".mp3", ".m4a", ".opus"];
private readonly ILogger<DownloadsController> _logger;
private readonly IConfiguration _configuration;
private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService;
public DownloadsController(
ILogger<DownloadsController> logger,
IConfiguration configuration)
IConfiguration configuration,
IKeptLyricsSidecarService? keptLyricsSidecarService = null)
{
_logger = logger;
_configuration = configuration;
_keptLyricsSidecarService = keptLyricsSidecarService;
}
[HttpGet("downloads")]
@@ -36,10 +42,8 @@ public class DownloadsController : ControllerBase
long totalSize = 0;
// Recursively get all audio files from kept folder
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.Where(IsSupportedAudioFile)
.ToList();
foreach (var filePath in allFiles)
@@ -112,6 +116,11 @@ public class DownloadsController : ControllerBase
}
System.IO.File.Delete(fullPath);
var sidecarPath = _keptLyricsSidecarService?.GetSidecarPath(fullPath) ?? Path.ChangeExtension(fullPath, ".lrc");
if (System.IO.File.Exists(sidecarPath))
{
System.IO.File.Delete(sidecarPath);
}
// Clean up empty directories (Album folder, then Artist folder if empty)
var directory = Path.GetDirectoryName(fullPath);
@@ -139,12 +148,67 @@ public class DownloadsController : ControllerBase
}
}
/// <summary>
/// DELETE /api/admin/downloads/all
/// Deletes all kept audio files and removes empty folders
/// </summary>
[HttpDelete("downloads/all")]
public IActionResult DeleteAllDownloads()
{
try
{
var keptPath = Path.GetFullPath(Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"));
if (!Directory.Exists(keptPath))
{
return Ok(new { success = true, deletedCount = 0, message = "No kept downloads found" });
}
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
.Where(IsSupportedAudioFile)
.ToList();
foreach (var filePath in allFiles)
{
System.IO.File.Delete(filePath);
}
var sidecarFiles = Directory.GetFiles(keptPath, "*.lrc", SearchOption.AllDirectories);
foreach (var sidecarFile in sidecarFiles)
{
System.IO.File.Delete(sidecarFile);
}
// Clean up empty directories under kept root (deepest first)
var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories)
.OrderByDescending(d => d.Length);
foreach (var directory in allDirectories)
{
if (!Directory.EnumerateFileSystemEntries(directory).Any())
{
Directory.Delete(directory);
}
}
return Ok(new
{
success = true,
deletedCount = allFiles.Count,
message = $"Deleted {allFiles.Count} kept download(s)"
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete all kept downloads");
return StatusCode(500, new { error = "Failed to delete all kept downloads" });
}
}
/// <summary>
/// GET /api/admin/downloads/file
/// Downloads a specific file from the kept folder
/// </summary>
[HttpGet("downloads/file")]
public IActionResult DownloadFile([FromQuery] string path)
public async Task<IActionResult> DownloadFile([FromQuery] string path)
{
try
{
@@ -166,8 +230,16 @@ public class DownloadsController : ControllerBase
}
var fileName = Path.GetFileName(fullPath);
var fileStream = System.IO.File.OpenRead(fullPath);
if (IsSupportedAudioFile(fullPath))
{
var sidecarPath = await EnsureLyricsSidecarIfPossibleAsync(fullPath, HttpContext.RequestAborted);
if (System.IO.File.Exists(sidecarPath))
{
return await CreateSingleTrackArchiveAsync(fullPath, sidecarPath, fileName);
}
}
var fileStream = System.IO.File.OpenRead(fullPath);
return File(fileStream, "application/octet-stream", fileName);
}
catch (Exception ex)
@@ -182,7 +254,7 @@ public class DownloadsController : ControllerBase
/// Downloads all kept files as a zip archive
/// </summary>
[HttpGet("downloads/all")]
public IActionResult DownloadAllFiles()
public async Task<IActionResult> DownloadAllFiles()
{
try
{
@@ -193,9 +265,8 @@ public class DownloadsController : ControllerBase
return NotFound(new { error = "No kept files found" });
}
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.Where(IsSupportedAudioFile)
.ToList();
if (allFiles.Count == 0)
@@ -209,14 +280,18 @@ public class DownloadsController : ControllerBase
var memoryStream = new MemoryStream();
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
{
var addedEntries = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var filePath in allFiles)
{
var relativePath = Path.GetRelativePath(keptPath, filePath);
var entry = archive.CreateEntry(relativePath, System.IO.Compression.CompressionLevel.NoCompression);
await AddFileToArchiveAsync(archive, filePath, relativePath, addedEntries);
using var entryStream = entry.Open();
using var fileStream = System.IO.File.OpenRead(filePath);
fileStream.CopyTo(entryStream);
var sidecarPath = await EnsureLyricsSidecarIfPossibleAsync(filePath, HttpContext.RequestAborted);
if (System.IO.File.Exists(sidecarPath))
{
var sidecarRelativePath = Path.GetRelativePath(keptPath, sidecarPath);
await AddFileToArchiveAsync(archive, sidecarPath, sidecarRelativePath, addedEntries);
}
}
}
@@ -280,6 +355,54 @@ public class DownloadsController : ControllerBase
: StringComparison.Ordinal;
}
private async Task<string> EnsureLyricsSidecarIfPossibleAsync(string audioFilePath, CancellationToken cancellationToken)
{
var sidecarPath = _keptLyricsSidecarService?.GetSidecarPath(audioFilePath) ?? Path.ChangeExtension(audioFilePath, ".lrc");
if (System.IO.File.Exists(sidecarPath) || _keptLyricsSidecarService == null)
{
return sidecarPath;
}
var generatedSidecar = await _keptLyricsSidecarService.EnsureSidecarAsync(audioFilePath, cancellationToken: cancellationToken);
return generatedSidecar ?? sidecarPath;
}
private async Task<IActionResult> CreateSingleTrackArchiveAsync(string audioFilePath, string sidecarPath, string fileName)
{
var archiveStream = new MemoryStream();
using (var archive = new System.IO.Compression.ZipArchive(archiveStream, System.IO.Compression.ZipArchiveMode.Create, true))
{
await AddFileToArchiveAsync(archive, audioFilePath, Path.GetFileName(audioFilePath), null);
await AddFileToArchiveAsync(archive, sidecarPath, Path.GetFileName(sidecarPath), null);
}
archiveStream.Position = 0;
var downloadName = $"{Path.GetFileNameWithoutExtension(fileName)}.zip";
return File(archiveStream, "application/zip", downloadName);
}
private static async Task AddFileToArchiveAsync(
System.IO.Compression.ZipArchive archive,
string filePath,
string entryPath,
HashSet<string>? addedEntries)
{
if (addedEntries != null && !addedEntries.Add(entryPath))
{
return;
}
var entry = archive.CreateEntry(entryPath, System.IO.Compression.CompressionLevel.NoCompression);
await using var entryStream = entry.Open();
await using var fileStream = System.IO.File.OpenRead(filePath);
await fileStream.CopyToAsync(entryStream);
}
private static bool IsSupportedAudioFile(string path)
{
return AudioExtensions.Contains(Path.GetExtension(path).ToLowerInvariant());
}
/// <summary>
/// Gets all Spotify track mappings (paginated)
/// </summary>
+8 -38
View File
@@ -2,7 +2,6 @@ using System.Text.Json;
using System.Text;
using System.Net.Http;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
@@ -168,7 +167,7 @@ public partial class JellyfinController
if (!spotifyPlaylistCreatedDates.TryGetValue(playlistName, out var playlistCreatedDate))
{
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistConfig);
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistName);
spotifyPlaylistCreatedDates[playlistName] = playlistCreatedDate;
}
@@ -178,16 +177,7 @@ public partial class JellyfinController
}
// Get matched external tracks (tracks that were successfully downloaded/matched)
var playlistScopeUserId = string.IsNullOrWhiteSpace(playlistConfig.UserId)
? null
: playlistConfig.UserId.Trim();
var playlistScopeId = !string.IsNullOrWhiteSpace(playlistConfig.JellyfinId)
? playlistConfig.JellyfinId
: playlistConfig.Id;
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
@@ -196,10 +186,7 @@ public partial class JellyfinController
// Fallback to legacy cache format
if (matchedTracks == null || matchedTracks.Count == 0)
{
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var legacyKey = $"spotify:matched:{playlistName}";
var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
if (legacySongs != null && legacySongs.Count > 0)
{
@@ -215,10 +202,7 @@ public partial class JellyfinController
// Prefer the currently served playlist items cache when available.
// This most closely matches what the injected playlist endpoint will return.
var exactServedCount = 0;
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
var cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
var exactServedRunTimeTicks = 0L;
if (cachedPlaylistItems != null &&
@@ -247,7 +231,7 @@ public partial class JellyfinController
var localRunTimeTicks = 0L;
try
{
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
var queryParams = new Dictionary<string, string>
{
@@ -350,22 +334,11 @@ public partial class JellyfinController
}
}
private async Task<DateTime?> ResolveSpotifyPlaylistCreatedDateAsync(SpotifyPlaylistConfig playlistConfig)
private async Task<DateTime?> ResolveSpotifyPlaylistCreatedDateAsync(string playlistName)
{
var playlistName = playlistConfig.Name;
try
{
var playlistScopeUserId = string.IsNullOrWhiteSpace(playlistConfig.UserId)
? null
: playlistConfig.UserId.Trim();
var playlistScopeId = !string.IsNullOrWhiteSpace(playlistConfig.JellyfinId)
? playlistConfig.JellyfinId
: playlistConfig.Id;
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
var cachedPlaylist = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
var createdAt = GetCreatedDateFromSpotifyPlaylist(cachedPlaylist);
if (createdAt.HasValue)
@@ -378,10 +351,7 @@ public partial class JellyfinController
return null;
}
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(
playlistName,
playlistScopeUserId,
playlistConfig.JellyfinId);
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(playlistName);
var earliestTrackAddedAt = tracks
.Where(t => t.AddedAt.HasValue)
.Select(t => t.AddedAt!.Value.ToUniversalTime())
@@ -245,7 +245,9 @@ public class JellyfinAdminController : ControllerBase
/// Get all playlists from the user's Spotify account
/// </summary>
[HttpGet("jellyfin/playlists")]
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
public async Task<IActionResult> GetJellyfinPlaylists(
[FromQuery] string? userId = null,
[FromQuery] bool includeStats = true)
{
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
{
@@ -330,13 +332,13 @@ public class JellyfinAdminController : ControllerBase
var statsUserId = requestedUserId;
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
if (isConfigured)
if (isConfigured && includeStats)
{
trackStats = await GetPlaylistTrackStats(id!, session, statsUserId);
}
var actualTrackCount = isConfigured
? trackStats.LocalTracks + trackStats.ExternalTracks
? (includeStats ? trackStats.LocalTracks + trackStats.ExternalTracks : childCount)
: childCount;
playlists.Add(new
@@ -349,6 +351,7 @@ public class JellyfinAdminController : ControllerBase
isLinkedByAnotherUser,
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
allLinkedForPlaylist.FirstOrDefault()?.UserId,
statsPending = isConfigured && !includeStats,
localTracks = trackStats.LocalTracks,
externalTracks = trackStats.ExternalTracks,
externalAvailable = trackStats.ExternalAvailable
@@ -1,9 +1,7 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Globalization;
using allstarr.Models.Jellyfin;
using allstarr.Models.Scrobbling;
using allstarr.Serialization;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
@@ -228,7 +226,7 @@ public partial class JellyfinController
// Build minimal playback start with just the ghost UUID
// Don't include the Item object - Jellyfin will just track the session without item details
var playbackStart = new JellyfinPlaybackStatePayload
var playbackStart = new
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
@@ -238,7 +236,7 @@ public partial class JellyfinController
PlayMethod = "DirectPlay"
};
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
var playbackJson = JsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
// Forward to Jellyfin with ghost UUID
@@ -359,13 +357,14 @@ public partial class JellyfinController
trackName ?? "Unknown", itemId);
// Build playback start info - Jellyfin will fetch item details itself
var playbackStart = new JellyfinPlaybackStatePayload
var playbackStart = new
{
ItemId = itemId ?? string.Empty,
PositionTicks = positionTicks ?? 0
ItemId = itemId,
PositionTicks = positionTicks ?? 0,
// Let Jellyfin fetch the item details - don't include NowPlayingItem
};
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
var playbackJson = JsonSerializer.Serialize(playbackStart);
_logger.LogDebug("📤 Sending playback start: {Json}", playbackJson);
var (result, statusCode) =
@@ -625,7 +624,7 @@ public partial class JellyfinController
externalId);
var inferredStartGhostUuid = GenerateUuidFromString(itemId);
var inferredExternalStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
var inferredExternalStartPayload = JsonSerializer.Serialize(new
{
ItemId = inferredStartGhostUuid,
PositionTicks = positionTicks ?? 0,
@@ -693,7 +692,7 @@ public partial class JellyfinController
var ghostUuid = GenerateUuidFromString(itemId);
// Build progress report with ghost UUID
var progressReport = new JellyfinPlaybackStatePayload
var progressReport = new
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
@@ -703,7 +702,7 @@ public partial class JellyfinController
PlayMethod = "DirectPlay"
};
var progressJson = AllstarrJsonSerializer.Serialize(progressReport);
var progressJson = JsonSerializer.Serialize(progressReport);
// Forward to Jellyfin with ghost UUID
var (progressResult, progressStatusCode) =
@@ -774,7 +773,7 @@ public partial class JellyfinController
_logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})",
trackName ?? "Unknown", itemId);
var inferredStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
var inferredStartPayload = JsonSerializer.Serialize(new
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0
@@ -949,7 +948,7 @@ public partial class JellyfinController
}
var ghostUuid = GenerateUuidFromString(previousItemId);
var inferredExternalStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
var inferredExternalStopPayload = JsonSerializer.Serialize(new
{
ItemId = ghostUuid,
PositionTicks = previousPositionTicks ?? 0,
@@ -998,7 +997,7 @@ public partial class JellyfinController
});
}
var inferredStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
var inferredStopPayload = JsonSerializer.Serialize(new
{
ItemId = previousItemId,
PositionTicks = previousPositionTicks ?? 0,
@@ -1063,7 +1062,7 @@ public partial class JellyfinController
return;
}
var userId = await ResolvePlaybackUserIdAsync(progressPayload);
var userId = ResolvePlaybackUserId(progressPayload);
if (string.IsNullOrWhiteSpace(userId))
{
_logger.LogDebug("Skipping local played signal for {ItemId} - no user id available", itemId);
@@ -1099,7 +1098,7 @@ public partial class JellyfinController
}
}
private async Task<string?> ResolvePlaybackUserIdAsync(JsonElement progressPayload)
private string? ResolvePlaybackUserId(JsonElement progressPayload)
{
if (progressPayload.TryGetProperty("UserId", out var userIdElement) &&
userIdElement.ValueKind == JsonValueKind.String)
@@ -1117,7 +1116,7 @@ public partial class JellyfinController
return queryUserId;
}
return await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
return _settings.UserId;
}
private static int? ToPlaybackPositionSeconds(long? positionTicks)
@@ -1295,13 +1294,13 @@ public partial class JellyfinController
// Report stop to Jellyfin with ghost UUID
var ghostUuid = GenerateUuidFromString(itemId);
var externalStopInfo = new JellyfinPlaybackStatePayload
var externalStopInfo = new
{
ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0
};
var stopJson = AllstarrJsonSerializer.Serialize(externalStopInfo);
var stopJson = JsonSerializer.Serialize(externalStopInfo);
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
var (stopResult, stopStatusCode) =
@@ -1470,7 +1469,7 @@ public partial class JellyfinController
stopInfo["PositionTicks"] = positionTicks.Value;
}
body = AllstarrJsonSerializer.Serialize(stopInfo);
body = JsonSerializer.Serialize(stopInfo);
_logger.LogDebug("📤 Sending playback stop body (IsPaused=false, {BodyLength} bytes)", body.Length);
var (result, statusCode) =
+72 -301
View File
@@ -1,11 +1,8 @@
using System.Buffers;
using System.Text.Json;
using System.Text;
using allstarr.Models.Domain;
using allstarr.Models.Jellyfin;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Serialization;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
@@ -28,7 +25,6 @@ public partial class JellyfinController
[FromQuery] int startIndex = 0,
[FromQuery] string? parentId = null,
[FromQuery] string? artistIds = null,
[FromQuery] string? contributingArtistIds = null,
[FromQuery] string? albumArtistIds = null,
[FromQuery] string? albumIds = null,
[FromQuery] string? sortBy = null,
@@ -43,8 +39,8 @@ public partial class JellyfinController
var effectiveArtistIds = albumArtistIds ?? artistIds;
var favoritesOnlyRequest = IsFavoritesOnlyRequest();
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, contributingArtistIds={ContributingArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}",
searchTerm, includeItemTypes, parentId, artistIds, contributingArtistIds, albumArtistIds, albumIds, userId);
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}",
searchTerm, includeItemTypes, parentId, artistIds, albumArtistIds, albumIds, userId);
_logger.LogInformation(
"SEARCH TRACE: rawQuery='{RawQuery}', boundSearchTerm='{BoundSearchTerm}', effectiveSearchTerm='{EffectiveSearchTerm}', includeItemTypes='{IncludeItemTypes}'",
Request.QueryString.Value ?? string.Empty,
@@ -55,34 +51,15 @@ public partial class JellyfinController
// ============================================================================
// REQUEST ROUTING LOGIC (Priority Order)
// ============================================================================
// 1. ContributingArtistIds present (external) → Handle external "appears on" albums
// 2. ArtistIds present (external) → Handle external artists (even with ParentId)
// 3. AlbumIds present (external) → Handle external albums (even with ParentId)
// 4. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
// 5. ArtistIds / ContributingArtistIds present (library) → Proxy to Jellyfin with full filter
// 6. SearchTerm present → Integrated search (Jellyfin + external sources)
// 7. Otherwise → Proxy browse request transparently to Jellyfin
// 1. ArtistIds present (external) → Handle external artists (even with ParentId)
// 2. AlbumIds present (external) → Handle external albums (even with ParentId)
// 3. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
// 4. ArtistIds present (library) → Proxy to Jellyfin with artist filter
// 5. SearchTerm present → Integrated search (Jellyfin + external sources)
// 6. Otherwise → Proxy browse request transparently to Jellyfin
// ============================================================================
// PRIORITY 1: External contributing artist filter - used by Jellyfin's "Appears on" album requests.
if (!string.IsNullOrWhiteSpace(contributingArtistIds))
{
var contributingArtistId = contributingArtistIds.Split(',')[0];
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(contributingArtistId);
if (isExternal)
{
_logger.LogDebug(
"Fetching contributing artist albums for external artist: {Provider}/{ExternalId}, type={Type}",
provider,
externalId,
type);
return await GetExternalContributorChildItems(provider!, type!, externalId!, includeItemTypes, HttpContext.RequestAborted);
}
// If library artist, fall through to proxy
}
// PRIORITY 2: External artist filter - takes precedence over everything (including ParentId)
// PRIORITY 1: External artist filter - takes precedence over everything (including ParentId)
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
{
var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple
@@ -110,7 +87,7 @@ public partial class JellyfinController
// If library artist, fall through to handle with ParentId or proxy
}
// PRIORITY 3: External album filter
// PRIORITY 2: External album filter
if (!string.IsNullOrWhiteSpace(albumIds))
{
var albumId = albumIds.Split(',')[0]; // Take first album if multiple
@@ -130,17 +107,23 @@ public partial class JellyfinController
var album = await _metadataService.GetAlbumAsync(provider!, externalId!, HttpContext.RequestAborted);
if (album == null)
{
return CreateItemsResponse([], 0, startIndex);
return new JsonResult(new
{ Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
}
var albumItems = album.Songs.Select(song => _responseBuilder.ConvertSongToJellyfinItem(song)).ToList();
return CreateItemsResponse(albumItems, albumItems.Count, startIndex);
return new JsonResult(new
{
Items = albumItems,
TotalRecordCount = albumItems.Count,
StartIndex = startIndex
});
}
// If library album, fall through to handle with ParentId or proxy
}
// PRIORITY 4: ParentId present - check if external first
// PRIORITY 3: ParentId present - check if external first
if (!string.IsNullOrWhiteSpace(parentId))
{
// Check if this is an external playlist
@@ -182,17 +165,7 @@ public partial class JellyfinController
}
}
// PRIORITY 5: Library artist/contributing-artist filters (already checked for external above)
if (!string.IsNullOrWhiteSpace(contributingArtistIds))
{
_logger.LogDebug("Library contributing artist filter requested, proxying to Jellyfin");
var endpoint = userId != null
? $"Users/{userId}/Items{Request.QueryString}"
: $"Items{Request.QueryString}";
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
}
// PRIORITY 4: Library artist filter (already checked for external above)
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
{
// Library artist - proxy transparently with full query string
@@ -204,7 +177,7 @@ public partial class JellyfinController
return HandleProxyResponse(result, statusCode);
}
// PRIORITY 6: Search term present - do integrated search (Jellyfin + external)
// PRIORITY 5: Search term present - do integrated search (Jellyfin + external)
if (!string.IsNullOrWhiteSpace(searchTerm))
{
// Check cache for search results (only cache pure searches, not filtered searches)
@@ -232,7 +205,7 @@ public partial class JellyfinController
// Fall through to integrated search below
}
// PRIORITY 7: No filters, no search - proxy browse request transparently
// PRIORITY 6: No filters, no search - proxy browse request transparently
else
{
_logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string");
@@ -573,20 +546,44 @@ public partial class JellyfinController
try
{
var response = new JellyfinItemsResponse
// Return with PascalCase - use ContentResult to bypass JSON serialization issues
var response = new
{
Items = pagedItems,
TotalRecordCount = items.Count,
StartIndex = startIndex
};
return await WriteSearchItemsResponseAsync(
response,
searchTerm,
effectiveArtistIds,
searchCacheKey,
externalHasRequestedTypeResults,
cleanQuery,
includeItemTypes);
var json = SerializeSearchResponseJson(response);
// Cache search results in Redis using the configured search TTL.
if (!string.IsNullOrWhiteSpace(searchTerm) &&
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
!string.IsNullOrWhiteSpace(searchCacheKey))
{
if (externalHasRequestedTypeResults)
{
await _cache.SetStringAsync(searchCacheKey, json, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
CacheExtensions.SearchResultsTTL.TotalMinutes);
}
else
{
_logger.LogInformation(
"SEARCH TRACE: skipped cache write for query '{Query}' because requested external result buckets were empty (types={ItemTypes})",
cleanQuery,
includeItemTypes ?? string.Empty);
}
}
_logger.LogDebug("About to serialize response...");
if (_logger.IsEnabled(LogLevel.Debug))
{
var preview = json.Length > 200 ? json[..200] : json;
_logger.LogDebug("JSON response preview: {Json}", preview);
}
return Content(json, "application/json");
}
catch (Exception ex)
{
@@ -595,47 +592,13 @@ public partial class JellyfinController
}
}
private static string SerializeSearchResponseJson(JellyfinItemsResponse response)
private static string SerializeSearchResponseJson<T>(T response) where T : class
{
return AllstarrJsonSerializer.Serialize(response);
}
private async Task<IActionResult> WriteSearchItemsResponseAsync(
JellyfinItemsResponse response,
string? searchTerm,
string? effectiveArtistIds,
string? searchCacheKey,
bool externalHasRequestedTypeResults,
string cleanQuery,
string? includeItemTypes)
{
var shouldCache = !string.IsNullOrWhiteSpace(searchTerm) &&
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
!string.IsNullOrWhiteSpace(searchCacheKey) &&
externalHasRequestedTypeResults;
Response.StatusCode = StatusCodes.Status200OK;
Response.ContentType = "application/json";
var json = SerializeSearchResponseJson(response);
await Response.WriteAsync(json, Encoding.UTF8);
if (shouldCache)
return JsonSerializer.Serialize(response, new JsonSerializerOptions
{
await _cache.SetStringAsync(searchCacheKey!, json, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
CacheExtensions.SearchResultsTTL.TotalMinutes);
}
else if (!string.IsNullOrWhiteSpace(searchTerm) &&
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
!string.IsNullOrWhiteSpace(searchCacheKey))
{
_logger.LogInformation(
"SEARCH TRACE: skipped cache write for query '{Query}' because requested external result buckets were empty (types={ItemTypes})",
cleanQuery,
includeItemTypes ?? string.Empty);
}
return new EmptyResult();
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null
});
}
/// <summary>
@@ -730,9 +693,12 @@ public partial class JellyfinController
var cleanQuery = searchTerm.Trim().Trim('"');
var requestedTypes = ParseItemTypes(includeItemTypes);
var externalSearchLimits = GetExternalSearchLimits(requestedTypes, limit, includePlaylistsAsAlbums: false);
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 ||
requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 ||
requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 ||
requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
_logger.LogInformation(
"SEARCH TRACE: hint limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
@@ -843,7 +809,8 @@ public partial class JellyfinController
var includeSongs = requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) ||
(includePlaylistsAsAlbums && requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
(includePlaylistsAsAlbums &&
requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
return (
@@ -854,210 +821,14 @@ public partial class JellyfinController
private static IActionResult CreateEmptyItemsResponse(int startIndex)
{
return CreateItemsResponse([], 0, startIndex);
}
private static ContentResult CreateItemsResponse(
List<Dictionary<string, object?>> items,
int totalRecordCount,
int startIndex)
{
var response = new JellyfinItemsResponse
return new JsonResult(new
{
Items = items,
TotalRecordCount = totalRecordCount,
Items = Array.Empty<object>(),
TotalRecordCount = 0,
StartIndex = startIndex
};
return new ContentResult
{
Content = SerializeSearchResponseJson(response),
ContentType = "application/json"
};
});
}
private sealed class TeeBufferWriter : IBufferWriter<byte>
{
private readonly IBufferWriter<byte> _primary;
private readonly ArrayBufferWriter<byte>? _secondary;
private Memory<byte> _currentBuffer;
public TeeBufferWriter(IBufferWriter<byte> primary, ArrayBufferWriter<byte>? secondary)
{
_primary = primary;
_secondary = secondary;
}
public void Advance(int count)
{
if (count > 0 && _secondary != null)
{
var destination = _secondary.GetSpan(count);
_currentBuffer.Span[..count].CopyTo(destination);
_secondary.Advance(count);
}
_primary.Advance(count);
_currentBuffer = Memory<byte>.Empty;
}
public Memory<byte> GetMemory(int sizeHint = 0)
{
_currentBuffer = _primary.GetMemory(sizeHint);
return _currentBuffer;
}
public Span<byte> GetSpan(int sizeHint = 0)
{
return GetMemory(sizeHint).Span;
}
}
private List<Dictionary<string, object?>> ApplyRequestedAlbumOrderingIfApplicable(
List<Dictionary<string, object?>> items,
string[]? requestedTypes,
string? sortBy,
string? sortOrder)
{
if (items.Count <= 1 || string.IsNullOrWhiteSpace(sortBy))
{
return items;
}
if (requestedTypes == null || requestedTypes.Length == 0)
{
return items;
}
var isAlbumOnlyRequest = requestedTypes.All(type =>
string.Equals(type, "MusicAlbum", StringComparison.OrdinalIgnoreCase) ||
string.Equals(type, "Playlist", StringComparison.OrdinalIgnoreCase));
if (!isAlbumOnlyRequest)
{
return items;
}
var sortFields = sortBy
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(field => !string.IsNullOrWhiteSpace(field))
.ToList();
if (sortFields.Count == 0)
{
return items;
}
var descending = string.Equals(sortOrder, "Descending", StringComparison.OrdinalIgnoreCase);
var sorted = items.ToList();
sorted.Sort((left, right) => CompareAlbumItemsByRequestedSort(left, right, sortFields, descending));
return sorted;
}
private int CompareAlbumItemsByRequestedSort(
Dictionary<string, object?> left,
Dictionary<string, object?> right,
IReadOnlyList<string> sortFields,
bool descending)
{
foreach (var field in sortFields)
{
var comparison = CompareAlbumItemsByField(left, right, field);
if (comparison == 0)
{
continue;
}
return descending ? -comparison : comparison;
}
return string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase);
}
private int CompareAlbumItemsByField(Dictionary<string, object?> left, Dictionary<string, object?> right, string field)
{
return field.ToLowerInvariant() switch
{
"sortname" => string.Compare(GetItemStringValue(left, "SortName"), GetItemStringValue(right, "SortName"), StringComparison.OrdinalIgnoreCase),
"name" => string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase),
"datecreated" => DateTime.Compare(GetItemDateValue(left, "DateCreated"), GetItemDateValue(right, "DateCreated")),
"premieredate" => DateTime.Compare(GetItemDateValue(left, "PremiereDate"), GetItemDateValue(right, "PremiereDate")),
"productionyear" => CompareIntValues(GetItemIntValue(left, "ProductionYear"), GetItemIntValue(right, "ProductionYear")),
_ => 0
};
}
private static int CompareIntValues(int? left, int? right)
{
if (left.HasValue && right.HasValue)
{
return left.Value.CompareTo(right.Value);
}
if (left.HasValue)
{
return 1;
}
if (right.HasValue)
{
return -1;
}
return 0;
}
private static DateTime GetItemDateValue(Dictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
{
return DateTime.MinValue;
}
if (value is JsonElement jsonElement)
{
if (jsonElement.ValueKind == JsonValueKind.String &&
DateTime.TryParse(jsonElement.GetString(), out var parsedDate))
{
return parsedDate;
}
return DateTime.MinValue;
}
if (DateTime.TryParse(value.ToString(), out var parsed))
{
return parsed;
}
return DateTime.MinValue;
}
private static int? GetItemIntValue(Dictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
{
return null;
}
if (value is JsonElement jsonElement)
{
if (jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt32(out var intValue))
{
return intValue;
}
if (jsonElement.ValueKind == JsonValueKind.String &&
int.TryParse(jsonElement.GetString(), out var parsedInt))
{
return parsedInt;
}
return null;
}
return int.TryParse(value.ToString(), out var parsed) ? parsed : null;
}
/// <summary>
/// Merges two source queues without reordering either queue.
/// At each step, compare only the current head from each source and dequeue the winner.
@@ -9,6 +9,8 @@ namespace allstarr.Controllers;
public partial class JellyfinController
{
private static readonly string[] KeptAudioExtensions = [".flac", ".mp3", ".m4a", ".opus"];
#region Spotify Playlist Injection
/// <summary>
@@ -57,18 +59,8 @@ public partial class JellyfinController
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName,
string playlistId)
{
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifySettings,
spotifyPlaylistName,
userId,
playlistId);
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, playlistId);
// Check if Jellyfin playlist has changed (cheap API call)
var jellyfinSignatureCacheKey =
$"spotify:playlist:jellyfin-signature:{CacheKeyBuilder.BuildSpotifyPlaylistScope(spotifyPlaylistName, playlistScopeUserId, playlistScopeId)}";
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}";
var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
@@ -76,10 +68,7 @@ public partial class JellyfinController
var requestNeedsGenreMetadata = RequestIncludesField("Genres");
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
spotifyPlaylistName,
playlistScopeUserId,
playlistScopeId);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
if (cachedItems != null && cachedItems.Count > 0 &&
@@ -123,7 +112,7 @@ public partial class JellyfinController
}
// Check file cache as fallback
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName, playlistScopeUserId, playlistScopeId);
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
if (fileItems != null && fileItems.Count > 0 &&
InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(fileItems))
{
@@ -160,63 +149,14 @@ public partial class JellyfinController
}
// Check for ordered matched tracks from SpotifyTrackMatchingService
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
spotifyPlaylistName,
playlistScopeUserId,
playlistScopeId);
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(spotifyPlaylistName);
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
if (orderedTracks == null || orderedTracks.Count == 0)
{
_logger.LogInformation(
"No ordered matched tracks in cache for {Playlist}; attempting exact-scope rebuild before fallback",
_logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
spotifyPlaylistName);
if (_spotifyTrackMatchingService != null)
{
try
{
await _spotifyTrackMatchingService.TriggerRebuildForPlaylistAsync(
spotifyPlaylistName,
playlistScopeUserId,
playlistScopeId);
orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"On-demand rebuild failed for {Playlist}; falling back to cached compatibility paths",
spotifyPlaylistName);
}
}
if (orderedTracks == null || orderedTracks.Count == 0)
{
var legacyCacheKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
spotifyPlaylistName,
playlistScopeUserId,
playlistScopeId);
var legacySongs = await _cache.GetAsync<List<Song>>(legacyCacheKey);
if (legacySongs != null && legacySongs.Count > 0)
{
orderedTracks = legacySongs.Select((song, index) => new MatchedTrack
{
Position = index,
MatchedSong = song
}).ToList();
_logger.LogInformation(
"Loaded {Count} legacy matched tracks for {Playlist} after ordered cache miss",
orderedTracks.Count,
spotifyPlaylistName);
}
}
if (orderedTracks == null || orderedTracks.Count == 0)
{
_logger.LogInformation("Ordered matched tracks are still unavailable for {Playlist}", spotifyPlaylistName);
return null; // Fall back to legacy mode
}
return null; // Fall back to legacy mode
}
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
@@ -224,10 +164,11 @@ public partial class JellyfinController
// Get existing Jellyfin playlist items (RAW - don't convert!)
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = _settings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogError(
"❌ Could not resolve Jellyfin user from the current request. Cannot fetch playlist tracks.");
"❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI.");
return null; // Fall back to legacy mode
}
@@ -298,7 +239,7 @@ public partial class JellyfinController
}
// Get the full playlist from Spotify to know the correct order
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName, userId, playlistId);
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
if (spotifyTracks.Count == 0)
{
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
@@ -455,7 +396,7 @@ public partial class JellyfinController
}
// Save to file cache for persistence across restarts
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems, playlistScopeUserId, playlistScopeId);
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
@@ -541,10 +482,13 @@ public partial class JellyfinController
if (Directory.Exists(keptAlbumPath))
{
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
if (existingFiles.Length > 0)
var existingAudioFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*")
.Where(IsKeptAudioFile)
.ToArray();
if (existingAudioFiles.Length > 0)
{
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
_logger.LogInformation("Track already exists in kept folder: {Path}", existingAudioFiles[0]);
await EnsureLyricsSidecarForKeptTrackAsync(existingAudioFiles[0], song, provider, externalId);
// Mark as favorited even if we didn't download it
await MarkTrackAsFavoritedAsync(itemId, song);
return;
@@ -633,6 +577,7 @@ public partial class JellyfinController
{
// Race condition - file was created by another request
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
await MarkTrackAsFavoritedAsync(itemId, song);
return;
}
@@ -650,6 +595,7 @@ public partial class JellyfinController
{
// Race condition on copy fallback
_logger.LogInformation("Track already exists in kept folder (race condition on copy): {Path}", keptFilePath);
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
await MarkTrackAsFavoritedAsync(itemId, song);
return;
}
@@ -711,6 +657,8 @@ public partial class JellyfinController
}
}
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
// Mark as favorited in persistent storage
await MarkTrackAsFavoritedAsync(itemId, song);
}
@@ -964,6 +912,33 @@ public partial class JellyfinController
}
}
private async Task EnsureLyricsSidecarForKeptTrackAsync(string keptFilePath, Song song, string provider, string externalId)
{
if (_keptLyricsSidecarService == null)
{
return;
}
try
{
await _keptLyricsSidecarService.EnsureSidecarAsync(
keptFilePath,
song,
provider,
externalId,
CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to create kept lyrics sidecar for {Path}", keptFilePath);
}
}
private static bool IsKeptAudioFile(string path)
{
return KeptAudioExtensions.Contains(Path.GetExtension(path).ToLowerInvariant());
}
#endregion
/// <summary>
@@ -977,7 +952,7 @@ public partial class JellyfinController
{
try
{
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
if (!string.IsNullOrEmpty(userId))
{
@@ -1019,19 +994,14 @@ public partial class JellyfinController
/// <summary>
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
/// </summary>
private async Task SavePlaylistItemsToFile(
string playlistName,
List<Dictionary<string, object?>> items,
string? userId = null,
string? scopeId = null)
private async Task SavePlaylistItemsToFile(string playlistName, List<Dictionary<string, object?>> items)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = AdminHelperService.SanitizeFileName(
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
@@ -1049,15 +1019,11 @@ public partial class JellyfinController
/// <summary>
/// Loads playlist items (raw Jellyfin JSON) from file cache.
/// </summary>
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(
string playlistName,
string? userId = null,
string? scopeId = null)
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(string playlistName)
{
try
{
var safeName = AdminHelperService.SanitizeFileName(
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
if (!System.IO.File.Exists(filePath))
+4 -82
View File
@@ -34,22 +34,20 @@ public partial class JellyfinController : ControllerBase
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly ScrobblingSettings _scrobblingSettings;
private readonly IMusicMetadataService _metadataService;
private readonly ExternalArtistAppearancesService _externalArtistAppearancesService;
private readonly ParallelMetadataService? _parallelMetadataService;
private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService;
private readonly JellyfinResponseBuilder _responseBuilder;
private readonly JellyfinModelMapper _modelMapper;
private readonly JellyfinProxyService _proxyService;
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
private readonly JellyfinSessionManager _sessionManager;
private readonly PlaylistSyncService? _playlistSyncService;
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
private readonly SpotifyTrackMatchingService? _spotifyTrackMatchingService;
private readonly SpotifyLyricsService? _spotifyLyricsService;
private readonly LyricsPlusService? _lyricsPlusService;
private readonly LrclibService? _lrclibService;
private readonly LyricsOrchestrator? _lyricsOrchestrator;
private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService;
private readonly ScrobblingOrchestrator? _scrobblingOrchestrator;
private readonly ScrobblingHelper? _scrobblingHelper;
private readonly OdesliService _odesliService;
@@ -63,13 +61,11 @@ public partial class JellyfinController : ControllerBase
IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<ScrobblingSettings> scrobblingSettings,
IMusicMetadataService metadataService,
ExternalArtistAppearancesService externalArtistAppearancesService,
ILocalLibraryService localLibraryService,
IDownloadService downloadService,
JellyfinResponseBuilder responseBuilder,
JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService,
JellyfinUserContextResolver jellyfinUserContextResolver,
JellyfinSessionManager sessionManager,
OdesliService odesliService,
RedisCacheService cache,
@@ -78,11 +74,11 @@ public partial class JellyfinController : ControllerBase
ParallelMetadataService? parallelMetadataService = null,
PlaylistSyncService? playlistSyncService = null,
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
SpotifyTrackMatchingService? spotifyTrackMatchingService = null,
SpotifyLyricsService? spotifyLyricsService = null,
LyricsPlusService? lyricsPlusService = null,
LrclibService? lrclibService = null,
LyricsOrchestrator? lyricsOrchestrator = null,
IKeptLyricsSidecarService? keptLyricsSidecarService = null,
ScrobblingOrchestrator? scrobblingOrchestrator = null,
ScrobblingHelper? scrobblingHelper = null)
{
@@ -91,22 +87,20 @@ public partial class JellyfinController : ControllerBase
_spotifyApiSettings = spotifyApiSettings.Value;
_scrobblingSettings = scrobblingSettings.Value;
_metadataService = metadataService;
_externalArtistAppearancesService = externalArtistAppearancesService;
_parallelMetadataService = parallelMetadataService;
_localLibraryService = localLibraryService;
_downloadService = downloadService;
_responseBuilder = responseBuilder;
_modelMapper = modelMapper;
_proxyService = proxyService;
_jellyfinUserContextResolver = jellyfinUserContextResolver;
_sessionManager = sessionManager;
_playlistSyncService = playlistSyncService;
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
_spotifyTrackMatchingService = spotifyTrackMatchingService;
_spotifyLyricsService = spotifyLyricsService;
_lyricsPlusService = lyricsPlusService;
_lrclibService = lrclibService;
_lyricsOrchestrator = lyricsOrchestrator;
_keptLyricsSidecarService = keptLyricsSidecarService;
_scrobblingOrchestrator = scrobblingOrchestrator;
_scrobblingHelper = scrobblingHelper;
_odesliService = odesliService;
@@ -299,75 +293,6 @@ public partial class JellyfinController : ControllerBase
return _responseBuilder.CreateItemsResponse(new List<Song>());
}
/// <summary>
/// Gets "appears on" albums for an external artist when Jellyfin requests
/// ContributingArtistIds for album containers.
/// </summary>
private async Task<IActionResult> GetExternalContributorChildItems(string provider, string type, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
{
if (IsFavoritesOnlyRequest())
{
_logger.LogDebug(
"Suppressing external contributing artist items for favorites-only request: provider={Provider}, type={Type}, externalId={ExternalId}",
provider,
type,
externalId);
return CreateEmptyItemsResponse(GetRequestedStartIndex());
}
if (!string.Equals(type, "artist", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug(
"Ignoring external contributing item request for non-artist id: provider={Provider}, type={Type}, externalId={ExternalId}",
provider,
type,
externalId);
return CreateEmptyItemsResponse(GetRequestedStartIndex());
}
var itemTypes = ParseItemTypes(includeItemTypes);
var itemTypesUnspecified = itemTypes == null || itemTypes.Length == 0;
var wantsAlbums = itemTypesUnspecified || itemTypes!.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
if (!wantsAlbums)
{
_logger.LogDebug(
"No external contributing artist handler for requested item types {ItemTypes}",
string.Join(",", itemTypes ?? Array.Empty<string>()));
return CreateEmptyItemsResponse(GetRequestedStartIndex());
}
var albums = await _externalArtistAppearancesService.GetAppearsOnAlbumsAsync(provider, externalId, cancellationToken);
var items = albums
.Select(_responseBuilder.ConvertAlbumToJellyfinItem)
.ToList();
items = ApplyRequestedAlbumOrderingIfApplicable(
items,
itemTypes,
Request.Query["SortBy"].ToString(),
Request.Query["SortOrder"].ToString());
var totalRecordCount = items.Count;
var startIndex = GetRequestedStartIndex();
if (startIndex > 0)
{
items = items.Skip(startIndex).ToList();
}
if (int.TryParse(Request.Query["Limit"], out var parsedLimit) && parsedLimit > 0)
{
items = items.Take(parsedLimit).ToList();
}
return _responseBuilder.CreateJsonResponse(new
{
Items = items,
TotalRecordCount = totalRecordCount,
StartIndex = startIndex
});
}
private int GetRequestedStartIndex()
{
return int.TryParse(Request.Query["StartIndex"], out var startIndex) && startIndex > 0
@@ -1826,10 +1751,7 @@ public partial class JellyfinController : ControllerBase
// Search through each playlist's matched tracks cache
foreach (var playlist in playlists)
{
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
playlist.Name,
playlist.UserId,
string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId);
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
if (matchedTracks == null || matchedTracks.Count == 0)
+27 -123
View File
@@ -8,7 +8,6 @@ using allstarr.Services.Common;
using allstarr.Services.Admin;
using allstarr.Services;
using allstarr.Filters;
using allstarr.Services.Jellyfin;
using System.Text.Json;
namespace allstarr.Controllers;
@@ -28,7 +27,6 @@ public class PlaylistController : ControllerBase
private readonly HttpClient _jellyfinHttpClient;
private readonly AdminHelperService _helperService;
private readonly IServiceProvider _serviceProvider;
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
private const string CacheDirectory = "/app/cache/spotify";
public PlaylistController(
@@ -41,7 +39,6 @@ public class PlaylistController : ControllerBase
IHttpClientFactory httpClientFactory,
AdminHelperService helperService,
IServiceProvider serviceProvider,
JellyfinUserContextResolver jellyfinUserContextResolver,
SpotifyTrackMatchingService? matchingService = null)
{
_logger = logger;
@@ -54,23 +51,6 @@ public class PlaylistController : ControllerBase
_jellyfinHttpClient = httpClientFactory.CreateClient();
_helperService = helperService;
_serviceProvider = serviceProvider;
_jellyfinUserContextResolver = jellyfinUserContextResolver;
}
private async Task<SpotifyPlaylistConfig?> ResolvePlaylistConfigForCurrentScopeAsync(string playlistName)
{
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
return _spotifyImportSettings.GetPlaylistByName(playlistName, userId);
}
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist)
{
if (!string.IsNullOrWhiteSpace(playlist?.JellyfinId))
{
return playlist.JellyfinId;
}
return string.IsNullOrWhiteSpace(playlist?.Id) ? null : playlist.Id;
}
[HttpGet("playlists")]
@@ -169,7 +149,7 @@ public class PlaylistController : ControllerBase
{
try
{
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
spotifyTrackCount = spotifyTracks.Count;
playlistInfo["trackCount"] = spotifyTrackCount;
_logger.LogDebug("Fetched {Count} tracks from Spotify for playlist {Name}", spotifyTrackCount, config.Name);
@@ -187,10 +167,7 @@ public class PlaylistController : ControllerBase
try
{
// Try to use the pre-built playlist cache
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
config.Name,
config.UserId,
GetPlaylistScopeId(config));
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name);
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
@@ -262,7 +239,7 @@ public class PlaylistController : ControllerBase
else
{
// No playlist cache - calculate from global mappings as fallback
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
var localCount = 0;
var externalCount = 0;
var missingCount = 0;
@@ -314,7 +291,7 @@ public class PlaylistController : ControllerBase
try
{
// Jellyfin requires UserId parameter to fetch playlist items
var userId = config.UserId;
var userId = _jellyfinSettings.UserId;
// If no user configured, try to get the first user
if (string.IsNullOrEmpty(userId))
@@ -353,13 +330,10 @@ public class PlaylistController : ControllerBase
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
{
// Get Spotify tracks to match against
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
// Try to use the pre-built playlist cache first (includes manual mappings!)
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
config.Name,
config.UserId,
GetPlaylistScopeId(config));
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name);
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
@@ -464,10 +438,7 @@ public class PlaylistController : ControllerBase
}
// Get matched external tracks cache once
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
config.Name,
config.UserId,
GetPlaylistScopeId(config));
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(config.Name);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
var matchedSpotifyIds = new HashSet<string>(
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
@@ -484,11 +455,7 @@ public class PlaylistController : ControllerBase
var hasExternalMapping = false;
// FIRST: Check for manual Jellyfin mapping
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
config.Name,
track.SpotifyId,
config.UserId,
GetPlaylistScopeId(config));
var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -499,11 +466,7 @@ public class PlaylistController : ControllerBase
else
{
// Check for external manual mapping
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
config.Name,
track.SpotifyId,
config.UserId,
GetPlaylistScopeId(config));
var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
@@ -629,22 +592,16 @@ public class PlaylistController : ControllerBase
public async Task<IActionResult> GetPlaylistTracks(string name)
{
var decodedName = Uri.UnescapeDataString(name);
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
// Get Spotify tracks
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName, playlistScopeUserId, playlistConfig?.JellyfinId);
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
var tracksWithStatus = new List<object>();
var matchedTracksBySpotifyId = new Dictionary<string, MatchedTrack>(StringComparer.OrdinalIgnoreCase);
try
{
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
if (matchedTracks != null)
@@ -670,10 +627,7 @@ public class PlaylistController : ControllerBase
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
// This cache includes all matched tracks with proper provider IDs
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
@@ -994,11 +948,7 @@ public class PlaylistController : ControllerBase
string? externalProvider = null;
// Check for manual Jellyfin mapping
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
decodedName,
track.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -1008,11 +958,7 @@ public class PlaylistController : ControllerBase
else
{
// Check for external manual mapping
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
decodedName,
track.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
@@ -1125,16 +1071,10 @@ public class PlaylistController : ControllerBase
try
{
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
await _playlistFetcher.RefreshPlaylistAsync(decodedName);
// Clear playlist stats cache first (so it gets recalculated with fresh data)
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
await _cache.DeleteAsync(statsCacheKey);
// Then invalidate playlist summary cache (will rebuild with fresh stats)
@@ -1169,28 +1109,18 @@ public class PlaylistController : ControllerBase
try
{
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
// Clear the Jellyfin playlist signature cache to force re-checking if local tracks changed
var jellyfinSignatureCacheKey =
$"spotify:playlist:jellyfin-signature:{CacheKeyBuilder.BuildSpotifyPlaylistScope(decodedName, playlistScopeUserId, playlistScopeId)}";
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{decodedName}";
await _cache.DeleteAsync(jellyfinSignatureCacheKey);
_logger.LogDebug("Cleared Jellyfin signature cache to force change detection");
// Clear the matched results cache to force re-matching
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
await _cache.DeleteAsync(matchedTracksKey);
_logger.LogDebug("Cleared matched tracks cache");
// Clear the playlist items cache
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
await _cache.DeleteAsync(playlistItemsCacheKey);
_logger.LogDebug("Cleared playlist items cache");
@@ -1201,10 +1131,7 @@ public class PlaylistController : ControllerBase
_helperService.InvalidatePlaylistSummaryCache();
// Clear playlist stats cache to force recalculation from new mappings
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
await _cache.DeleteAsync(statsCacheKey);
_logger.LogDebug("Cleared stats cache for {Name}", decodedName);
@@ -1269,7 +1196,7 @@ public class PlaylistController : ControllerBase
try
{
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
var userId = _jellyfinSettings.UserId;
// Build URL with UserId if available
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
@@ -1401,7 +1328,7 @@ public class PlaylistController : ControllerBase
try
{
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
var userId = _jellyfinSettings.UserId;
var url = $"{_jellyfinSettings.Url}/Items/{id}";
if (!string.IsNullOrEmpty(userId))
@@ -1497,20 +1424,13 @@ public class PlaylistController : ControllerBase
try
{
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
var playlistScopeUserId = playlistConfig?.UserId;
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
string? normalizedProvider = null;
string? normalizedExternalId = null;
if (hasJellyfinMapping)
{
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
var mappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
decodedName,
request.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
await _cache.SetAsync(mappingKey, request.JellyfinId!);
// Also save to file for persistence across restarts
@@ -1522,11 +1442,7 @@ public class PlaylistController : ControllerBase
else
{
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
decodedName,
request.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
normalizedExternalId = NormalizeExternalTrackId(normalizedProvider, request.ExternalId!);
var externalMapping = new { provider = normalizedProvider, id = normalizedExternalId };
@@ -1566,22 +1482,10 @@ public class PlaylistController : ControllerBase
}
// Clear all related caches to force rebuild
var matchedCacheKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
decodedName,
playlistScopeUserId,
playlistScopeId);
var matchedCacheKey = $"spotify:matched:{decodedName}";
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
await _cache.DeleteAsync(matchedCacheKey);
await _cache.DeleteAsync(orderedCacheKey);
@@ -357,9 +357,9 @@ public class SpotifyAdminController : ControllerBase
{
var keys = new[]
{
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name)
};
foreach (var key in keys)
+3 -26
View File
@@ -79,29 +79,6 @@ public class TrackMetadataRequest
public int? DurationMs { get; set; }
}
public class SquidWtfEndpointHealthResponse
{
public DateTime TestedAtUtc { get; set; }
public int TotalRows { get; set; }
public List<SquidWtfEndpointHealthRow> Endpoints { get; set; } = new();
}
public class SquidWtfEndpointHealthRow
{
public string Host { get; set; } = string.Empty;
public string? ApiUrl { get; set; }
public string? StreamingUrl { get; set; }
public SquidWtfEndpointProbeResult Api { get; set; } = new();
public SquidWtfEndpointProbeResult Streaming { get; set; } = new();
}
public class SquidWtfEndpointProbeResult
{
public bool Configured { get; set; }
public bool IsUp { get; set; }
public string State { get; set; } = "unknown";
public int? StatusCode { get; set; }
public long? LatencyMs { get; set; }
public string? RequestUrl { get; set; }
public string? Error { get; set; }
}
/// <summary>
/// Request model for updating configuration
/// </summary>
@@ -1,37 +0,0 @@
namespace allstarr.Models.Jellyfin;
/// <summary>
/// Canonical Jellyfin Items response wrapper used by search-related hot paths.
/// </summary>
public class JellyfinItemsResponse
{
public List<Dictionary<string, object?>> Items { get; set; } = [];
public int TotalRecordCount { get; set; }
public int StartIndex { get; set; }
}
/// <summary>
/// Playback payload forwarded to Jellyfin for start/progress/stop events.
/// Nullable members are omitted to preserve the lean request shapes clients expect.
/// </summary>
public class JellyfinPlaybackStatePayload
{
public string ItemId { get; set; } = string.Empty;
public long PositionTicks { get; set; }
public bool? CanSeek { get; set; }
public bool? IsPaused { get; set; }
public bool? IsMuted { get; set; }
public string? PlayMethod { get; set; }
}
/// <summary>
/// Synthetic capabilities payload used when allstarr needs to establish a Jellyfin session.
/// </summary>
public class JellyfinSessionCapabilitiesPayload
{
public string[] PlayableMediaTypes { get; set; } = [];
public string[] SupportedCommands { get; set; } = [];
public bool SupportsMediaControl { get; set; }
public bool SupportsPersistentIdentifier { get; set; }
public bool SupportsSync { get; set; }
}
@@ -126,33 +126,8 @@ public class SpotifyImportSettings
/// <summary>
/// Gets the playlist configuration by name.
/// </summary>
public SpotifyPlaylistConfig? GetPlaylistByName(string name, string? userId = null, string? jellyfinPlaylistId = null)
{
var matches = Playlists
.Where(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
.ToList();
if (!string.IsNullOrWhiteSpace(jellyfinPlaylistId))
{
var byPlaylistId = matches.FirstOrDefault(p =>
p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
if (byPlaylistId != null)
{
return byPlaylistId;
}
}
if (!string.IsNullOrWhiteSpace(userId))
{
var normalizedUserId = userId.Trim();
return matches.FirstOrDefault(p =>
!string.IsNullOrWhiteSpace(p.UserId) &&
p.UserId.Equals(normalizedUserId, StringComparison.OrdinalIgnoreCase))
?? matches.FirstOrDefault(p => string.IsNullOrWhiteSpace(p.UserId));
}
return matches.FirstOrDefault();
}
public SpotifyPlaylistConfig? GetPlaylistByName(string name) =>
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Checks if a Jellyfin playlist ID is configured for Spotify import.
+13 -4
View File
@@ -12,8 +12,10 @@ using allstarr.Services.Lyrics;
using allstarr.Services.Scrobbling;
using allstarr.Middleware;
using allstarr.Filters;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Http;
using System.Net;
using System.IO;
var builder = WebApplication.CreateBuilder(args);
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
@@ -198,6 +200,11 @@ builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHttpContextAccessor();
var dataProtectionKeysDirectory = new DirectoryInfo("/app/cache/data-protection");
dataProtectionKeysDirectory.Create();
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(dataProtectionKeysDirectory)
.SetApplicationName("allstarr-admin");
// Exception handling
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
@@ -528,7 +535,6 @@ else
// Business services - shared across backends
builder.Services.AddSingleton(squidWtfEndpointCatalog);
builder.Services.AddMemoryCache(); // L1 in-memory tier for RedisCacheService
builder.Services.AddSingleton<RedisCacheService>();
builder.Services.AddSingleton<FavoritesMigrationService>();
builder.Services.AddSingleton<OdesliService>();
@@ -541,8 +547,6 @@ if (backendType == BackendType.Jellyfin)
// Jellyfin services
builder.Services.AddSingleton<JellyfinResponseBuilder>();
builder.Services.AddSingleton<JellyfinModelMapper>();
builder.Services.AddSingleton<ExternalArtistAppearancesService>();
builder.Services.AddScoped<JellyfinUserContextResolver>();
builder.Services.AddScoped<JellyfinProxyService>();
builder.Services.AddSingleton<JellyfinSessionManager>();
builder.Services.AddScoped<JellyfinAuthFilter>();
@@ -715,6 +719,7 @@ builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
builder.Services.AddSingleton<allstarr.Services.Lyrics.IKeptLyricsSidecarService, allstarr.Services.Lyrics.KeptLyricsSidecarService>();
// Register Spotify mapping service (global Spotify ID → Local/External mappings)
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyMappingService>();
@@ -968,7 +973,11 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// The admin UI is documented and intended to be reachable directly over HTTP on port 5275.
// Keep HTTPS redirection for non-admin traffic only.
app.UseWhen(
context => context.Connection.LocalPort != 5275,
branch => branch.UseHttpsRedirection());
// Serve static files only on admin port (5275)
app.UseMiddleware<allstarr.Middleware.AdminNetworkAllowlistMiddleware>();
@@ -1,67 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using allstarr.Models.Domain;
using allstarr.Models.Jellyfin;
using allstarr.Models.Lyrics;
using allstarr.Models.Search;
using allstarr.Models.Spotify;
namespace allstarr.Serialization;
/// <summary>
/// System.Text.Json source-generated serializer context for hot-path types.
/// Eliminates runtime reflection for serialize/deserialize operations, providing
/// 3-8x faster throughput and significantly reduced GC allocations.
///
/// Used by RedisCacheService (all cached types), search response serialization,
/// and playback session payload construction.
/// </summary>
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
)]
// Domain models (hot: cached in Redis, serialized in search responses)
[JsonSerializable(typeof(Song))]
[JsonSerializable(typeof(Album))]
[JsonSerializable(typeof(Artist))]
[JsonSerializable(typeof(SearchResult))]
[JsonSerializable(typeof(JellyfinItemsResponse))]
[JsonSerializable(typeof(JellyfinPlaybackStatePayload))]
[JsonSerializable(typeof(JellyfinSessionCapabilitiesPayload))]
[JsonSerializable(typeof(List<Song>))]
[JsonSerializable(typeof(List<Album>))]
[JsonSerializable(typeof(List<Artist>))]
// Spotify models (hot: playlist loading, track matching)
[JsonSerializable(typeof(SpotifyPlaylistTrack))]
[JsonSerializable(typeof(SpotifyPlaylist))]
[JsonSerializable(typeof(MatchedTrack))]
[JsonSerializable(typeof(MissingTrack))]
[JsonSerializable(typeof(SpotifyTrackMapping))]
[JsonSerializable(typeof(TrackMetadata))]
[JsonSerializable(typeof(List<SpotifyPlaylistTrack>))]
[JsonSerializable(typeof(List<MatchedTrack>))]
[JsonSerializable(typeof(List<MissingTrack>))]
// Lyrics models (moderate: cached in Redis)
[JsonSerializable(typeof(LyricsInfo))]
// Collection types used in cache and playlist items
[JsonSerializable(typeof(List<Dictionary<string, object?>>))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(byte[]))]
internal partial class AllstarrJsonContext : JsonSerializerContext
{
/// <summary>
/// Shared default instance. Use this for all hot-path serialization
/// where PropertyNamingPolicy = null (PascalCase / preserve casing).
/// </summary>
public static AllstarrJsonContext Shared { get; } = new(new JsonSerializerOptions
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
});
}
@@ -1,47 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace allstarr.Serialization;
internal static class AllstarrJsonSerializer
{
private static readonly JsonSerializerOptions ReflectionFallbackOptions = new()
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
public static string Serialize<T>(T value)
{
var typeInfo = GetTypeInfo<T>();
if (typeInfo != null)
{
try
{
return JsonSerializer.Serialize(value, typeInfo);
}
catch (NotSupportedException)
{
// Mixed Jellyfin payloads often carry runtime-only shapes such as JsonElement,
// List<object>, or dictionary arrays. Fall back to reflection for those cases.
}
}
return JsonSerializer.Serialize(value, ReflectionFallbackOptions);
}
private static JsonTypeInfo<T>? GetTypeInfo<T>()
{
try
{
return (JsonTypeInfo<T>?)AllstarrJsonContext.Shared.GetTypeInfo(typeof(T));
}
catch
{
return null;
}
}
}
@@ -1,5 +1,8 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Logging.Abstractions;
namespace allstarr.Services.Admin;
@@ -11,27 +14,83 @@ public sealed class AdminAuthSession
public required bool IsAdministrator { get; init; }
public required string JellyfinAccessToken { 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; }
}
/// <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>
public class AdminAuthSessionService
{
public const string SessionCookieName = "allstarr_admin_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 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(
string userId,
string userName,
bool isAdministrator,
string jellyfinAccessToken,
string? jellyfinServerId)
string? jellyfinServerId,
bool isPersistent = false)
{
RemoveExpiredSessions();
@@ -44,11 +103,13 @@ public class AdminAuthSessionService
IsAdministrator = isAdministrator,
JellyfinAccessToken = jellyfinAccessToken,
JellyfinServerId = jellyfinServerId,
ExpiresAtUtc = now.Add(SessionLifetime),
IsPersistent = isPersistent,
ExpiresAtUtc = now.Add(isPersistent ? PersistentSessionLifetime : DefaultSessionLifetime),
LastSeenUtc = now
};
_sessions[session.SessionId] = session;
PersistSessions();
return session;
}
@@ -69,6 +130,7 @@ public class AdminAuthSessionService
if (existing.ExpiresAtUtc <= DateTime.UtcNow)
{
_sessions.TryRemove(sessionId, out _);
PersistSessions();
return false;
}
@@ -84,17 +146,117 @@ public class AdminAuthSessionService
return;
}
_sessions.TryRemove(sessionId, out _);
if (_sessions.TryRemove(sessionId, out _))
{
PersistSessions();
}
}
private void RemoveExpiredSessions()
{
var now = DateTime.UtcNow;
var removedAny = false;
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);
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; }
}
}
+19 -93
View File
@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using System.Text.RegularExpressions;
namespace allstarr.Services.Common;
@@ -10,10 +9,6 @@ namespace allstarr.Services.Common;
/// </summary>
public static class AuthHeaderHelper
{
private static readonly Regex AuthParameterRegex = new(
@"(?<key>[A-Za-z0-9_-]+)\s*=\s*""(?<value>[^""]*)""",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Forwards authentication headers from HTTP request to HttpRequestMessage.
/// Handles both X-Emby-Authorization and Authorization headers.
@@ -104,7 +99,17 @@ public static class AuthHeaderHelper
/// </summary>
private static string? ExtractDeviceIdFromAuthString(string authValue)
{
return ExtractAuthParameter(authValue, "DeviceId");
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match(
authValue,
@"DeviceId=""([^""]+)""",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (deviceIdMatch.Success)
{
return deviceIdMatch.Groups[1].Value;
}
return null;
}
/// <summary>
@@ -135,95 +140,16 @@ public static class AuthHeaderHelper
/// </summary>
private static string? ExtractClientNameFromAuthString(string authValue)
{
return ExtractAuthParameter(authValue, "Client");
}
/// <summary>
/// Extracts the authenticated Jellyfin access token from request headers.
/// Supports X-Emby-Authorization, X-Emby-Token, Authorization: MediaBrowser ..., and Bearer tokens.
/// </summary>
public static string? ExtractAccessToken(IHeaderDictionary headers)
{
if (headers.TryGetValue("X-Emby-Token", out var tokenHeader))
var clientMatch = System.Text.RegularExpressions.Regex.Match(
authValue,
@"Client=""([^""]+)""",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (clientMatch.Success)
{
var token = tokenHeader.ToString().Trim();
if (!string.IsNullOrWhiteSpace(token))
{
return token;
}
return clientMatch.Groups[1].Value;
}
if (headers.TryGetValue("X-Emby-Authorization", out var authHeader))
{
var token = ExtractAuthParameter(authHeader.ToString(), "Token");
if (!string.IsNullOrWhiteSpace(token))
{
return token;
}
}
if (headers.TryGetValue("Authorization", out var authorizationHeader))
{
var authValue = authorizationHeader.ToString().Trim();
if (authValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var bearerToken = authValue["Bearer ".Length..].Trim();
return string.IsNullOrWhiteSpace(bearerToken) ? null : bearerToken;
}
var token = ExtractAuthParameter(authValue, "Token");
if (!string.IsNullOrWhiteSpace(token))
{
return token;
}
}
return null;
}
/// <summary>
/// Extracts a Jellyfin user id from auth headers when present.
/// This is uncommon but some clients may include it in MediaBrowser auth parameters.
/// </summary>
public static string? ExtractUserId(IHeaderDictionary headers)
{
if (headers.TryGetValue("X-Emby-Authorization", out var authHeader))
{
var userId = ExtractAuthParameter(authHeader.ToString(), "UserId");
if (!string.IsNullOrWhiteSpace(userId))
{
return userId;
}
}
if (headers.TryGetValue("Authorization", out var authorizationHeader))
{
var userId = ExtractAuthParameter(authorizationHeader.ToString(), "UserId");
if (!string.IsNullOrWhiteSpace(userId))
{
return userId;
}
}
return null;
}
private static string? ExtractAuthParameter(string authValue, string parameterName)
{
if (string.IsNullOrWhiteSpace(authValue))
{
return null;
}
foreach (Match match in AuthParameterRegex.Matches(authValue))
{
if (match.Groups["key"].Value.Equals(parameterName, StringComparison.OrdinalIgnoreCase))
{
var value = match.Groups["value"].Value;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
}
return null;
}
+18 -44
View File
@@ -67,52 +67,34 @@ public static class CacheKeyBuilder
#region Spotify Keys
public static string BuildSpotifyPlaylistScope(string playlistName, string? userId = null, string? scopeId = null)
public static string BuildSpotifyPlaylistKey(string playlistName)
{
var normalizedUserId = Normalize(userId);
var normalizedScopeId = Normalize(scopeId);
var normalizedPlaylistName = Normalize(playlistName);
if (string.IsNullOrEmpty(normalizedUserId) && string.IsNullOrEmpty(normalizedScopeId))
{
return playlistName;
}
var effectiveScopeId = string.IsNullOrEmpty(normalizedScopeId)
? normalizedPlaylistName
: normalizedScopeId;
return $"{normalizedUserId}:{effectiveScopeId}";
return $"spotify:playlist:{playlistName}";
}
public static string BuildSpotifyPlaylistKey(string playlistName, string? userId = null, string? scopeId = null)
public static string BuildSpotifyPlaylistItemsKey(string playlistName)
{
return $"spotify:playlist:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
return $"spotify:playlist:items:{playlistName}";
}
public static string BuildSpotifyPlaylistItemsKey(string playlistName, string? userId = null, string? scopeId = null)
public static string BuildSpotifyPlaylistOrderedKey(string playlistName)
{
return $"spotify:playlist:items:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
return $"spotify:playlist:ordered:{playlistName}";
}
public static string BuildSpotifyPlaylistOrderedKey(string playlistName, string? userId = null, string? scopeId = null)
public static string BuildSpotifyMatchedTracksKey(string playlistName)
{
return $"spotify:playlist:ordered:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
return $"spotify:matched:ordered:{playlistName}";
}
public static string BuildSpotifyMatchedTracksKey(string playlistName, string? userId = null, string? scopeId = null)
public static string BuildSpotifyLegacyMatchedTracksKey(string playlistName)
{
return $"spotify:matched:ordered:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
return $"spotify:matched:{playlistName}";
}
public static string BuildSpotifyLegacyMatchedTracksKey(string playlistName, string? userId = null, string? scopeId = null)
public static string BuildSpotifyPlaylistStatsKey(string playlistName)
{
return $"spotify:matched:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
}
public static string BuildSpotifyPlaylistStatsKey(string playlistName, string? userId = null, string? scopeId = null)
{
return $"spotify:playlist:stats:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
return $"spotify:playlist:stats:{playlistName}";
}
public static string BuildSpotifyPlaylistStatsPattern()
@@ -120,27 +102,19 @@ public static class CacheKeyBuilder
return "spotify:playlist:stats:*";
}
public static string BuildSpotifyMissingTracksKey(string playlistName, string? userId = null, string? scopeId = null)
public static string BuildSpotifyMissingTracksKey(string playlistName)
{
return $"spotify:missing:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
return $"spotify:missing:{playlistName}";
}
public static string BuildSpotifyManualMappingKey(
string playlist,
string spotifyId,
string? userId = null,
string? scopeId = null)
public static string BuildSpotifyManualMappingKey(string playlist, string spotifyId)
{
return $"spotify:manual-map:{BuildSpotifyPlaylistScope(playlist, userId, scopeId)}:{spotifyId}";
return $"spotify:manual-map:{playlist}:{spotifyId}";
}
public static string BuildSpotifyExternalMappingKey(
string playlist,
string spotifyId,
string? userId = null,
string? scopeId = null)
public static string BuildSpotifyExternalMappingKey(string playlist, string spotifyId)
{
return $"spotify:external-map:{BuildSpotifyPlaylistScope(playlist, userId, scopeId)}:{spotifyId}";
return $"spotify:external-map:{playlist}:{spotifyId}";
}
public static string BuildSpotifyGlobalMappingKey(string spotifyId)
+23 -275
View File
@@ -1,57 +1,27 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Serialization;
using StackExchange.Redis;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
namespace allstarr.Services.Common;
/// <summary>
/// Tiered caching service: L1 in-memory (IMemoryCache, ~30s TTL) backed by
/// L2 Redis for persistence. The memory tier eliminates Redis network round-trips
/// for repeated reads within a short window (playlist scrolling, search-as-you-type).
/// Redis caching service for metadata and images.
/// </summary>
public class RedisCacheService
{
/// <summary>
/// Default L1 memory cache duration. Kept short to avoid serving stale data,
/// but long enough to absorb bursts of repeated reads.
/// </summary>
private static readonly TimeSpan DefaultMemoryTtl = TimeSpan.FromSeconds(30);
/// <summary>
/// Key prefixes that should NOT be cached in memory (e.g., large binary blobs).
/// </summary>
private static readonly string[] MemoryExcludedPrefixes = ["image:"];
private static readonly JsonSerializerOptions ReflectionFallbackJsonOptions = new()
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private readonly RedisSettings _settings;
private readonly ILogger<RedisCacheService> _logger;
private readonly IMemoryCache _memoryCache;
private readonly ConcurrentDictionary<string, byte> _memoryKeys = new(StringComparer.Ordinal);
private IConnectionMultiplexer? _redis;
private IDatabase? _db;
private readonly object _lock = new();
public RedisCacheService(
IOptions<RedisSettings> settings,
ILogger<RedisCacheService> logger,
IMemoryCache memoryCache)
ILogger<RedisCacheService> logger)
{
_settings = settings.Value;
_logger = logger;
_memoryCache = memoryCache;
if (_settings.Enabled)
{
@@ -77,146 +47,24 @@ public class RedisCacheService
public bool IsEnabled => _settings.Enabled && _db != null;
/// <summary>
/// Checks whether a key should be cached in the L1 memory tier.
/// Large binary data (images) is excluded to avoid memory pressure.
/// </summary>
private static bool ShouldUseMemoryCache(string key)
{
foreach (var prefix in MemoryExcludedPrefixes)
{
if (key.StartsWith(prefix, StringComparison.Ordinal))
return false;
}
return true;
}
/// <summary>
/// Computes the L1 TTL for a key mirrored from Redis.
/// Returns null for already-expired entries, which skips L1 caching entirely.
/// </summary>
private static TimeSpan? GetMemoryTtl(TimeSpan? redisExpiry)
{
if (redisExpiry == null)
return DefaultMemoryTtl;
if (redisExpiry.Value <= TimeSpan.Zero)
return null;
return redisExpiry.Value < DefaultMemoryTtl ? redisExpiry.Value : DefaultMemoryTtl;
}
private bool TryGetMemoryValue(string key, out string? value)
{
if (!ShouldUseMemoryCache(key))
{
value = null;
return false;
}
return _memoryCache.TryGetValue(key, out value);
}
private void SetMemoryValue(string key, string value, TimeSpan? expiry)
{
if (!ShouldUseMemoryCache(key))
return;
var memoryTtl = GetMemoryTtl(expiry);
if (memoryTtl == null)
{
_memoryCache.Remove(key);
_memoryKeys.TryRemove(key, out _);
return;
}
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = memoryTtl
};
options.RegisterPostEvictionCallback(
static (cacheKey, _, _, state) =>
{
if (cacheKey is string stringKey && state is ConcurrentDictionary<string, byte> memoryKeys)
{
memoryKeys.TryRemove(stringKey, out _);
}
},
_memoryKeys);
_memoryCache.Set(key, value, options);
_memoryKeys[key] = 0;
}
private int RemoveMemoryKeysByPattern(string pattern)
{
if (_memoryKeys.IsEmpty)
return 0;
if (!pattern.Contains('*') && !pattern.Contains('?'))
{
var removed = _memoryKeys.TryRemove(pattern, out _);
_memoryCache.Remove(pattern);
return removed ? 1 : 0;
}
var regex = new Regex(
"^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$",
RegexOptions.CultureInvariant);
var keysToRemove = _memoryKeys.Keys.Where(key => regex.IsMatch(key)).ToArray();
foreach (var key in keysToRemove)
{
_memoryCache.Remove(key);
_memoryKeys.TryRemove(key, out _);
}
return keysToRemove.Length;
}
/// <summary>
/// Gets a cached value as a string.
/// Checks L1 memory cache first, falls back to L2 Redis.
/// </summary>
public ValueTask<string?> GetStringAsync(string key)
public async Task<string?> GetStringAsync(string key)
{
// L1: Try in-memory cache first (sub-microsecond)
if (TryGetMemoryValue(key, out var memoryValue))
{
_logger.LogDebug("L1 memory cache HIT: {Key}", key);
return new ValueTask<string?>(memoryValue);
}
if (!IsEnabled) return null;
if (!IsEnabled) return new ValueTask<string?>((string?)null);
return new ValueTask<string?>(GetStringFromRedisAsync(key));
}
private async Task<string?> GetStringFromRedisAsync(string key)
{
try
{
// L2: Fall back to Redis
var value = await _db!.StringGetAsync(key);
if (value.HasValue)
{
_logger.LogDebug("L2 Redis cache HIT: {Key}", key);
// Promote to L1 for subsequent reads
if (ShouldUseMemoryCache(key))
{
var stringValue = (string?)value;
if (stringValue != null)
{
var redisExpiry = await _db.KeyTimeToLiveAsync(key);
SetMemoryValue(key, stringValue, redisExpiry);
}
}
_logger.LogDebug("Redis cache HIT: {Key}", key);
}
else
{
_logger.LogDebug("Cache MISS: {Key}", key);
_logger.LogDebug("Redis cache MISS: {Key}", key);
}
return value;
}
@@ -229,17 +77,15 @@ public class RedisCacheService
/// <summary>
/// Gets a cached value and deserializes it.
/// Uses source-generated serializer for registered types (3-8x faster),
/// with automatic fallback to reflection-based serialization.
/// </summary>
public async ValueTask<T?> GetAsync<T>(string key) where T : class
public async Task<T?> GetAsync<T>(string key) where T : class
{
var json = await GetStringAsync(key);
if (string.IsNullOrEmpty(json)) return null;
try
{
return DeserializeWithFallback<T>(json, key);
return JsonSerializer.Deserialize<T>(json);
}
catch (Exception ex)
{
@@ -250,20 +96,11 @@ public class RedisCacheService
/// <summary>
/// Sets a cached value with TTL.
/// Writes to both L1 memory cache and L2 Redis.
/// </summary>
public ValueTask<bool> SetStringAsync(string key, string value, TimeSpan? expiry = null)
public async Task<bool> SetStringAsync(string key, string value, TimeSpan? expiry = null)
{
// Always update L1 (even if Redis is down — provides degraded caching)
SetMemoryValue(key, value, expiry);
if (!IsEnabled) return false;
if (!IsEnabled) return new ValueTask<bool>(false);
return new ValueTask<bool>(SetStringWithRedisAsync(key, value, expiry));
}
private async Task<bool> SetStringWithRedisAsync(string key, string value, TimeSpan? expiry)
{
try
{
return await SetStringInternalAsync(key, value, expiry);
@@ -360,14 +197,12 @@ public class RedisCacheService
/// <summary>
/// Sets a cached value by serializing it with TTL.
/// Uses source-generated serializer for registered types (3-8x faster),
/// with automatic fallback to reflection-based serialization.
/// </summary>
public async ValueTask<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) where T : class
public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) where T : class
{
try
{
var json = SerializeWithFallback(value, key);
var json = JsonSerializer.Serialize(value);
return await SetStringAsync(key, json, expiry);
}
catch (Exception ex)
@@ -377,80 +212,13 @@ public class RedisCacheService
}
}
private T? DeserializeWithFallback<T>(string json, string key) where T : class
{
var typeInfo = TryGetTypeInfo<T>();
if (typeInfo != null)
{
try
{
return JsonSerializer.Deserialize(json, typeInfo);
}
catch (NotSupportedException ex)
{
_logger.LogDebug(
ex,
"Source-generated deserialization unsupported for key: {Key}; falling back to reflection.",
key);
}
}
return JsonSerializer.Deserialize<T>(json, ReflectionFallbackJsonOptions);
}
private string SerializeWithFallback<T>(T value, string key) where T : class
{
var typeInfo = TryGetTypeInfo<T>();
if (typeInfo != null)
{
try
{
return JsonSerializer.Serialize(value, typeInfo);
}
catch (NotSupportedException ex)
{
_logger.LogDebug(
ex,
"Source-generated serialization unsupported for key: {Key}; falling back to reflection.",
key);
}
}
return JsonSerializer.Serialize(value, ReflectionFallbackJsonOptions);
}
/// <summary>
/// Attempts to resolve a JsonTypeInfo from the AllstarrJsonContext source generator.
/// Returns null if the type isn't registered, triggering fallback to reflection.
/// Deletes a cached value.
/// </summary>
private static JsonTypeInfo<T>? TryGetTypeInfo<T>() where T : class
public async Task<bool> DeleteAsync(string key)
{
try
{
return (JsonTypeInfo<T>?)AllstarrJsonContext.Default.GetTypeInfo(typeof(T));
}
catch
{
return null;
}
}
if (!IsEnabled) return false;
/// <summary>
/// Deletes a cached value from both L1 memory and L2 Redis.
/// </summary>
public ValueTask<bool> DeleteAsync(string key)
{
// Always evict from L1
_memoryCache.Remove(key);
_memoryKeys.TryRemove(key, out _);
if (!IsEnabled) return new ValueTask<bool>(false);
return new ValueTask<bool>(DeleteFromRedisAsync(key));
}
private async Task<bool> DeleteFromRedisAsync(string key)
{
try
{
return await _db!.KeyDeleteAsync(key);
@@ -465,20 +233,10 @@ public class RedisCacheService
/// <summary>
/// Checks if a key exists.
/// </summary>
public ValueTask<bool> ExistsAsync(string key)
public async Task<bool> ExistsAsync(string key)
{
if (ShouldUseMemoryCache(key) && _memoryCache.TryGetValue(key, out _))
{
return new ValueTask<bool>(true);
}
if (!IsEnabled) return false;
if (!IsEnabled) return new ValueTask<bool>(false);
return new ValueTask<bool>(ExistsInRedisAsync(key));
}
private async Task<bool> ExistsInRedisAsync(string key)
{
try
{
return await _db!.KeyExistsAsync(key);
@@ -513,16 +271,10 @@ public class RedisCacheService
/// Deletes all keys matching a pattern (e.g., "search:*").
/// WARNING: Use with caution as this scans all keys.
/// </summary>
public ValueTask<int> DeleteByPatternAsync(string pattern)
public async Task<int> DeleteByPatternAsync(string pattern)
{
var memoryDeleted = RemoveMemoryKeysByPattern(pattern);
if (!IsEnabled) return new ValueTask<int>(memoryDeleted);
if (!IsEnabled) return 0;
return new ValueTask<int>(DeleteByPatternFromRedisAsync(pattern, memoryDeleted));
}
private async Task<int> DeleteByPatternFromRedisAsync(string pattern, int memoryDeleted)
{
try
{
var server = _redis!.GetServer(_redis.GetEndPoints().First());
@@ -530,22 +282,18 @@ public class RedisCacheService
if (keys.Length == 0)
{
_logger.LogDebug("No Redis keys found matching pattern: {Pattern}", pattern);
return memoryDeleted;
_logger.LogDebug("No keys found matching pattern: {Pattern}", pattern);
return 0;
}
var deleted = await _db!.KeyDeleteAsync(keys);
_logger.LogDebug(
"Deleted {RedisCount} Redis keys and {MemoryCount} memory keys matching pattern: {Pattern}",
deleted,
memoryDeleted,
pattern);
_logger.LogDebug("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
return (int)deleted;
}
catch (Exception ex)
{
_logger.LogError(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
return memoryDeleted;
return 0;
}
}
}
@@ -442,18 +442,16 @@ public class RoundRobinFallbackHelper
private void LogEndpointFailure(string baseUrl, Exception ex, bool willRetry)
{
var message = BuildFailureSummary(ex);
var isTimeoutOrCancellation = ex is TaskCanceledException or OperationCanceledException;
var verb = isTimeoutOrCancellation ? "request timed out" : "request failed";
if (willRetry)
{
_logger.LogWarning("{Service} {Verb} at {Endpoint}: {Error}. Trying next...",
_serviceName, verb, baseUrl, message);
_logger.LogWarning("{Service} request failed at {Endpoint}: {Error}. Trying next...",
_serviceName, baseUrl, message);
}
else
{
_logger.LogError("{Service} {Verb} at {Endpoint}: {Error}",
_serviceName, verb, baseUrl, message);
_logger.LogError("{Service} request failed at {Endpoint}: {Error}",
_serviceName, baseUrl, message);
}
_logger.LogDebug(ex, "{Service} detailed failure for endpoint {Endpoint}",
@@ -468,16 +466,6 @@ public class RoundRobinFallbackHelper
return $"{statusCode}: {httpRequestException.StatusCode.Value}";
}
if (ex is TaskCanceledException)
{
return "Timed out";
}
if (ex is OperationCanceledException)
{
return "Canceled";
}
return ex.Message;
}
@@ -1,57 +0,0 @@
using allstarr.Models.Settings;
namespace allstarr.Services.Common;
public static class SpotifyPlaylistScopeResolver
{
public static SpotifyPlaylistConfig? ResolveConfig(
SpotifyImportSettings settings,
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
{
if (!string.IsNullOrWhiteSpace(jellyfinPlaylistId))
{
var byJellyfinId = settings.GetPlaylistByJellyfinId(jellyfinPlaylistId.Trim());
if (byJellyfinId != null)
{
return byJellyfinId;
}
}
return settings.GetPlaylistByName(playlistName, userId, jellyfinPlaylistId);
}
public static string? GetUserId(SpotifyPlaylistConfig? playlist, string? fallbackUserId = null)
{
if (!string.IsNullOrWhiteSpace(playlist?.UserId))
{
return playlist.UserId.Trim();
}
// A configured playlist with no explicit owner is global. Do not
// accidentally scope its caches to whichever Jellyfin user made
// the current request.
if (playlist != null)
{
return null;
}
return string.IsNullOrWhiteSpace(fallbackUserId) ? null : fallbackUserId.Trim();
}
public static string? GetScopeId(SpotifyPlaylistConfig? playlist, string? fallbackScopeId = null)
{
if (!string.IsNullOrWhiteSpace(playlist?.JellyfinId))
{
return playlist.JellyfinId.Trim();
}
if (!string.IsNullOrWhiteSpace(playlist?.Id))
{
return playlist.Id.Trim();
}
return string.IsNullOrWhiteSpace(fallbackScopeId) ? null : fallbackScopeId.Trim();
}
}
@@ -14,6 +14,8 @@ public class VersionUpgradeRebuildService : IHostedService
private readonly SpotifyTrackMatchingService _matchingService;
private readonly SpotifyImportSettings _spotifyImportSettings;
private readonly ILogger<VersionUpgradeRebuildService> _logger;
private CancellationTokenSource? _backgroundRebuildCts;
private Task? _backgroundRebuildTask;
public VersionUpgradeRebuildService(
SpotifyTrackMatchingService matchingService,
@@ -53,15 +55,12 @@ public class VersionUpgradeRebuildService : IHostedService
}
else
{
_logger.LogInformation("Triggering full rebuild for all playlists after version upgrade");
try
{
await _matchingService.TriggerRebuildAllAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade");
}
_logger.LogInformation(
"Scheduling full rebuild for all playlists in background after version upgrade");
_backgroundRebuildCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_backgroundRebuildTask = RunBackgroundRebuildAsync(currentVersion, _backgroundRebuildCts.Token);
return;
}
}
else
@@ -76,7 +75,51 @@ public class VersionUpgradeRebuildService : IHostedService
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
return StopBackgroundRebuildAsync(cancellationToken);
}
private async Task RunBackgroundRebuildAsync(string currentVersion, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("Starting background full rebuild for all playlists after version upgrade");
await _matchingService.TriggerRebuildAllAsync(cancellationToken);
_logger.LogInformation("Background full rebuild after version upgrade completed");
await WriteCurrentVersionAsync(currentVersion, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("Background full rebuild after version upgrade was cancelled before completion");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade");
await WriteCurrentVersionAsync(currentVersion, CancellationToken.None);
}
}
private async Task StopBackgroundRebuildAsync(CancellationToken cancellationToken)
{
if (_backgroundRebuildTask == null)
{
return;
}
try
{
_backgroundRebuildCts?.Cancel();
await _backgroundRebuildTask.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
// Host shutdown is in progress or the background task observed cancellation.
}
finally
{
_backgroundRebuildCts?.Dispose();
_backgroundRebuildCts = null;
_backgroundRebuildTask = null;
}
}
private async Task<string?> ReadPreviousVersionAsync(CancellationToken cancellationToken)
@@ -1,165 +0,0 @@
using allstarr.Models.Domain;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Resolves "Appears on" albums for external artists.
/// Prefers provider-supplied album lists when they expose non-primary releases and
/// falls back to deriving albums from artist track payloads when needed.
/// </summary>
public class ExternalArtistAppearancesService
{
private readonly IMusicMetadataService _metadataService;
private readonly ILogger<ExternalArtistAppearancesService> _logger;
public ExternalArtistAppearancesService(
IMusicMetadataService metadataService,
ILogger<ExternalArtistAppearancesService> logger)
{
_metadataService = metadataService;
_logger = logger;
}
public async Task<List<Album>> GetAppearsOnAlbumsAsync(
string provider,
string externalId,
CancellationToken cancellationToken = default)
{
var artistTask = _metadataService.GetArtistAsync(provider, externalId, cancellationToken);
var albumsTask = _metadataService.GetArtistAlbumsAsync(provider, externalId, cancellationToken);
var tracksTask = _metadataService.GetArtistTracksAsync(provider, externalId, cancellationToken);
await Task.WhenAll(artistTask, albumsTask, tracksTask);
var artist = await artistTask;
if (artist == null || string.IsNullOrWhiteSpace(artist.Name))
{
_logger.LogDebug(
"No external artist metadata available for appears-on lookup: provider={Provider}, externalId={ExternalId}",
provider,
externalId);
return new List<Album>();
}
var allArtistAlbums = await albumsTask;
var artistTracks = await tracksTask;
var appearsOnAlbums = new Dictionary<string, Album>(StringComparer.OrdinalIgnoreCase);
var albumsById = allArtistAlbums
.Where(album => !string.IsNullOrWhiteSpace(album.Id))
.GroupBy(album => album.Id, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
foreach (var album in allArtistAlbums)
{
if (!IsKnownNonPrimaryAlbum(album, artist))
{
continue;
}
AddAlbumIfMissing(appearsOnAlbums, album);
}
foreach (var track in artistTracks)
{
var album = TryCreateAlbumFromTrack(provider, track, artist, albumsById);
if (album == null)
{
continue;
}
AddAlbumIfMissing(appearsOnAlbums, album);
}
var resolvedAlbums = appearsOnAlbums.Values
.OrderByDescending(album => album.Year ?? int.MinValue)
.ThenBy(album => album.Title, StringComparer.OrdinalIgnoreCase)
.ToList();
_logger.LogDebug(
"Resolved {Count} external appears-on albums for artist {ArtistId}",
resolvedAlbums.Count,
artist.Id);
return resolvedAlbums;
}
private static Album? TryCreateAlbumFromTrack(
string provider,
Song track,
Artist artist,
IReadOnlyDictionary<string, Album> albumsById)
{
if (string.IsNullOrWhiteSpace(track.AlbumId) || string.IsNullOrWhiteSpace(track.Album))
{
return null;
}
if (albumsById.TryGetValue(track.AlbumId, out var knownAlbum))
{
return IsKnownNonPrimaryAlbum(knownAlbum, artist) ? knownAlbum : null;
}
if (string.IsNullOrWhiteSpace(track.AlbumArtist) || NamesEqual(track.AlbumArtist, artist.Name))
{
return null;
}
return new Album
{
Id = track.AlbumId,
Title = track.Album,
Artist = track.AlbumArtist,
Year = track.Year,
SongCount = track.TotalTracks,
CoverArtUrl = track.CoverArtUrl,
IsLocal = false,
ExternalProvider = provider,
ExternalId = ExtractExternalAlbumId(track.AlbumId, provider)
};
}
private static bool IsKnownNonPrimaryAlbum(Album album, Artist artist)
{
if (!string.IsNullOrWhiteSpace(album.ArtistId) && !string.IsNullOrWhiteSpace(artist.Id))
{
return !string.Equals(album.ArtistId, artist.Id, StringComparison.OrdinalIgnoreCase);
}
return !string.IsNullOrWhiteSpace(album.Artist) && !NamesEqual(album.Artist, artist.Name);
}
private static void AddAlbumIfMissing(IDictionary<string, Album> albums, Album album)
{
var key = BuildAlbumKey(album);
if (!albums.ContainsKey(key))
{
albums[key] = album;
}
}
private static string BuildAlbumKey(Album album)
{
if (!string.IsNullOrWhiteSpace(album.Id))
{
return album.Id;
}
return $"{album.Title}\u001f{album.Artist}";
}
private static string? ExtractExternalAlbumId(string albumId, string provider)
{
var prefix = $"ext-{provider}-album-";
return albumId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? albumId[prefix.Length..]
: null;
}
private static bool NamesEqual(string? left, string? right)
{
return string.Equals(
left?.Trim(),
right?.Trim(),
StringComparison.OrdinalIgnoreCase);
}
}
@@ -24,7 +24,6 @@ public class JellyfinProxyService
private readonly HttpClient _httpClient;
private readonly JellyfinSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly JellyfinUserContextResolver _userContextResolver;
private readonly ILogger<JellyfinProxyService> _logger;
private readonly RedisCacheService _cache;
private string? _cachedMusicLibraryId;
@@ -37,35 +36,16 @@ public class JellyfinProxyService
IHttpClientFactory httpClientFactory,
IOptions<JellyfinSettings> settings,
IHttpContextAccessor httpContextAccessor,
JellyfinUserContextResolver userContextResolver,
ILogger<JellyfinProxyService> logger,
RedisCacheService cache)
{
_httpClient = httpClientFactory.CreateClient(HttpClientName);
_settings = settings.Value;
_httpContextAccessor = httpContextAccessor;
_userContextResolver = userContextResolver;
_logger = logger;
_cache = cache;
}
private async Task AddResolvedUserIdAsync(
Dictionary<string, string> queryParams,
IHeaderDictionary? clientHeaders = null,
bool allowConfigurationFallback = true)
{
if (queryParams.ContainsKey("userId") || queryParams.ContainsKey("UserId"))
{
return;
}
var userId = await _userContextResolver.ResolveCurrentUserIdAsync(clientHeaders, allowConfigurationFallback);
if (!string.IsNullOrWhiteSpace(userId))
{
queryParams["userId"] = userId;
}
}
/// <summary>
/// Gets the music library ID, auto-detecting it if not configured.
/// </summary>
@@ -211,10 +191,14 @@ public class JellyfinProxyService
{
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
// Always parse the response, even for errors
// The caller needs to see 401s so the client can re-authenticate
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
if (!isBrowserStaticRequest && !isPublicEndpoint)
@@ -223,22 +207,23 @@ public class JellyfinProxyService
}
// Try to parse error response to pass through to client
try
if (!string.IsNullOrWhiteSpace(content))
{
await using var errorStream = await response.Content.ReadAsStreamAsync();
var errorDoc = await JsonDocument.ParseAsync(errorStream);
return (errorDoc, statusCode);
}
catch (JsonException)
{
// Not valid JSON, return null
try
{
var errorDoc = JsonDocument.Parse(content);
return (errorDoc, statusCode);
}
catch
{
// Not valid JSON, return null
}
}
return (null, statusCode);
}
await using var stream = await response.Content.ReadAsStreamAsync();
return (await JsonDocument.ParseAsync(stream), statusCode);
return (JsonDocument.Parse(content), statusCode);
}
private HttpRequestMessage CreateClientGetRequest(
@@ -567,7 +552,10 @@ public class JellyfinProxyService
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds"
};
await AddResolvedUserIdAsync(queryParams, clientHeaders);
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
// Note: We don't force parentId here - let clients specify which library to search
// The controller will detect music library searches and add external results
@@ -614,7 +602,10 @@ public class JellyfinProxyService
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds,ParentId"
};
await AddResolvedUserIdAsync(queryParams, clientHeaders);
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
if (!string.IsNullOrEmpty(parentId))
{
@@ -656,7 +647,10 @@ public class JellyfinProxyService
{
var queryParams = new Dictionary<string, string>();
await AddResolvedUserIdAsync(queryParams, clientHeaders);
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
return await GetJsonAsync($"Items/{itemId}", queryParams, clientHeaders);
}
@@ -675,7 +669,10 @@ public class JellyfinProxyService
["fields"] = "PrimaryImageAspectRatio,Genres,Overview"
};
await AddResolvedUserIdAsync(queryParams, clientHeaders);
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
if (!string.IsNullOrEmpty(searchTerm))
{
@@ -702,7 +699,10 @@ public class JellyfinProxyService
{
var queryParams = new Dictionary<string, string>();
await AddResolvedUserIdAsync(queryParams, clientHeaders);
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
// Try to get by ID first
if (Guid.TryParse(artistIdOrName, out _))
@@ -893,7 +893,10 @@ public class JellyfinProxyService
try
{
var queryParams = new Dictionary<string, string>();
await AddResolvedUserIdAsync(queryParams);
if (!string.IsNullOrEmpty(_settings.UserId))
{
queryParams["userId"] = _settings.UserId;
}
var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams);
if (result == null)
@@ -1014,12 +1017,12 @@ public class JellyfinProxyService
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
var content = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
statusCode, url, content);
return (null, statusCode);
@@ -1027,13 +1030,12 @@ public class JellyfinProxyService
try
{
await using var stream = await response.Content.ReadAsStreamAsync();
var jsonDocument = await JsonDocument.ParseAsync(stream);
var jsonDocument = JsonDocument.Parse(content);
return (jsonDocument, statusCode);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse JSON response from {Url}", url);
_logger.LogError(ex, "Failed to parse JSON response from {Url}: {Content}", url, content);
return (null, statusCode);
}
}
@@ -1,11 +1,10 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using allstarr.Models.Jellyfin;
using allstarr.Models.Settings;
using allstarr.Serialization;
namespace allstarr.Services.Jellyfin;
@@ -186,21 +185,21 @@ public class JellyfinSessionManager : IDisposable
/// </summary>
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
{
var capabilities = new JellyfinSessionCapabilitiesPayload
var capabilities = new
{
PlayableMediaTypes = ["Audio"],
SupportedCommands =
[
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = new[]
{
"Play",
"Playstate",
"PlayNext"
],
},
SupportsMediaControl = true,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var json = AllstarrJsonSerializer.Serialize(capabilities);
var json = JsonSerializer.Serialize(capabilities);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
if (statusCode == 204 || statusCode == 200)
@@ -456,12 +455,12 @@ public class JellyfinSessionManager : IDisposable
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
{
var stopPayload = new JellyfinPlaybackStatePayload
var stopPayload = new
{
ItemId = session.LastPlayingItemId,
PositionTicks = session.LastPlayingPositionTicks ?? 0
};
var stopJson = AllstarrJsonSerializer.Serialize(stopPayload);
var stopJson = JsonSerializer.Serialize(stopPayload);
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
@@ -1,155 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using allstarr.Models.Settings;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Resolves the effective Jellyfin user for the current request.
/// Prefers explicit request/session context and falls back to the legacy configured user id.
/// </summary>
public class JellyfinUserContextResolver
{
private static readonly TimeSpan TokenLookupCacheTtl = TimeSpan.FromMinutes(5);
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IHttpClientFactory _httpClientFactory;
private readonly JellyfinSettings _settings;
private readonly IMemoryCache _memoryCache;
private readonly ILogger<JellyfinUserContextResolver> _logger;
public JellyfinUserContextResolver(
IHttpContextAccessor httpContextAccessor,
IHttpClientFactory httpClientFactory,
IOptions<JellyfinSettings> settings,
IMemoryCache memoryCache,
ILogger<JellyfinUserContextResolver> logger)
{
_httpContextAccessor = httpContextAccessor;
_httpClientFactory = httpClientFactory;
_settings = settings.Value;
_memoryCache = memoryCache;
_logger = logger;
}
public async Task<string?> ResolveCurrentUserIdAsync(
IHeaderDictionary? headers = null,
bool allowConfigurationFallback = true,
CancellationToken cancellationToken = default)
{
var httpContext = _httpContextAccessor.HttpContext;
var request = httpContext?.Request;
headers ??= request?.Headers;
var explicitUserId = request?.RouteValues["userId"]?.ToString();
if (string.IsNullOrWhiteSpace(explicitUserId))
{
explicitUserId = request?.Query["userId"].ToString();
}
if (!string.IsNullOrWhiteSpace(explicitUserId))
{
return explicitUserId.Trim();
}
if (httpContext?.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) == true &&
sessionObj is AdminAuthSession session &&
!string.IsNullOrWhiteSpace(session.UserId))
{
return session.UserId.Trim();
}
if (headers != null)
{
var headerUserId = AuthHeaderHelper.ExtractUserId(headers);
if (!string.IsNullOrWhiteSpace(headerUserId))
{
return headerUserId.Trim();
}
var token = AuthHeaderHelper.ExtractAccessToken(headers);
if (!string.IsNullOrWhiteSpace(token))
{
var cacheKey = BuildTokenCacheKey(token);
if (_memoryCache.TryGetValue(cacheKey, out string? cachedUserId) &&
!string.IsNullOrWhiteSpace(cachedUserId))
{
return cachedUserId;
}
var resolvedUserId = await ResolveUserIdFromJellyfinAsync(headers, cancellationToken);
if (!string.IsNullOrWhiteSpace(resolvedUserId))
{
_memoryCache.Set(cacheKey, resolvedUserId.Trim(), TokenLookupCacheTtl);
return resolvedUserId.Trim();
}
}
}
if (allowConfigurationFallback && !string.IsNullOrWhiteSpace(_settings.UserId))
{
_logger.LogDebug("Falling back to configured Jellyfin user id for current request scope");
return _settings.UserId.Trim();
}
return null;
}
private async Task<string?> ResolveUserIdFromJellyfinAsync(
IHeaderDictionary headers,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_settings.Url))
{
return null;
}
try
{
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"{_settings.Url.TrimEnd('/')}/Users/Me");
if (!AuthHeaderHelper.ForwardAuthHeaders(headers, request))
{
return null;
}
request.Headers.Accept.ParseAdd("application/json");
var client = _httpClientFactory.CreateClient(JellyfinProxyService.HttpClientName);
using var response = await client.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogDebug("Failed to resolve Jellyfin user from token via /Users/Me: {StatusCode}",
response.StatusCode);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
if (doc.RootElement.TryGetProperty("Id", out var idProp))
{
var userId = idProp.GetString();
return string.IsNullOrWhiteSpace(userId) ? null : userId.Trim();
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error resolving Jellyfin user from auth token");
}
return null;
}
private static string BuildTokenCacheKey(string token)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
return $"jellyfin:user-from-token:{Convert.ToHexString(hash)}";
}
}
@@ -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; }
}
}
+102 -20
View File
@@ -1026,26 +1026,7 @@ public class SpotifyApiClient : IDisposable
continue;
}
// Get track count if available - try multiple possible paths
var trackCount = 0;
if (playlist.TryGetProperty("content", out var content))
{
if (content.TryGetProperty("totalCount", out var totalTrackCount))
{
trackCount = totalTrackCount.GetInt32();
}
}
// Fallback: try attributes.itemCount
else if (playlist.TryGetProperty("attributes", out var attributes) &&
attributes.TryGetProperty("itemCount", out var itemCountProp))
{
trackCount = itemCountProp.GetInt32();
}
// Fallback: try totalCount directly
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
{
trackCount = directTotalCount.GetInt32();
}
var trackCount = TryGetSpotifyPlaylistItemCount(playlist);
// Log if we couldn't find track count for debugging
if (trackCount == 0)
@@ -1057,7 +1038,9 @@ public class SpotifyApiClient : IDisposable
// Get owner name
string? ownerName = null;
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
ownerV2.ValueKind == JsonValueKind.Object &&
ownerV2.TryGetProperty("data", out var ownerData) &&
ownerData.ValueKind == JsonValueKind.Object &&
ownerData.TryGetProperty("username", out var ownerNameProp))
{
ownerName = ownerNameProp.GetString();
@@ -1066,11 +1049,14 @@ public class SpotifyApiClient : IDisposable
// Get image URL
string? imageUrl = null;
if (playlist.TryGetProperty("images", out var images) &&
images.ValueKind == JsonValueKind.Object &&
images.TryGetProperty("items", out var imageItems) &&
imageItems.ValueKind == JsonValueKind.Array &&
imageItems.GetArrayLength() > 0)
{
var firstImage = imageItems[0];
if (firstImage.TryGetProperty("sources", out var sources) &&
sources.ValueKind == JsonValueKind.Array &&
sources.GetArrayLength() > 0)
{
var firstSource = sources[0];
@@ -1165,6 +1151,68 @@ public class SpotifyApiClient : IDisposable
return null;
}
private static int TryGetSpotifyPlaylistItemCount(JsonElement playlistElement)
{
if (playlistElement.TryGetProperty("content", out var content) &&
content.ValueKind == JsonValueKind.Object &&
content.TryGetProperty("totalCount", out var totalTrackCount) &&
TryParseSpotifyIntegerElement(totalTrackCount, out var contentCount))
{
return contentCount;
}
if (playlistElement.TryGetProperty("attributes", out var attributes))
{
if (attributes.ValueKind == JsonValueKind.Object &&
attributes.TryGetProperty("itemCount", out var itemCountProp) &&
TryParseSpotifyIntegerElement(itemCountProp, out var directAttributeCount))
{
return directAttributeCount;
}
if (attributes.ValueKind == JsonValueKind.Array)
{
foreach (var attribute in attributes.EnumerateArray())
{
if (attribute.ValueKind != JsonValueKind.Object ||
!attribute.TryGetProperty("key", out var keyProp) ||
keyProp.ValueKind != JsonValueKind.String ||
!attribute.TryGetProperty("value", out var valueProp))
{
continue;
}
var key = keyProp.GetString();
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
var normalizedKey = key.Replace("_", "", StringComparison.OrdinalIgnoreCase)
.Replace(":", "", StringComparison.OrdinalIgnoreCase);
if (!normalizedKey.Contains("itemcount", StringComparison.OrdinalIgnoreCase) &&
!normalizedKey.Contains("trackcount", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (TryParseSpotifyIntegerElement(valueProp, out var attributeCount))
{
return attributeCount;
}
}
}
}
if (playlistElement.TryGetProperty("totalCount", out var directTotalCount) &&
TryParseSpotifyIntegerElement(directTotalCount, out var totalCount))
{
return totalCount;
}
return 0;
}
private static DateTime? ParseSpotifyDateElement(JsonElement value)
{
switch (value.ValueKind)
@@ -1238,6 +1286,40 @@ public class SpotifyApiClient : IDisposable
return null;
}
private static bool TryParseSpotifyIntegerElement(JsonElement value, out int parsed)
{
switch (value.ValueKind)
{
case JsonValueKind.Number:
return value.TryGetInt32(out parsed);
case JsonValueKind.String:
return int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
case JsonValueKind.Object:
if (value.TryGetProperty("value", out var nestedValue) &&
TryParseSpotifyIntegerElement(nestedValue, out parsed))
{
return true;
}
if (value.TryGetProperty("itemCount", out var itemCount) &&
TryParseSpotifyIntegerElement(itemCount, out parsed))
{
return true;
}
if (value.TryGetProperty("totalCount", out var totalCount) &&
TryParseSpotifyIntegerElement(totalCount, out parsed))
{
return true;
}
break;
}
parsed = 0;
return false;
}
private static DateTime? ParseSpotifyUnixTimestamp(long value)
{
try
@@ -29,7 +29,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
private readonly RedisCacheService _cache;
// Track Spotify playlist IDs after discovery
private readonly Dictionary<string, string> _playlistScopeToSpotifyId = new();
private readonly Dictionary<string, string> _playlistNameToSpotifyId = new();
public SpotifyPlaylistFetcher(
ILogger<SpotifyPlaylistFetcher> logger,
@@ -55,20 +55,10 @@ public class SpotifyPlaylistFetcher : BackgroundService
/// </summary>
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(string playlistName)
{
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifyImportSettings,
playlistName,
userId,
jellyfinPlaylistId);
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, jellyfinPlaylistId);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName, playlistScopeUserId, playlistScopeId);
var playlistScope = CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, playlistScopeUserId, playlistScopeId);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
// Try Redis cache first
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
@@ -134,14 +124,14 @@ public class SpotifyPlaylistFetcher : BackgroundService
try
{
// Try to use cached or configured Spotify playlist ID
if (!_playlistScopeToSpotifyId.TryGetValue(playlistScope, out var spotifyId))
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
{
// Check if we have a configured Spotify ID for this playlist
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
{
// Use the configured Spotify playlist ID directly
spotifyId = playlistConfig.Id;
_playlistScopeToSpotifyId[playlistScope] = spotifyId;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
}
else
@@ -160,7 +150,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
spotifyId = exactMatch.SpotifyId;
_playlistScopeToSpotifyId[playlistScope] = spotifyId;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
}
}
@@ -236,8 +226,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
string playlistName,
HashSet<string> jellyfinTrackIds)
{
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
var allTracks = await GetPlaylistTracksAsync(playlistName, playlistConfig?.UserId, playlistConfig?.JellyfinId);
var allTracks = await GetPlaylistTracksAsync(playlistName);
// Filter to only tracks not in Jellyfin, preserving order
return allTracks
@@ -248,30 +237,17 @@ public class SpotifyPlaylistFetcher : BackgroundService
/// <summary>
/// Manual trigger to refresh a specific playlist.
/// </summary>
public async Task RefreshPlaylistAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
public async Task RefreshPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
// Clear cache to force refresh
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifyImportSettings,
playlistName,
userId,
jellyfinPlaylistId);
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, jellyfinPlaylistId);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
await _cache.DeleteAsync(cacheKey);
// Re-fetch
await GetPlaylistTracksAsync(playlistName, playlistScopeUserId, playlistConfig?.JellyfinId ?? jellyfinPlaylistId);
await ClearPlaylistImageCacheAsync(playlistName, userId, jellyfinPlaylistId);
await GetPlaylistTracksAsync(playlistName);
await ClearPlaylistImageCacheAsync(playlistName);
}
/// <summary>
@@ -283,20 +259,13 @@ public class SpotifyPlaylistFetcher : BackgroundService
foreach (var config in _spotifyImportSettings.Playlists)
{
await RefreshPlaylistAsync(config.Name, config.UserId, config.JellyfinId);
await RefreshPlaylistAsync(config.Name);
}
}
private async Task ClearPlaylistImageCacheAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
private async Task ClearPlaylistImageCacheAsync(string playlistName)
{
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
_spotifyImportSettings,
playlistName,
userId,
jellyfinPlaylistId);
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
if (playlistConfig == null || string.IsNullOrWhiteSpace(playlistConfig.JellyfinId))
{
return;
@@ -362,7 +331,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
{
// Check each playlist to see if it needs refreshing based on cron schedule
var now = DateTime.UtcNow;
var needsRefresh = new List<SpotifyPlaylistConfig>();
var needsRefresh = new List<string>();
foreach (var config in _spotifyImportSettings.Playlists)
{
@@ -373,10 +342,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
var cron = CronExpression.Parse(schedule);
// Check if we have cached data
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
config.Name,
config.UserId,
config.JellyfinId ?? config.Id);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(config.Name);
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
if (cached != null)
@@ -386,7 +352,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (nextRun.HasValue && now >= nextRun.Value)
{
needsRefresh.Add(config);
needsRefresh.Add(config.Name);
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
}
@@ -394,7 +360,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
else
{
// No cache, fetch it
needsRefresh.Add(config);
needsRefresh.Add(config.Name);
}
}
catch (Exception ex)
@@ -408,24 +374,24 @@ public class SpotifyPlaylistFetcher : BackgroundService
{
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
foreach (var config in needsRefresh)
foreach (var playlistName in needsRefresh)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
await GetPlaylistTracksAsync(playlistName);
// Rate limiting between playlists
if (!ReferenceEquals(config, needsRefresh.Last()))
if (playlistName != needsRefresh.Last())
{
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", config.Name);
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", playlistName);
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching playlist '{Name}'", config.Name);
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
}
}
@@ -453,7 +419,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
try
{
var tracks = await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
var tracks = await GetPlaylistTracksAsync(config.Name);
_logger.LogDebug(" {Name}: {Count} tracks", config.Name, tracks.Count);
// Log sample of track order for debugging
@@ -1,7 +1,6 @@
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Admin;
using allstarr.Services.Common;
using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Http;
@@ -39,6 +38,7 @@ public class SpotifyTrackMatchingService : BackgroundService
private readonly IServiceProvider _serviceProvider;
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
private static readonly TimeSpan ExternalProviderSearchTimeout = TimeSpan.FromSeconds(30);
// Track last run time per playlist to prevent duplicate runs
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
@@ -73,24 +73,6 @@ public class SpotifyTrackMatchingService : BackgroundService
return true;
}
private static string? GetPlaylistScopeUserId(SpotifyPlaylistConfig? playlist) =>
SpotifyPlaylistScopeResolver.GetUserId(playlist);
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist) =>
SpotifyPlaylistScopeResolver.GetScopeId(playlist);
private SpotifyPlaylistConfig? ResolvePlaylistConfig(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null) =>
SpotifyPlaylistScopeResolver.ResolveConfig(_spotifySettings, playlistName, userId, jellyfinPlaylistId);
private static string BuildPlaylistRunKey(SpotifyPlaylistConfig playlist) =>
CacheKeyBuilder.BuildSpotifyPlaylistScope(
playlist.Name,
GetPlaylistScopeUserId(playlist),
GetPlaylistScopeId(playlist));
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
@@ -140,7 +122,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Use a small grace window so we don't miss exact-minute cron runs when waking slightly late.
var now = DateTime.UtcNow;
var schedulerReference = now.AddMinutes(-1);
var nextRuns = new List<(SpotifyPlaylistConfig Playlist, DateTime NextRun, CronExpression Cron)>();
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
foreach (var playlist in _spotifySettings.Playlists)
{
@@ -153,7 +135,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (nextRun.HasValue)
{
nextRuns.Add((playlist, nextRun.Value, cron));
nextRuns.Add((playlist.Name, nextRun.Value, cron));
}
else
{
@@ -188,7 +170,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var waitTime = nextPlaylist.NextRun - now;
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
nextPlaylist.Playlist.Name, nextPlaylist.NextRun, waitTime.TotalMinutes);
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
var maxWait = TimeSpan.FromHours(1);
var actualWait = waitTime > maxWait ? maxWait : waitTime;
@@ -209,10 +191,10 @@ public class SpotifyTrackMatchingService : BackgroundService
break;
}
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.Playlist.Name);
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.PlaylistName);
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
due.Playlist,
due.PlaylistName,
stoppingToken,
trigger: "cron");
@@ -223,7 +205,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
_logger.LogInformation("✓ Finished scheduled rebuild for {Playlist} - Next run at {NextRun} UTC",
due.Playlist.Name, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
due.PlaylistName, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
}
// Avoid a tight loop if one or more due playlists were skipped by cooldown.
@@ -244,24 +226,29 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches).
/// Used by individual per-playlist rebuild actions.
/// </summary>
private async Task RebuildSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
{
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
var playlistScopeId = GetPlaylistScopeId(playlist);
var playlistName = playlist.Name;
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
_logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName);
// Clear cache for this playlist (same as "Rebuild All Remote" button)
var keysToDelete = new[]
{
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name, playlistScopeUserId, playlistScopeId),
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name, playlistScopeUserId, playlistScopeId)
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name), // Legacy key
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name),
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name)
};
foreach (var key in keysToDelete)
@@ -282,7 +269,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (playlistFetcher != null)
{
// Force refresh from Spotify (clears cache and re-fetches)
await playlistFetcher.RefreshPlaylistAsync(playlist.Name, playlistScopeUserId, playlist.JellyfinId);
await playlistFetcher.RefreshPlaylistAsync(playlist.Name);
}
}
@@ -294,13 +281,13 @@ public class SpotifyTrackMatchingService : BackgroundService
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist, playlistFetcher, metadataService, cancellationToken);
playlist.Name, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist, metadataService, cancellationToken);
playlist.Name, metadataService, cancellationToken);
}
}
catch (Exception ex)
@@ -317,9 +304,16 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify.
/// Used for lightweight re-matching when only local library has changed.
/// </summary>
private async Task MatchSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
{
var playlistName = playlist.Name;
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
using var scope = _serviceProvider.CreateScope();
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
@@ -337,13 +331,13 @@ public class SpotifyTrackMatchingService : BackgroundService
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist, playlistFetcher, metadataService, cancellationToken);
playlist.Name, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist, metadataService, cancellationToken);
playlist.Name, metadataService, cancellationToken);
}
await ClearPlaylistImageCacheAsync(playlist);
@@ -372,38 +366,27 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
/// This clears caches, fetches fresh data, and re-matches everything immediately.
/// </summary>
public async Task TriggerRebuildAllAsync()
public async Task TriggerRebuildAllAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Manual full rebuild triggered for all playlists");
await RebuildAllPlaylistsAsync(CancellationToken.None);
_logger.LogInformation("Full rebuild triggered for all playlists");
await RebuildAllPlaylistsAsync(cancellationToken);
}
/// <summary>
/// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button).
/// This clears cache, fetches fresh data, and re-matches - same workflow as scheduled cron rebuilds for a playlist.
/// </summary>
public async Task TriggerRebuildForPlaylistAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
public async Task TriggerRebuildForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist}", playlistName);
var playlist = ResolvePlaylistConfig(playlistName, userId, jellyfinPlaylistId);
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
playlist,
playlistName,
CancellationToken.None,
trigger: "manual");
if (!rebuilt)
{
var runKey = BuildPlaylistRunKey(playlist);
if (_lastRunTimes.TryGetValue(runKey, out var lastRun))
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
var remaining = _minimumRunInterval - timeSinceLastRun;
@@ -417,12 +400,11 @@ public class SpotifyTrackMatchingService : BackgroundService
}
private async Task<bool> TryRunSinglePlaylistRebuildWithCooldownAsync(
SpotifyPlaylistConfig playlist,
string playlistName,
CancellationToken cancellationToken,
string trigger)
{
var runKey = BuildPlaylistRunKey(playlist);
if (_lastRunTimes.TryGetValue(runKey, out var lastRun))
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
@@ -430,15 +412,15 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogWarning(
"Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
trigger,
playlist.Name,
playlistName,
(int)timeSinceLastRun.TotalSeconds,
(int)_minimumRunInterval.TotalSeconds);
return false;
}
}
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
_lastRunTimes[runKey] = DateTime.UtcNow;
await RebuildSinglePlaylistAsync(playlistName, cancellationToken);
_lastRunTimes[playlistName] = DateTime.UtcNow;
return true;
}
@@ -458,23 +440,14 @@ public class SpotifyTrackMatchingService : BackgroundService
/// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify.
/// Use this when only the local library has changed, not when Spotify playlist changed.
/// </summary>
public async Task TriggerMatchingForPlaylistAsync(
string playlistName,
string? userId = null,
string? jellyfinPlaylistId = null)
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (lightweight, no cache clear)", playlistName);
var playlist = ResolvePlaylistConfig(playlistName, userId, jellyfinPlaylistId);
if (playlist == null)
{
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
// Intentionally no cooldown here: this path should react immediately to
// local library changes and manual mapping updates without waiting for
// Spotify API cooldown windows.
await MatchSinglePlaylistAsync(playlist, CancellationToken.None);
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
}
private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken)
@@ -494,7 +467,7 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
await RebuildSinglePlaylistAsync(playlist.Name, cancellationToken);
}
catch (Exception ex)
{
@@ -522,7 +495,7 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
await MatchSinglePlaylistAsync(playlist, cancellationToken);
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
}
catch (Exception ex)
{
@@ -540,25 +513,15 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Uses GREEDY ASSIGNMENT to maximize total matches.
/// </summary>
private async Task MatchPlaylistTracksWithIsrcAsync(
SpotifyPlaylistConfig playlistConfig,
string playlistName,
SpotifyPlaylistFetcher playlistFetcher,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var playlist = playlistConfig ?? throw new ArgumentNullException(nameof(playlistConfig));
var playlistName = playlist.Name;
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
var playlistScopeId = GetPlaylistScopeId(playlist);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
// Get playlist tracks with full metadata including ISRC and position
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(
playlistName,
playlistScopeUserId,
playlist.JellyfinId);
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
if (spotifyTracks.Count == 0)
{
_logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName);
@@ -566,10 +529,12 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// Get the Jellyfin playlist ID to check which tracks already exist
var playlistConfig = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
HashSet<string> existingSpotifyIds = new();
if (!string.IsNullOrEmpty(playlist.JellyfinId))
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
{
// Get existing tracks from Jellyfin playlist to avoid re-matching
using var scope = _serviceProvider.CreateScope();
@@ -581,9 +546,8 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
var userId = playlist.UserId ?? jellyfinSettings.UserId;
var jellyfinPlaylistId = playlist.JellyfinId;
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
var userId = jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(userId))
{
@@ -665,18 +629,10 @@ public class SpotifyTrackMatchingService : BackgroundService
foreach (var track in tracksToMatch)
{
// Check if this track has a manual mapping but isn't in the cached results
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
playlistName,
track.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, track.SpotifyId);
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
playlistName,
track.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, track.SpotifyId);
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
@@ -704,7 +660,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin)
var jellyfinTracks = new List<Song>();
if (!string.IsNullOrEmpty(playlist.JellyfinId))
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
{
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
@@ -715,9 +671,8 @@ public class SpotifyTrackMatchingService : BackgroundService
{
try
{
var userId = playlist.UserId ?? jellyfinSettings.UserId;
var jellyfinPlaylistId = playlist.JellyfinId;
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
var userId = jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields };
if (!string.IsNullOrEmpty(userId))
{
@@ -819,11 +774,28 @@ public class SpotifyTrackMatchingService : BackgroundService
if (cancellationToken.IsCancellationRequested) break;
var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList();
var batchStart = i + 1;
var batchEnd = i + batch.Count;
var batchStopwatch = System.Diagnostics.Stopwatch.StartNew();
_logger.LogInformation(
"Starting external matching batch for {Playlist}: tracks {Start}-{End}/{Total}",
playlistName,
batchStart,
batchEnd,
unmatchedSpotifyTracks.Count);
var batchTasks = batch.Select(async spotifyTrack =>
{
var primaryArtist = spotifyTrack.PrimaryArtist;
var trackStopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(ExternalProviderSearchTimeout);
var trackCancellationToken = timeoutCts.Token;
var candidates = new List<(Song Song, double Score, string MatchType)>();
// Check global external mapping first
@@ -835,12 +807,23 @@ public class SpotifyTrackMatchingService : BackgroundService
if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) &&
!string.IsNullOrEmpty(globalMapping.ExternalId))
{
mappedSong = await metadataService.GetSongAsync(globalMapping.ExternalProvider, globalMapping.ExternalId);
mappedSong = await metadataService.GetSongAsync(
globalMapping.ExternalProvider,
globalMapping.ExternalId,
trackCancellationToken);
}
if (mappedSong != null)
{
candidates.Add((mappedSong, 100.0, "global-mapping-external"));
trackStopwatch.Stop();
_logger.LogDebug(
"External candidate search finished for {Playlist} track #{Position}: {Title} by {Artist} in {ElapsedMs}ms using global mapping",
playlistName,
spotifyTrack.Position,
spotifyTrack.Title,
primaryArtist,
trackStopwatch.ElapsedMilliseconds);
return (spotifyTrack, candidates);
}
}
@@ -848,10 +831,31 @@ public class SpotifyTrackMatchingService : BackgroundService
// Try ISRC match
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
{
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
if (isrcSong != null)
try
{
candidates.Add((isrcSong, 100.0, "isrc"));
var isrcSong = await TryMatchByIsrcAsync(
spotifyTrack.Isrc,
metadataService,
trackCancellationToken);
if (isrcSong != null)
{
candidates.Add((isrcSong, 100.0, "isrc"));
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"ISRC lookup failed for {Playlist} track #{Position}: {Title} by {Artist}",
playlistName,
spotifyTrack.Position,
spotifyTrack.Title,
primaryArtist);
}
}
@@ -859,7 +863,8 @@ public class SpotifyTrackMatchingService : BackgroundService
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
spotifyTrack.Title,
spotifyTrack.Artists,
metadataService);
metadataService,
trackCancellationToken);
foreach (var (song, score) in fuzzySongs)
{
@@ -869,16 +874,48 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
trackStopwatch.Stop();
_logger.LogDebug(
"External candidate search finished for {Playlist} track #{Position}: {Title} by {Artist} in {ElapsedMs}ms with {CandidateCount} candidates",
playlistName,
spotifyTrack.Position,
spotifyTrack.Title,
primaryArtist,
trackStopwatch.ElapsedMilliseconds,
candidates.Count);
return (spotifyTrack, candidates);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return (spotifyTrack, new List<(Song, double, string)>());
}
catch (OperationCanceledException)
{
_logger.LogWarning(
"External candidate search timed out for {Playlist} track #{Position}: {Title} by {Artist} after {TimeoutSeconds}s",
playlistName,
spotifyTrack.Position,
spotifyTrack.Title,
primaryArtist,
ExternalProviderSearchTimeout.TotalSeconds);
return (spotifyTrack, new List<(Song, double, string)>());
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to match track: {Title}", spotifyTrack.Title);
_logger.LogError(
ex,
"Failed to match track for {Playlist} track #{Position}: {Title} by {Artist}",
playlistName,
spotifyTrack.Position,
spotifyTrack.Title,
primaryArtist);
return (spotifyTrack, new List<(Song, double, string)>());
}
}).ToList();
var batchResults = await Task.WhenAll(batchTasks);
batchStopwatch.Stop();
foreach (var result in batchResults)
{
@@ -888,6 +925,16 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
var batchCandidateCount = batchResults.Sum(result => result.Item2.Count);
_logger.LogInformation(
"Finished external matching batch for {Playlist}: tracks {Start}-{End}/{Total} in {ElapsedMs}ms ({CandidateCount} candidates)",
playlistName,
batchStart,
batchEnd,
unmatchedSpotifyTracks.Count,
batchStopwatch.ElapsedMilliseconds,
batchCandidateCount);
if (i + BatchSize < unmatchedSpotifyTracks.Count)
{
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
@@ -988,19 +1035,19 @@ public class SpotifyTrackMatchingService : BackgroundService
["missing"] = statsMissingCount
};
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlistName);
await _cache.SetAsync(statsCacheKey, stats, TimeSpan.FromMinutes(30));
_logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing",
playlistName, statsLocalCount, statsExternalCount, statsMissingCount);
// Calculate cache expiration: until next cron run (not just cache duration from settings)
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
if (!string.IsNullOrEmpty(playlist.SyncSchedule))
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
{
try
{
@@ -1027,13 +1074,10 @@ public class SpotifyTrackMatchingService : BackgroundService
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
// Save matched tracks to file for persistence across restarts
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks, playlistScopeUserId, playlistScopeId);
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
// Also update legacy cache for backward compatibility
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName);
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
@@ -1043,7 +1087,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Pre-build playlist items cache for instant serving
// This is what makes the UI show all matched tracks at once
await PreBuildPlaylistItemsCacheAsync(playlistName, playlist.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
}
else
{
@@ -1063,140 +1107,136 @@ public class SpotifyTrackMatchingService : BackgroundService
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
string title,
List<string> artists,
IMusicMetadataService metadataService)
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
try
var primaryArtist = artists.FirstOrDefault() ?? "";
var titleStripped = FuzzyMatcher.StripDecorators(title);
var query = $"{titleStripped} {primaryArtist}";
var allCandidates = new List<(Song Song, double Score)>();
// STEP 1: Search LOCAL Jellyfin library FIRST
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
if (proxyService != null)
{
var primaryArtist = artists.FirstOrDefault() ?? "";
var titleStripped = FuzzyMatcher.StripDecorators(title);
var query = $"{titleStripped} {primaryArtist}";
var allCandidates = new List<(Song Song, double Score)>();
// STEP 1: Search LOCAL Jellyfin library FIRST
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
if (proxyService != null)
try
{
try
// Search Jellyfin for local tracks
var searchParams = new Dictionary<string, string>
{
// Search Jellyfin for local tracks
var searchParams = new Dictionary<string, string>
{
["searchTerm"] = query,
["includeItemTypes"] = "Audio",
["recursive"] = "true",
["limit"] = "10"
};
["searchTerm"] = query,
["includeItemTypes"] = "Audio",
["recursive"] = "true",
["limit"] = "10"
};
var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
{
var localResults = new List<Song>();
foreach (var item in items.EnumerateArray())
{
var localResults = new List<Song>();
foreach (var item in items.EnumerateArray())
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
localResults.Add(new Song
{
Id = id,
Title = songTitle,
Artist = artist,
IsLocal = true
});
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
if (localResults.Count > 0)
localResults.Add(new Song
{
// Score local results
var scoredLocal = localResults
.Select(song => new
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.Where(x =>
x.TotalScore >= 40 ||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
x.TitleScore >= 85)
.OrderByDescending(x => x.TotalScore)
.Select(x => (x.Song, x.TotalScore))
.ToList();
Id = id,
Title = songTitle,
Artist = artist,
IsLocal = true
});
}
allCandidates.AddRange(scoredLocal);
// If we found good local matches, return them (don't search external)
if (scoredLocal.Any(x => x.TotalScore >= 70))
if (localResults.Count > 0)
{
// Score local results
var scoredLocal = localResults
.Select(song => new
{
_logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search",
scoredLocal.Count, title);
return allCandidates;
}
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.Where(x =>
x.TotalScore >= 40 ||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
x.TitleScore >= 85)
.OrderByDescending(x => x.TotalScore)
.Select(x => (x.Song, x.TotalScore))
.ToList();
allCandidates.AddRange(scoredLocal);
// If we found good local matches, return them (don't search external)
if (scoredLocal.Any(x => x.TotalScore >= 70))
{
_logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search",
scoredLocal.Count, title);
return allCandidates;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to search local library for '{Title}'", title);
}
}
// STEP 2: Only search EXTERNAL if no good local match found
var externalResults = await metadataService.SearchSongsAsync(query, limit: 10);
if (externalResults.Count > 0)
catch (Exception ex)
{
var scoredExternal = externalResults
.Select(song => new
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.Where(x =>
x.TotalScore >= 40 ||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
x.TitleScore >= 85)
.OrderByDescending(x => x.TotalScore)
.Select(x => (x.Song, x.TotalScore))
.ToList();
allCandidates.AddRange(scoredExternal);
_logger.LogWarning(ex, "Failed to search local library for '{Title}'", title);
}
}
return allCandidates;
}
catch
cancellationToken.ThrowIfCancellationRequested();
// STEP 2: Only search EXTERNAL if no good local match found
var externalResults = await metadataService.SearchSongsAsync(query, limit: 10, cancellationToken);
if (externalResults.Count > 0)
{
return new List<(Song, double)>();
var scoredExternal = externalResults
.Select(song => new
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.Where(x =>
x.TotalScore >= 40 ||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
x.TitleScore >= 85)
.OrderByDescending(x => x.TotalScore)
.Select(x => (x.Song, x.TotalScore))
.ToList();
allCandidates.AddRange(scoredExternal);
}
return allCandidates;
}
private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist)
@@ -1210,21 +1250,19 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Attempts to match a track by ISRC.
/// SEARCHES LOCAL FIRST, then external if no local match found.
/// </summary>
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
private async Task<Song?> TryMatchByIsrcAsync(
string isrc,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
try
{
// STEP 1: Search LOCAL Jellyfin library FIRST by ISRC
// Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search
// Local tracks will be found via fuzzy matching instead
// STEP 1: Search LOCAL Jellyfin library FIRST by ISRC
// Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search
// Local tracks will be found via fuzzy matching instead
// STEP 2: Search EXTERNAL by ISRC
return await metadataService.FindSongByIsrcAsync(isrc);
}
catch
{
return null;
}
cancellationToken.ThrowIfCancellationRequested();
// STEP 2: Search EXTERNAL by ISRC
return await metadataService.FindSongByIsrcAsync(isrc, cancellationToken);
}
/// <summary>
@@ -1314,21 +1352,12 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
/// </summary>
private async Task MatchPlaylistTracksLegacyAsync(
SpotifyPlaylistConfig playlistConfig,
string playlistName,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var playlistName = playlistConfig.Name;
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName);
// Check if we already have matched tracks cached
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
@@ -1435,10 +1464,6 @@ public class SpotifyTrackMatchingService : BackgroundService
{
try
{
var playlistConfig = _spotifySettings.GetPlaylistByName(playlistName, jellyfinPlaylistId: jellyfinPlaylistId);
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
var playlistScopeId = GetPlaylistScopeId(playlistConfig) ?? jellyfinPlaylistId;
_logger.LogDebug("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
if (string.IsNullOrEmpty(jellyfinPlaylistId))
@@ -1459,7 +1484,7 @@ public class SpotifyTrackMatchingService : BackgroundService
return;
}
var userId = playlistConfig?.UserId ?? jellyfinSettings.UserId;
var userId = jellyfinSettings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
@@ -1535,11 +1560,7 @@ public class SpotifyTrackMatchingService : BackgroundService
string? matchedKey = null;
// FIRST: Check for manual Jellyfin mapping
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
playlistName,
spotifyTrack.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, spotifyTrack.SpotifyId);
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -1619,11 +1640,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// SECOND: Check for external manual mapping
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
playlistName,
spotifyTrack.SpotifyId,
playlistScopeUserId,
playlistScopeId);
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, spotifyTrack.SpotifyId);
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
@@ -1911,14 +1928,11 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// Save to Redis cache with same expiration as matched tracks (until next cron run)
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
playlistName,
playlistScopeUserId,
playlistScopeId);
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
// Save to file cache for persistence
await SavePlaylistItemsToFileAsync(playlistName, finalItems, playlistScopeUserId, playlistScopeId);
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
var manualMappingInfo = "";
if (manualExternalCount > 0)
@@ -1943,19 +1957,14 @@ public class SpotifyTrackMatchingService : BackgroundService
/// <summary>
/// Saves playlist items to file cache for persistence across restarts.
/// </summary>
private async Task SavePlaylistItemsToFileAsync(
string playlistName,
List<Dictionary<string, object?>> items,
string? userId = null,
string? scopeId = null)
private async Task SavePlaylistItemsToFileAsync(string playlistName, List<Dictionary<string, object?>> items)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = AdminHelperService.SanitizeFileName(
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
@@ -1972,19 +1981,14 @@ public class SpotifyTrackMatchingService : BackgroundService
/// <summary>
/// Saves matched tracks to file cache for persistence across restarts.
/// </summary>
private async Task SaveMatchedTracksToFileAsync(
string playlistName,
List<MatchedTrack> matchedTracks,
string? userId = null,
string? scopeId = null)
private async Task SaveMatchedTracksToFileAsync(string playlistName, List<MatchedTrack> matchedTracks)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = AdminHelperService.SanitizeFileName(
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
@@ -510,7 +510,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
await Task.WhenAll(songsTask, albumsTask, artistsTask);
var temp = new SearchResult
var temp = new SearchResult
{
Songs = await songsTask,
Albums = await albumsTask,
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path fill="#FFDD00" d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"/></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 96" aria-hidden="true"><path fill="#f0f6fc" fill-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 980 B

+13
View File
@@ -0,0 +1,13 @@
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_1_219" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
</mask>
<g mask="url(#mask0_1_219)">
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+195 -108
View File
@@ -12,8 +12,8 @@
<!-- Restart Required Banner -->
<div class="restart-banner" id="restart-banner">
⚠️ Configuration changed. Restart required to apply changes.
<button onclick="restartContainer()">Restart Allstarr</button>
<button onclick="dismissRestartBanner()"
<button data-action="restartContainer">Restart Allstarr</button>
<button data-action="dismissRestartBanner"
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
</div>
@@ -28,58 +28,93 @@
<label for="auth-password">Password</label>
<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>
<div class="auth-error" id="auth-error" role="alert"></div>
</form>
</div>
<div class="support-badge">
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development via
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
or
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
<p class="support-text">
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development
</p>
<ul class="support-funding-icons">
<li>
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
rel="noopener noreferrer" aria-label="Support on Ko-fi">
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
</a>
</li>
<li>
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
rel="noopener noreferrer" aria-label="GitHub Sponsors">
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
</a>
</li>
<li>
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
</a>
</li>
</ul>
</div>
</div>
<div class="container" id="main-container" style="display:none;">
<header>
<h1>
Allstarr <span class="version" id="version">Loading...</span>
</h1>
<div class="header-actions">
<div class="auth-user" id="auth-user-display" style="display:none;">
Signed in as <strong id="auth-user-name">-</strong>
<div class="container hidden" id="main-container">
<div class="app-shell">
<aside class="sidebar" aria-label="Admin navigation">
<div class="sidebar-brand">
<div class="sidebar-title">
<a class="title-link" href="https://github.com/SoPat712/allstarr" target="_blank"
rel="noopener noreferrer">Allstarr</a>
</div>
<div class="sidebar-subtitle" id="sidebar-version">Loading...</div>
<div class="sidebar-status" id="status-indicator">
<span class="status-badge" id="spotify-status">
<span class="status-dot"></span>
<span>Loading...</span>
</span>
</div>
</div>
<button id="auth-logout-btn" onclick="logoutAdminSession()" style="display:none;">Logout</button>
<div id="status-indicator">
<span class="status-badge" id="spotify-status">
<span class="status-dot"></span>
<span>Loading...</span>
</span>
<nav class="sidebar-nav">
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button>
<button class="sidebar-link" type="button" data-tab="jellyfin-playlists">Link Playlists</button>
<button class="sidebar-link" type="button" data-tab="playlists">Injected Playlists</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="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>
</nav>
<div class="sidebar-footer">
<div class="auth-user hidden" id="auth-user-display">
Signed in as <strong id="auth-user-name">-</strong>
</div>
<button id="auth-logout-btn" data-action="logoutAdminSession" class="hidden">Logout</button>
</div>
</div>
</header>
</aside>
<div class="tabs">
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
<div class="tab" data-tab="playlists">Injected Playlists</div>
<div class="tab" data-tab="kept">Kept Downloads</div>
<div class="tab" data-tab="scrobbling">Scrobbling</div>
<div class="tab" data-tab="config">Configuration</div>
<div class="tab" data-tab="endpoints">API Analytics</div>
</div>
<main class="app-main">
<div class="tabs top-tabs" aria-hidden="true">
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
<div class="tab" data-tab="playlists">Injected Playlists</div>
<div class="tab" data-tab="kept">Kept Downloads</div>
<div class="tab" data-tab="scrobbling">Scrobbling</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>
<!-- Dashboard Tab -->
<div class="tab-content active" id="tab-dashboard">
<div class="card" id="download-activity-card">
<h2>Live Download Queue</h2>
<div id="download-activity-list" class="download-queue-list">
<div class="empty-state">No active downloads</div>
</div>
</div>
<div class="grid">
<div class="card">
<h2>Spotify API</h2>
@@ -128,9 +163,9 @@
</h2>
<div id="dashboard-guidance" class="guidance-stack"></div>
<div class="card-actions-row">
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
<button onclick="clearCache()">Clear Cache</button>
<button onclick="openAddPlaylist()">Add Playlist</button>
<button class="primary" data-action="refreshPlaylists">Refresh All Playlists</button>
<button data-action="clearCache">Clear Cache</button>
<button data-action="openAddPlaylist">Add Playlist</button>
<button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button>
</div>
</div>
@@ -145,7 +180,7 @@
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
<p class="text-secondary mb-16">
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing
tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more
@@ -153,10 +188,9 @@
</p>
<div id="jellyfin-guidance" class="guidance-stack"></div>
<div id="jellyfin-user-filter" style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
<div class="form-group" style="margin: 0; flex: 1; min-width: 200px;">
<label
style="display: block; margin-bottom: 4px; color: var(--text-secondary); font-size: 0.85rem;">User</label>
<div id="jellyfin-user-filter" class="flex-row-wrap mb-16">
<div class="form-group jellyfin-user-form-group">
<label class="text-secondary">User</label>
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()"
style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
<option value="">All Users</option>
@@ -232,7 +266,7 @@
</div>
</details>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
<p class="text-secondary mb-12">
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music
service.
</p>
@@ -268,15 +302,14 @@
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For
local Jellyfin tracks, use the Spotify Import plugin instead.
</p>
<div id="mappings-summary"
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div id="mappings-summary" class="summary-box">
<div>
<span style="color: var(--text-secondary);">Total:</span>
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
<span class="summary-label">Total:</span>
<span class="summary-value" id="mappings-total">0</span>
</div>
<div>
<span style="color: var(--text-secondary);">External:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--success);"
<span class="summary-label">External:</span>
<span class="summary-value success"
id="mappings-external">0</span>
</div>
</div>
@@ -309,15 +342,14 @@
<button onclick="fetchMissingTracks()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
<p class="text-secondary mb-12">
Tracks that couldn't be matched locally or externally. Map them manually to add them to your
playlists.
</p>
<div id="missing-summary"
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div id="missing-summary" class="summary-box">
<div>
<span style="color: var(--text-secondary);">Total Missing:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);"
<span class="summary-label">Total Missing:</span>
<span class="summary-value warning"
id="missing-total">0</span>
</div>
</div>
@@ -348,23 +380,23 @@
<h2>
Kept Downloads
<div class="actions">
<button onclick="downloadAllKept()" style="background:var(--accent);border-color:var(--accent);">Download All</button>
<button onclick="downloadAllKept()" class="primary">Download All</button>
<button onclick="deleteAllKept()" class="danger">Delete All</button>
<button onclick="fetchDownloads()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
<p class="text-secondary mb-12">
Downloaded files stored permanently. Download individual tracks or download all as a zip archive.
</p>
<div id="downloads-summary"
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div id="downloads-summary" class="summary-box">
<div>
<span style="color: var(--text-secondary);">Total Files:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);"
<span class="summary-label">Total Files:</span>
<span class="summary-value accent"
id="downloads-count">0</span>
</div>
<div>
<span style="color: var(--text-secondary);">Total Size:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0
<span class="summary-label">Total Size:</span>
<span class="summary-value accent" id="downloads-size">0
B</span>
</div>
</div>
@@ -871,48 +903,84 @@
</div>
</div>
<!-- API Analytics Tab -->
<div class="tab-content" id="tab-endpoints">
<!-- Report Issues Tab -->
<div class="tab-content" id="tab-report-issues">
<div class="card">
<h2>
SquidWTF Endpoint Health
<div class="actions">
<button class="primary" onclick="fetchSquidWtfEndpointHealth(true)">Test Endpoints</button>
<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>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Runs a real SquidWTF API search probe and a real SquidWTF streaming manifest probe against every configured mirror.
Green means the API request worked. Blue means the streaming request worked.
</p>
<div class="endpoint-health-toolbar">
<div class="endpoint-health-legend">
<span><span class="endpoint-health-dot api up"></span> API up</span>
<span><span class="endpoint-health-dot streaming up"></span> Streaming up</span>
<span><span class="endpoint-health-dot down"></span> Down</span>
<span><span class="endpoint-health-dot unknown"></span> Not tested</span>
</div>
<div class="endpoint-health-last-tested" id="squidwtf-endpoints-tested-at">Not tested yet</div>
</div>
<div style="max-height: 520px; overflow-y: auto;">
<table class="playlist-table endpoint-health-table">
<thead>
<tr>
<th>Host</th>
<th style="width: 72px; text-align: center;">API</th>
<th style="width: 96px; text-align: center;">Streaming</th>
</tr>
</thead>
<tbody id="squidwtf-endpoints-table-body">
<tr>
<td colspan="3" class="loading">
Click <strong>Test Endpoints</strong> to probe SquidWTF mirrors.
</td>
</tr>
</tbody>
</table>
<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 -->
<div class="tab-content" id="tab-endpoints">
<div class="card">
<h2>
API Endpoint Usage
@@ -1004,14 +1072,33 @@
</div>
<footer class="support-footer">
<p>
<p class="support-text">
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development via
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
or
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
supporting its development
</p>
<ul class="support-funding-icons">
<li>
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
rel="noopener noreferrer" aria-label="Support on Ko-fi">
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
</a>
</li>
<li>
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
rel="noopener noreferrer" aria-label="GitHub Sponsors">
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
</a>
</li>
<li>
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
</a>
</li>
</ul>
</footer>
</main>
</div>
</div>
<!-- Add Playlist Modal -->
+84
View File
@@ -0,0 +1,84 @@
function toBoolean(value) {
if (value === true || value === false) {
return value;
}
const normalized = String(value ?? "")
.trim()
.toLowerCase();
return normalized === "true" || normalized === "1" || normalized === "yes";
}
function toNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function getActionArgs(el) {
if (!el || !el.dataset) {
return {};
}
// Convention:
// - data-action="foo"
// - data-arg-bar="baz" => { bar: "baz" }
const args = {};
for (const [key, value] of Object.entries(el.dataset)) {
if (!key.startsWith("arg")) continue;
const argName = key.slice(3);
if (!argName) continue;
const normalized =
argName.charAt(0).toLowerCase() + argName.slice(1);
args[normalized] = value;
}
return args;
}
export function initActionDispatcher({ root = document } = {}) {
const handlers = new Map();
function register(actionName, handler) {
if (!actionName || typeof handler !== "function") {
return;
}
handlers.set(actionName, handler);
}
async function dispatch(actionName, el, event = null) {
const handler = handlers.get(actionName);
const args = getActionArgs(el);
if (handler) {
return await handler({ el, event, args, toBoolean, toNumber });
}
// Transitional fallback: if a legacy window function exists, call it.
// This allows incremental conversion away from inline onclick.
const legacy = typeof window !== "undefined" ? window[actionName] : null;
if (typeof legacy === "function") {
const legacyArgs = args && Object.keys(args).length > 0 ? [args] : [];
return legacy(...legacyArgs);
}
console.warn(`No handler registered for action "${actionName}"`);
return null;
}
function bind() {
root.addEventListener("click", (event) => {
const trigger = event.target?.closest?.("[data-action]");
if (!trigger) return;
const actionName = trigger.getAttribute("data-action") || "";
if (!actionName) return;
event.preventDefault();
dispatch(actionName, trigger, event);
});
}
bind();
return { register, dispatch };
}
+17 -12
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(
"/api/admin/auth/login",
asJsonBody({ username, password }),
asJsonBody({ username, password, rememberMe }),
"Authentication failed",
);
}
@@ -124,6 +124,14 @@ export async function deleteDownload(path) {
);
}
export async function deleteAllDownloads() {
return requestJson(
"/api/admin/downloads/all",
{ method: "DELETE" },
"Failed to delete all downloads",
);
}
export async function fetchConfig() {
return requestJson(
"/api/admin/config",
@@ -144,10 +152,15 @@ export async function fetchJellyfinUsers() {
return requestOptionalJson("/api/admin/jellyfin/users");
}
export async function fetchJellyfinPlaylists(userId = null) {
export async function fetchJellyfinPlaylists(userId = null, includeStats = true) {
let url = "/api/admin/jellyfin/playlists";
const params = [];
if (userId) {
url += "?userId=" + encodeURIComponent(userId);
params.push("userId=" + encodeURIComponent(userId));
}
params.push("includeStats=" + String(Boolean(includeStats)));
if (params.length > 0) {
url += "?" + params.join("&");
}
return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
@@ -414,14 +427,6 @@ export async function getSquidWTFBaseUrl() {
);
}
export async function fetchSquidWtfEndpointHealth() {
return requestJson(
"/api/admin/squidwtf/endpoints/test",
{ method: "POST" },
"Failed to test SquidWTF endpoints",
);
}
export async function fetchScrobblingStatus() {
return requestJson(
"/api/admin/scrobbling/status",
+4 -1
View File
@@ -72,6 +72,7 @@ function applyAuthorizationScope() {
"kept",
"scrobbling",
"config",
"report-issues",
"endpoints",
];
@@ -196,9 +197,11 @@ function wireLoginForm() {
const usernameInput = document.getElementById("auth-username");
const passwordInput = document.getElementById("auth-password");
const rememberMeInput = document.getElementById("auth-remember-me");
const authError = document.getElementById("auth-error");
const username = usernameInput?.value?.trim() || "";
const password = passwordInput?.value || "";
const rememberMe = Boolean(rememberMeInput?.checked);
if (!username || !password) {
if (authError) {
@@ -212,7 +215,7 @@ function wireLoginForm() {
authError.textContent = "";
}
const result = await API.loginAdminSession(username, password);
const result = await API.loginAdminSession(username, password, rememberMe);
if (passwordInput) {
passwordInput.value = "";
}
+41 -42
View File
@@ -1,4 +1,4 @@
import { escapeHtml, showToast, formatCookieAge } from "./utils.js";
import { escapeHtml, escapeJs, showToast, formatCookieAge } from "./utils.js";
import * as API from "./api.js";
import * as UI from "./ui.js";
import { renderCookieAge } from "./settings-editor.js";
@@ -15,6 +15,8 @@ let onCookieNeedsInit = async () => {};
let setCurrentConfigState = () => {};
let syncConfigUiExtras = () => {};
let loadScrobblingConfig = () => {};
let injectedPlaylistRequestToken = 0;
let jellyfinPlaylistRequestToken = 0;
async function fetchStatus() {
try {
@@ -38,10 +40,20 @@ async function fetchStatus() {
}
async function fetchPlaylists(silent = false) {
const requestToken = ++injectedPlaylistRequestToken;
try {
const data = await API.fetchPlaylists();
if (requestToken !== injectedPlaylistRequestToken) {
return;
}
UI.updatePlaylistsUI(data);
} catch (error) {
if (requestToken !== injectedPlaylistRequestToken) {
return;
}
if (!silent) {
console.error("Failed to fetch playlists:", error);
showToast("Failed to fetch playlists", "error");
@@ -129,6 +141,7 @@ async function fetchMissingTracks() {
missing.forEach((t) => {
missingTracks.push({
playlist: playlist.name,
provider: t.externalProvider || t.provider || "squidwtf",
...t,
});
});
@@ -151,6 +164,7 @@ async function fetchMissingTracks() {
const artist =
t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
const searchQuery = `${t.title} ${artist}`;
const provider = t.provider || "squidwtf";
const trackPosition = Number.isFinite(t.position)
? Number(t.position)
: 0;
@@ -163,7 +177,7 @@ async function fetchMissingTracks() {
<td class="mapping-actions-cell">
<button class="map-action-btn map-action-search missing-track-search-btn"
data-query="${escapeHtml(searchQuery)}"
data-provider="squidwtf">🔍 Search</button>
data-provider="${escapeHtml(provider)}">🔍 Search</button>
<button class="map-action-btn map-action-local missing-track-local-btn"
data-playlist="${escapeHtml(t.playlist)}"
data-position="${trackPosition}"
@@ -213,9 +227,9 @@ async function fetchDownloads() {
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
<td>
<button onclick="downloadFile('${escapeJs(f.path)}')"
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
<button onclick="deleteDownload('${escapeJs(f.path)}')"
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
</td>
</tr>
@@ -245,11 +259,28 @@ async function fetchJellyfinPlaylists() {
'<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
try {
const requestToken = ++jellyfinPlaylistRequestToken;
const userId = isAdminSession()
? document.getElementById("jellyfin-user-select")?.value
: null;
const data = await API.fetchJellyfinPlaylists(userId);
UI.updateJellyfinPlaylistsUI(data);
const baseData = await API.fetchJellyfinPlaylists(userId, false);
if (requestToken !== jellyfinPlaylistRequestToken) {
return;
}
UI.updateJellyfinPlaylistsUI(baseData);
// Enrich counts after initial render so big accounts don't appear empty.
API.fetchJellyfinPlaylists(userId, true)
.then((statsData) => {
if (requestToken !== jellyfinPlaylistRequestToken) {
return;
}
UI.updateJellyfinPlaylistsUI(statsData);
})
.catch((err) => {
console.error("Failed to fetch Jellyfin playlist track stats:", err);
});
} catch (error) {
console.error("Failed to fetch Jellyfin playlists:", error);
tbody.innerHTML =
@@ -300,33 +331,6 @@ async function clearEndpointUsage() {
}
}
async function fetchSquidWtfEndpointHealth(showFeedback = false) {
const tbody = document.getElementById("squidwtf-endpoints-table-body");
if (tbody) {
tbody.innerHTML =
'<tr><td colspan="3" class="loading"><span class="spinner"></span> Testing SquidWTF endpoints...</td></tr>';
}
try {
const data = await API.fetchSquidWtfEndpointHealth();
UI.updateSquidWtfEndpointHealthUI(data);
if (showFeedback) {
showToast("SquidWTF endpoint test completed", "success");
}
} catch (error) {
console.error("Failed to test SquidWTF endpoints:", error);
if (tbody) {
tbody.innerHTML =
'<tr><td colspan="3" style="text-align:center;color:var(--error);padding:40px;">Failed to test SquidWTF endpoints</td></tr>';
}
if (showFeedback) {
showToast("Failed to test SquidWTF endpoints", "error");
}
}
}
function startPlaylistAutoRefresh() {
if (playlistAutoRefreshInterval) {
clearInterval(playlistAutoRefreshInterval);
@@ -373,7 +377,10 @@ function startDashboardRefresh() {
fetchPlaylists();
fetchTrackMappings();
fetchMissingTracks();
fetchDownloads();
const keptTab = document.getElementById("tab-kept");
if (keptTab && keptTab.classList.contains("active")) {
fetchDownloads();
}
const endpointsTab = document.getElementById("tab-endpoints");
if (endpointsTab && endpointsTab.classList.contains("active")) {
@@ -397,11 +404,6 @@ async function loadDashboardData() {
fetchEndpointUsage(),
]);
const endpointsTab = document.getElementById("tab-endpoints");
if (endpointsTab && endpointsTab.classList.contains("active")) {
await fetchSquidWtfEndpointHealth(false);
}
// Ensure user filter defaults are populated before loading Link Playlists rows.
await fetchJellyfinUsers();
await fetchJellyfinPlaylists();
@@ -412,7 +414,6 @@ async function loadDashboardData() {
}
startDashboardRefresh();
startDownloadActivityStream();
}
function startDownloadActivityStream() {
@@ -561,7 +562,6 @@ export function initDashboardData(options) {
window.fetchJellyfinUsers = fetchJellyfinUsers;
window.fetchEndpointUsage = fetchEndpointUsage;
window.clearEndpointUsage = clearEndpointUsage;
window.fetchSquidWtfEndpointHealth = fetchSquidWtfEndpointHealth;
return {
stopDashboardRefresh,
@@ -573,6 +573,5 @@ export function initDashboardData(options) {
fetchJellyfinPlaylists,
fetchConfig,
fetchStatus,
fetchSquidWtfEndpointHealth,
};
}
+32 -11
View File
@@ -100,14 +100,14 @@ export async function viewTracks(name) {
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
const externalSearchLink =
t.isLocal === false && t.searchQuery && t.externalProvider
? `<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', '${escapeJs(t.externalProvider)}'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
? `<br><small style="color:var(--accent)"><a href="#" data-action="searchProvider" data-arg-query="${escapeHtml(escapeJs(t.searchQuery))}" data-arg-provider="${escapeHtml(escapeJs(t.externalProvider))}" style="color:var(--accent);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
: "";
const missingSearchLink =
t.isLocal === null && t.searchQuery
? `<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', 'squidwtf'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
? `<br><small style="color:var(--text-secondary)"><a href="#" data-action="searchProvider" data-arg-query="${escapeHtml(escapeJs(t.searchQuery))}" data-arg-provider="squidwtf" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
: "";
const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || "")}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
const lyricsMapButton = `<button class="small" data-action="openLyricsMap" data-arg-artist="${escapeHtml(escapeJs(firstArtist))}" data-arg-title="${escapeHtml(escapeJs(t.title))}" data-arg-album="${escapeHtml(escapeJs(t.album || ""))}" data-arg-duration-seconds="${durationSeconds}" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
return `
<div class="track-item" data-position="${t.position}">
@@ -246,7 +246,7 @@ export async function searchJellyfinTracks() {
const artist = track.artist || "";
const album = track.album || "";
return `
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" onclick="selectJellyfinTrack('${escapeJs(id)}')">
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" data-action="selectJellyfinTrack" data-arg-jellyfin-id="${escapeHtml(escapeJs(id))}">
<div>
<strong>${escapeHtml(title)}</strong>
<br>
@@ -344,7 +344,15 @@ export async function searchExternalTracks() {
const externalUrl = track.url || "";
return `
<div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}" onclick="selectExternalTrack(${index}, '${escapeJs(id)}', '${escapeJs(title)}', '${escapeJs(artist)}', '${escapeJs(providerName)}', '${escapeJs(externalUrl)}')">
<div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}"
data-action="selectExternalTrack"
data-arg-result-index="${index}"
data-arg-external-id="${escapeHtml(escapeJs(id))}"
data-arg-title="${escapeHtml(escapeJs(title))}"
data-arg-artist="${escapeHtml(escapeJs(artist))}"
data-arg-provider="${escapeHtml(escapeJs(providerName))}"
data-arg-external-url="${escapeHtml(escapeJs(externalUrl))}"
>
<div>
<strong>${escapeHtml(title)}</strong>
<br>
@@ -662,13 +670,26 @@ export async function saveLyricsMapping() {
// Search provider (open in new tab)
export async function searchProvider(query, provider) {
try {
const data = await API.getSquidWTFBaseUrl();
const baseUrl = data.baseUrl; // Use the actual property name from API
const searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
const normalizedProvider = (provider || "squidwtf").toLowerCase();
let searchUrl = "";
if (normalizedProvider === "squidwtf" || normalizedProvider === "tidal") {
const data = await API.getSquidWTFBaseUrl();
const baseUrl = data.baseUrl;
searchUrl = `${baseUrl}/search/?s=${encodeURIComponent(query)}`;
} else if (normalizedProvider === "deezer") {
searchUrl = `https://www.deezer.com/search/${encodeURIComponent(query)}`;
} else if (normalizedProvider === "qobuz") {
searchUrl = `https://www.qobuz.com/search?query=${encodeURIComponent(query)}`;
} else {
const data = await API.getSquidWTFBaseUrl();
const baseUrl = data.baseUrl;
searchUrl = `${baseUrl}/search/?s=${encodeURIComponent(query)}`;
}
window.open(searchUrl, "_blank");
} catch (error) {
console.error("Failed to get SquidWTF base URL:", error);
// Fallback to first encoded URL (triton)
showToast("Failed to get SquidWTF URL, using fallback", "warning");
console.error("Failed to open provider search:", error);
showToast("Failed to open provider search link", "warning");
}
}
+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();
}
+107 -39
View File
@@ -34,17 +34,14 @@ import {
} from "./playlist-admin.js";
import { initScrobblingAdmin } from "./scrobbling-admin.js";
import { initAuthSession } from "./auth-session.js";
import { initActionDispatcher } from "./action-dispatcher.js";
import { initNavigationView } from "./views/navigation-view.js";
import { initScrobblingView } from "./views/scrobbling-view.js";
import { initIssueReporter } from "./issue-reporter.js";
let cookieDateInitialized = false;
let restartRequired = false;
window.showToast = showToast;
window.escapeHtml = escapeHtml;
window.escapeJs = escapeJs;
window.openModal = openModal;
window.closeModal = closeModal;
window.capitalizeProvider = capitalizeProvider;
window.showRestartBanner = function () {
restartRequired = true;
document.getElementById("restart-banner")?.classList.add("active");
@@ -58,17 +55,30 @@ window.switchTab = function (tabName) {
document
.querySelectorAll(".tab")
.forEach((tab) => tab.classList.remove("active"));
document
.querySelectorAll(".sidebar-link")
.forEach((link) => link.classList.remove("active"));
document
.querySelectorAll(".tab-content")
.forEach((content) => content.classList.remove("active"));
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
const sidebarLink = document.querySelector(
`.sidebar-link[data-tab="${tabName}"]`,
);
const content = document.getElementById(`tab-${tabName}`);
if (tab && content) {
tab.classList.add("active");
if (sidebarLink) {
sidebarLink.classList.add("active");
}
content.classList.add("active");
window.location.hash = tabName;
if (tabName === "kept" && typeof window.fetchDownloads === "function") {
window.fetchDownloads();
}
}
};
@@ -128,6 +138,8 @@ initPlaylistAdmin({
fetchJellyfinPlaylists: dashboard.fetchJellyfinPlaylists,
});
initIssueReporter();
const authSession = initAuthSession({
stopDashboardRefresh: dashboard.stopDashboardRefresh,
loadDashboardData: dashboard.loadDashboardData,
@@ -138,56 +150,112 @@ const authSession = initAuthSession({
},
});
window.viewTracks = viewTracks;
window.openManualMap = openManualMap;
window.openExternalMap = openExternalMap;
window.openMapToLocal = openManualMap;
window.openMapToExternal = openExternalMap;
window.openModal = openModal;
window.closeModal = closeModal;
window.searchJellyfinTracks = searchJellyfinTracks;
window.selectJellyfinTrack = selectJellyfinTrack;
window.saveLocalMapping = saveLocalMapping;
window.saveManualMapping = saveManualMapping;
window.searchExternalTracks = searchExternalTracks;
window.selectExternalTrack = selectExternalTrack;
window.validateExternalMapping = validateExternalMapping;
window.openLyricsMap = openLyricsMap;
window.saveLyricsMapping = saveLyricsMapping;
window.searchProvider = searchProvider;
window.validateExternalMapping = validateExternalMapping;
window.saveLyricsMapping = saveLyricsMapping;
// Note: viewTracks/selectExternalTrack/selectJellyfinTrack/openLyricsMap/searchProvider
// are now wired via the ActionDispatcher and no longer require window exports.
document.addEventListener("DOMContentLoaded", () => {
console.log("🚀 Allstarr Admin UI (Modular) loaded");
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
window.switchTab(tab.dataset.tab);
});
const dispatcher = initActionDispatcher({ root: document });
// Register a few core actions first; more will be migrated as inline
// onclick handlers are removed from HTML and generated markup.
dispatcher.register("switchTab", ({ args }) => {
const tab = args?.tab || args?.tabName;
if (tab) {
window.switchTab(tab);
}
});
dispatcher.register("logoutAdminSession", () => window.logoutAdminSession?.());
dispatcher.register("dismissRestartBanner", () =>
window.dismissRestartBanner?.(),
);
dispatcher.register("restartContainer", () => window.restartContainer?.());
dispatcher.register("refreshPlaylists", () => window.refreshPlaylists?.());
dispatcher.register("clearCache", () => window.clearCache?.());
dispatcher.register("openAddPlaylist", () => window.openAddPlaylist?.());
dispatcher.register("toggleRowMenu", ({ event, args }) =>
window.toggleRowMenu?.(event, args?.menuId),
);
dispatcher.register("toggleDetailsRow", ({ event, args }) =>
window.toggleDetailsRow?.(event, args?.detailsRowId),
);
dispatcher.register("viewTracks", ({ args }) => viewTracks(args?.playlistName));
dispatcher.register("refreshPlaylist", ({ args }) =>
window.refreshPlaylist?.(args?.playlistName),
);
dispatcher.register("matchPlaylistTracks", ({ args }) =>
window.matchPlaylistTracks?.(args?.playlistName),
);
dispatcher.register("clearPlaylistCache", ({ args }) =>
window.clearPlaylistCache?.(args?.playlistName),
);
dispatcher.register("editPlaylistSchedule", ({ args }) =>
window.editPlaylistSchedule?.(args?.playlistName, args?.syncSchedule),
);
dispatcher.register("removePlaylist", ({ args }) =>
window.removePlaylist?.(args?.playlistName),
);
dispatcher.register("openLinkPlaylist", ({ args }) =>
window.openLinkPlaylist?.(args?.jellyfinId, args?.jellyfinName),
);
dispatcher.register("unlinkPlaylist", ({ args }) =>
window.unlinkPlaylist?.(args?.jellyfinId, args?.jellyfinName),
);
dispatcher.register("fetchJellyfinPlaylists", () =>
window.fetchJellyfinPlaylists?.(),
);
dispatcher.register("searchProvider", ({ args }) =>
searchProvider(args?.query, args?.provider),
);
dispatcher.register("openLyricsMap", ({ args, toNumber }) =>
openLyricsMap(
args?.artist,
args?.title,
args?.album,
toNumber(args?.durationSeconds) ?? 0,
),
);
dispatcher.register("selectJellyfinTrack", ({ args }) =>
selectJellyfinTrack(args?.jellyfinId),
);
dispatcher.register("selectExternalTrack", ({ args, toNumber }) =>
selectExternalTrack(
toNumber(args?.resultIndex),
args?.externalId,
args?.title,
args?.artist,
args?.provider,
args?.externalUrl,
),
);
dispatcher.register("downloadFile", ({ args }) =>
window.downloadFile?.(args?.path),
);
dispatcher.register("deleteDownload", ({ args }) =>
window.deleteDownload?.(args?.path),
);
const hash = window.location.hash.substring(1);
if (hash) {
window.switchTab(hash);
}
initNavigationView({ switchTab: window.switchTab });
setupModalBackdropClose();
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
if (scrobblingTab) {
scrobblingTab.addEventListener("click", () => {
if (authSession.isAuthenticated()) {
window.loadScrobblingConfig();
}
});
}
const endpointsTab = document.querySelector('.tab[data-tab="endpoints"]');
if (endpointsTab) {
endpointsTab.addEventListener("click", () => {
if (authSession.isAuthenticated() && authSession.isAdminSession()) {
window.fetchEndpointUsage?.();
window.fetchSquidWtfEndpointHealth?.(false);
}
});
}
initScrobblingView({
isAuthenticated: () => authSession.isAuthenticated(),
loadScrobblingConfig: () => window.loadScrobblingConfig?.(),
});
authSession.bootstrapAuth();
});
+89 -6
View File
@@ -1,17 +1,100 @@
// Modal management
const modalState = new Map();
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
function getModal(id) {
return document.getElementById(id);
}
function getFocusableElements(modal) {
return Array.from(modal.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
(el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"),
);
}
function onModalKeyDown(event, modal) {
if (event.key === "Escape") {
event.preventDefault();
closeModal(modal.id);
return;
}
if (event.key !== "Tab") {
return;
}
const focusable = getFocusableElements(modal);
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const isShift = event.shiftKey;
if (isShift && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!isShift && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
export function openModal(id) {
document.getElementById(id).classList.add('active');
const modal = getModal(id);
if (!modal) return;
const modalContent = modal.querySelector(".modal-content");
if (!modalContent) return;
const previousActive = document.activeElement;
modalState.set(id, { previousActive });
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.removeAttribute("aria-hidden");
modal.classList.add("active");
const keydownHandler = (event) => onModalKeyDown(event, modal);
modalState.set(id, { previousActive, keydownHandler });
modal.addEventListener("keydown", keydownHandler);
const focusable = getFocusableElements(modalContent);
if (focusable.length > 0) {
focusable[0].focus();
} else {
modalContent.setAttribute("tabindex", "-1");
modalContent.focus();
}
}
export function closeModal(id) {
document.getElementById(id).classList.remove('active');
const modal = getModal(id);
if (!modal) return;
modal.classList.remove("active");
modal.setAttribute("aria-hidden", "true");
const state = modalState.get(id);
if (state?.keydownHandler) {
modal.removeEventListener("keydown", state.keydownHandler);
}
if (state?.previousActive && typeof state.previousActive.focus === "function") {
state.previousActive.focus();
}
modalState.delete(id);
}
export function setupModalBackdropClose() {
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', e => {
if (e.target === modal) closeModal(modal.id);
});
document.querySelectorAll(".modal").forEach((modal) => {
modal.setAttribute("aria-hidden", "true");
modal.addEventListener("click", (e) => {
if (e.target === modal) closeModal(modal.id);
});
});
}
+15
View File
@@ -77,6 +77,20 @@ function downloadAllKept() {
}
}
async function deleteAllKept() {
const result = await runAction({
confirmMessage:
"Delete ALL kept downloads?\n\nThis will permanently remove all kept audio files.",
task: () => API.deleteAllDownloads(),
success: (data) => data.message || "All kept downloads deleted",
error: (err) => err.message || "Failed to delete all kept downloads",
});
if (result) {
await fetchDownloads();
}
}
async function deleteDownload(path) {
const result = await runAction({
confirmMessage: `Delete this file?\n\n${path}\n\nThis action cannot be undone.`,
@@ -364,6 +378,7 @@ export function initOperations(options) {
window.deleteTrackMapping = deleteTrackMapping;
window.downloadFile = downloadFile;
window.downloadAllKept = downloadAllKept;
window.deleteAllKept = deleteAllKept;
window.deleteDownload = deleteDownload;
window.refreshPlaylists = refreshPlaylists;
window.refreshPlaylist = refreshPlaylist;
+6 -1
View File
@@ -70,7 +70,12 @@ async function openLinkPlaylist(jellyfinId, name) {
}
try {
spotifyUserPlaylists = await API.fetchSpotifyUserPlaylists(selectedUserId);
const response = await API.fetchSpotifyUserPlaylists(selectedUserId);
spotifyUserPlaylists = Array.isArray(response?.playlists)
? response.playlists
: Array.isArray(response)
? response
: [];
spotifyUserPlaylistsScopeUserId = selectedUserId;
const availablePlaylists = spotifyUserPlaylists.filter((p) => !p.isLinked);
+426 -202
View File
@@ -3,6 +3,9 @@
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
let rowMenuHandlersBound = false;
let tableRowHandlersBound = false;
const expandedInjectedPlaylistDetails = new Set();
let openInjectedPlaylistMenuKey = null;
function bindRowMenuHandlers() {
if (rowMenuHandlersBound) {
@@ -16,12 +19,55 @@ function bindRowMenuHandlers() {
rowMenuHandlersBound = true;
}
function bindTableRowHandlers() {
if (tableRowHandlersBound) {
return;
}
document.addEventListener("click", (event) => {
const detailsTrigger = event.target.closest?.(
"button.details-trigger[data-details-target]",
);
if (detailsTrigger) {
const target = detailsTrigger.getAttribute("data-details-target");
if (target) {
toggleDetailsRow(event, target);
}
return;
}
const row = event.target.closest?.("tr.compact-row[data-details-row]");
if (!row) {
return;
}
if (event.target.closest("button, a, .row-actions-menu")) {
return;
}
const detailsRowId = row.getAttribute("data-details-row");
if (detailsRowId) {
toggleDetailsRow(null, detailsRowId);
}
});
tableRowHandlersBound = true;
}
function closeAllRowMenus(exceptId = null) {
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
if (!exceptId || menu.id !== exceptId) {
menu.classList.remove("open");
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
if (trigger) {
trigger.setAttribute("aria-expanded", "false");
}
}
});
if (!exceptId) {
openInjectedPlaylistMenuKey = null;
}
}
function closeRowMenu(event, menuId) {
@@ -32,6 +78,13 @@ function closeRowMenu(event, menuId) {
const menu = document.getElementById(menuId);
if (menu) {
menu.classList.remove("open");
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
if (trigger) {
trigger.setAttribute("aria-expanded", "false");
}
if (menu.dataset.menuKey) {
openInjectedPlaylistMenuKey = null;
}
}
}
@@ -48,6 +101,14 @@ function toggleRowMenu(event, menuId) {
const isOpen = menu.classList.contains("open");
closeAllRowMenus(menuId);
menu.classList.toggle("open", !isOpen);
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
if (trigger) {
trigger.setAttribute("aria-expanded", String(!isOpen));
}
if (menu.dataset.menuKey) {
openInjectedPlaylistMenuKey = isOpen ? null : menu.dataset.menuKey;
}
}
function toggleDetailsRow(event, detailsRowId) {
@@ -82,6 +143,18 @@ function toggleDetailsRow(event, detailsRowId) {
);
if (parentRow) {
parentRow.classList.toggle("expanded", isExpanded);
// Persist Injected Playlists details expansion across auto-refreshes.
if (parentRow.closest("#playlist-table-body")) {
const detailsKey = parentRow.getAttribute("data-details-key");
if (detailsKey) {
if (isExpanded) {
expandedInjectedPlaylistDetails.add(detailsKey);
} else {
expandedInjectedPlaylistDetails.delete(detailsKey);
}
}
}
}
}
@@ -175,6 +248,275 @@ function getPlaylistStatusSummary(playlist) {
};
}
function syncElementAttributes(target, source) {
if (!target || !source) {
return;
}
const sourceAttributes = new Map(
Array.from(source.attributes || []).map((attribute) => [
attribute.name,
attribute.value,
]),
);
Array.from(target.attributes || []).forEach((attribute) => {
if (!sourceAttributes.has(attribute.name)) {
target.removeAttribute(attribute.name);
}
});
sourceAttributes.forEach((value, name) => {
target.setAttribute(name, value);
});
}
function syncPlaylistRowActionsWrap(existingWrap, nextWrap) {
if (!existingWrap || !nextWrap) {
return;
}
syncElementAttributes(existingWrap, nextWrap);
const activeElement = document.activeElement;
let focusTarget = null;
if (activeElement && existingWrap.contains(activeElement)) {
if (activeElement.classList.contains("menu-trigger")) {
focusTarget = { type: "trigger" };
} else if (activeElement.tagName === "BUTTON") {
focusTarget = {
type: "menu-item",
action: activeElement.getAttribute("data-action") || "",
text: activeElement.textContent || "",
};
}
}
const existingTrigger = existingWrap.querySelector(".menu-trigger");
const nextTrigger = nextWrap.querySelector(".menu-trigger");
if (existingTrigger && nextTrigger) {
syncElementAttributes(existingTrigger, nextTrigger);
existingTrigger.textContent = nextTrigger.textContent;
} else if (nextTrigger && !existingTrigger) {
existingWrap.prepend(nextTrigger.cloneNode(true));
} else if (existingTrigger && !nextTrigger) {
existingTrigger.remove();
}
const existingMenu = existingWrap.querySelector(".row-actions-menu");
const nextMenu = nextWrap.querySelector(".row-actions-menu");
if (existingMenu && nextMenu) {
syncElementAttributes(existingMenu, nextMenu);
existingMenu.replaceChildren(
...Array.from(nextMenu.children).map((child) => child.cloneNode(true)),
);
} else if (nextMenu && !existingMenu) {
existingWrap.append(nextMenu.cloneNode(true));
} else if (existingMenu && !nextMenu) {
existingMenu.remove();
}
if (!focusTarget) {
return;
}
if (focusTarget.type === "trigger") {
existingWrap.querySelector(".menu-trigger")?.focus();
return;
}
const matchingButton =
Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find(
(button) =>
(button.getAttribute("data-action") || "") === focusTarget.action &&
button.textContent === focusTarget.text,
) ||
Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find(
(button) =>
(button.getAttribute("data-action") || "") === focusTarget.action,
);
matchingButton?.focus();
}
function syncPlaylistControlsCell(
existingControlsCell,
nextControlsCell,
preserveOpenMenu = false,
) {
if (!existingControlsCell || !nextControlsCell) {
return;
}
syncElementAttributes(existingControlsCell, nextControlsCell);
if (!preserveOpenMenu) {
existingControlsCell.innerHTML = nextControlsCell.innerHTML;
return;
}
const existingDetailsTrigger =
existingControlsCell.querySelector(".details-trigger");
const nextDetailsTrigger = nextControlsCell.querySelector(".details-trigger");
const existingWrap = existingControlsCell.querySelector(".row-actions-wrap");
const nextWrap = nextControlsCell.querySelector(".row-actions-wrap");
if (
!existingDetailsTrigger ||
!nextDetailsTrigger ||
!existingWrap ||
!nextWrap
) {
existingControlsCell.innerHTML = nextControlsCell.innerHTML;
return;
}
syncElementAttributes(existingDetailsTrigger, nextDetailsTrigger);
existingDetailsTrigger.textContent = nextDetailsTrigger.textContent;
syncPlaylistRowActionsWrap(existingWrap, nextWrap);
}
function syncPlaylistMainRow(
existingMainRow,
nextMainRow,
preserveOpenMenu = false,
) {
if (!existingMainRow || !nextMainRow) {
return;
}
syncElementAttributes(existingMainRow, nextMainRow);
const nextCells = Array.from(nextMainRow.children);
const existingCells = Array.from(existingMainRow.children);
if (!preserveOpenMenu || nextCells.length !== existingCells.length) {
existingMainRow.innerHTML = nextMainRow.innerHTML;
return;
}
nextCells.forEach((nextCell, index) => {
const existingCell = existingCells[index];
if (!existingCell) {
existingMainRow.append(nextCell.cloneNode(true));
return;
}
if (index === nextCells.length - 1) {
syncPlaylistControlsCell(existingCell, nextCell, preserveOpenMenu);
return;
}
existingCell.replaceWith(nextCell.cloneNode(true));
});
while (existingMainRow.children.length > nextCells.length) {
existingMainRow.lastElementChild?.remove();
}
}
function syncPlaylistDetailsRow(existingDetailsRow, nextDetailsRow) {
if (!existingDetailsRow || !nextDetailsRow) {
return;
}
syncElementAttributes(existingDetailsRow, nextDetailsRow);
existingDetailsRow.innerHTML = nextDetailsRow.innerHTML;
}
function renderPlaylistRowPairMarkup(playlist, index) {
const summary = getPlaylistStatusSummary(playlist);
const detailsRowId = `playlist-details-${index}`;
const menuId = `playlist-menu-${index}`;
const detailsKey = `${playlist.id || playlist.name || index}`;
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
const isMenuOpen = openInjectedPlaylistMenuKey === detailsKey;
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
const escapedPlaylistName = escapeHtml(playlist.name);
const escapedSyncSchedule = escapeHtml(syncSchedule);
const escapedDetailsKey = escapeHtml(detailsKey);
const breakdownBadges = [
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
`<span class="status-pill info">${summary.externalMatched} External</span>`,
];
if (summary.externalMissing > 0) {
breakdownBadges.push(
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
);
}
return `
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
<td>
<div class="name-cell">
<strong>${escapeHtml(playlist.name)}</strong>
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
</div>
</td>
<td>
<span class="track-count">${summary.totalPlayable}/${summary.spotifyTotal}</span>
<div class="meta-text">${summary.completionPct}% playable</div>
</td>
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
<td class="row-controls">
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button>
<div class="row-actions-wrap">
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="${isMenuOpen ? "true" : "false"}"
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
<div class="row-actions-menu ${isMenuOpen ? "open" : ""}" id="${menuId}" data-menu-key="${escapedDetailsKey}" role="menu">
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button>
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button>
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button>
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button>
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button>
<hr>
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button>
</div>
</div>
</td>
</tr>
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
<td colspan="4">
<div class="details-panel">
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">Sync Schedule</span>
<span class="detail-value mono">
${escapeHtml(syncSchedule)}
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button>
</span>
</div>
<div class="detail-item">
<span class="detail-label">Cache Age</span>
<span class="detail-value">${escapeHtml(playlist.cacheAge || "-")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Track Breakdown</span>
<span class="detail-value">${breakdownBadges.join(" ")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Completion</span>
<div class="completion-bar">
<div class="completion-fill ${summary.completionClass}" style="width:${Math.max(0, Math.min(summary.completionPct, 100))}%;"></div>
</div>
</div>
</div>
</div>
</td>
</tr>
`;
}
function createPlaylistRowPair(playlist, index) {
const template = document.createElement("template");
template.innerHTML = renderPlaylistRowPairMarkup(playlist, index).trim();
const [mainRow, detailsRow] = template.content.querySelectorAll("tr");
return { mainRow, detailsRow };
}
if (typeof window !== "undefined") {
window.toggleRowMenu = toggleRowMenu;
window.closeRowMenu = closeRowMenu;
@@ -183,10 +525,11 @@ if (typeof window !== "undefined") {
}
bindRowMenuHandlers();
bindTableRowHandlers();
export function updateStatusUI(data) {
const versionEl = document.getElementById("version");
if (versionEl) versionEl.textContent = "v" + data.version;
const sidebarVersionEl = document.getElementById("sidebar-version");
if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version;
const backendTypeEl = document.getElementById("backend-type");
if (backendTypeEl) backendTypeEl.textContent = data.backendType;
@@ -268,9 +611,15 @@ export function updateStatusUI(data) {
export function updatePlaylistsUI(data) {
const tbody = document.getElementById("playlist-table-body");
if (!tbody) {
return;
}
const playlists = data.playlists || [];
if (playlists.length === 0) {
expandedInjectedPlaylistDetails.clear();
openInjectedPlaylistMenuKey = null;
tbody.innerHTML =
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
renderGuidance("playlists-guidance", [
@@ -324,89 +673,68 @@ export function updatePlaylistsUI(data) {
});
renderGuidance("playlists-guidance", guidance);
tbody.innerHTML = playlists
.map((playlist, index) => {
const summary = getPlaylistStatusSummary(playlist);
const detailsRowId = `playlist-details-${index}`;
const menuId = `playlist-menu-${index}`;
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
const escapedPlaylistName = escapeJs(playlist.name);
const escapedSyncSchedule = escapeJs(syncSchedule);
const existingPairs = new Map();
Array.from(
tbody.querySelectorAll("tr.compact-row[data-details-key]"),
).forEach((mainRow) => {
const detailsKey = mainRow.getAttribute("data-details-key");
if (!detailsKey || existingPairs.has(detailsKey)) {
return;
}
const breakdownBadges = [
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
`<span class="status-pill info">${summary.externalMatched} External</span>`,
];
const detailsRowId = mainRow.getAttribute("data-details-row");
const detailsRow =
(detailsRowId && document.getElementById(detailsRowId)) ||
mainRow.nextElementSibling;
if (!detailsRow) {
return;
}
if (summary.externalMissing > 0) {
breakdownBadges.push(
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
);
}
existingPairs.set(detailsKey, { mainRow, detailsRow });
});
return `
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
<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="false"
onclick="toggleDetailsRow(event, '${detailsRowId}')">Details</button>
<div class="row-actions-wrap">
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
onclick="toggleRowMenu(event, '${menuId}')">...</button>
<div class="row-actions-menu" id="${menuId}" role="menu">
<button onclick="closeRowMenu(event, '${menuId}'); viewTracks('${escapedPlaylistName}')">View Tracks</button>
<button onclick="closeRowMenu(event, '${menuId}'); refreshPlaylist('${escapedPlaylistName}')">Refresh</button>
<button onclick="closeRowMenu(event, '${menuId}'); matchPlaylistTracks('${escapedPlaylistName}')">Rematch</button>
<button onclick="closeRowMenu(event, '${menuId}'); clearPlaylistCache('${escapedPlaylistName}')">Rebuild</button>
<button onclick="closeRowMenu(event, '${menuId}'); editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit Schedule</button>
<hr>
<button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); removePlaylist('${escapedPlaylistName}')">Remove Playlist</button>
</div>
</div>
</td>
</tr>
<tr id="${detailsRowId}" class="details-row" 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" onclick="editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit</button>
</span>
</div>
<div class="detail-item">
<span class="detail-label">Cache Age</span>
<span class="detail-value">${escapeHtml(playlist.cacheAge || "-")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Track Breakdown</span>
<span class="detail-value">${breakdownBadges.join(" ")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Completion</span>
<div class="completion-bar">
<div class="completion-fill ${summary.completionClass}" style="width:${Math.max(0, Math.min(summary.completionPct, 100))}%;"></div>
</div>
</div>
</div>
</div>
</td>
</tr>
`;
})
.join("");
const orderedRows = [];
playlists.forEach((playlist, index) => {
const detailsKey = `${playlist.id || playlist.name || index}`;
const { mainRow: nextMainRow, detailsRow: nextDetailsRow } =
createPlaylistRowPair(playlist, index);
const existingPair = existingPairs.get(detailsKey);
if (!existingPair) {
orderedRows.push(nextMainRow, nextDetailsRow);
return;
}
syncPlaylistMainRow(
existingPair.mainRow,
nextMainRow,
detailsKey === openInjectedPlaylistMenuKey,
);
syncPlaylistDetailsRow(existingPair.detailsRow, nextDetailsRow);
orderedRows.push(existingPair.mainRow, existingPair.detailsRow);
existingPairs.delete(detailsKey);
});
const activeRows = new Set(orderedRows);
orderedRows.forEach((row) => {
tbody.append(row);
});
Array.from(tbody.children).forEach((row) => {
if (!activeRows.has(row)) {
row.remove();
}
});
if (
openInjectedPlaylistMenuKey &&
!playlists.some(
(playlist, index) =>
`${playlist.id || playlist.name || index}` === openInjectedPlaylistMenuKey,
)
) {
openInjectedPlaylistMenuKey = null;
}
}
export function updateTrackMappingsUI(data) {
@@ -478,9 +806,9 @@ export function updateDownloadsUI(data) {
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
<td>
<button onclick="downloadFile('${escapeJs(f.path)}')"
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
<button onclick="deleteDownload('${escapeJs(f.path)}')"
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
</td>
</tr>
@@ -634,26 +962,27 @@ export function updateJellyfinPlaylistsUI(data) {
.map((playlist, index) => {
const detailsRowId = `jellyfin-details-${index}`;
const menuId = `jellyfin-menu-${index}`;
const statsPending = Boolean(playlist.statsPending);
const localCount = playlist.localTracks || 0;
const externalCount = playlist.externalTracks || 0;
const externalAvailable = playlist.externalAvailable || 0;
const escapedId = escapeJs(playlist.id);
const escapedName = escapeJs(playlist.name);
const escapedId = escapeHtml(playlist.id);
const escapedName = escapeHtml(playlist.name);
const statusClass = playlist.isConfigured ? "success" : "info";
const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked";
const actionButtons = playlist.isConfigured
? `
<button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
<button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); unlinkPlaylist('${escapedId}', '${escapedName}')">Unlink from Spotify</button>
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
<button class="danger-item" data-action="unlinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Unlink from Spotify</button>
`
: `
<button onclick="closeRowMenu(event, '${menuId}'); openLinkPlaylist('${escapedId}', '${escapedName}')">Link to Spotify</button>
<button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
<button data-action="openLinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Link to Spotify</button>
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
`;
return `
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
<tr class="compact-row" data-details-row="${detailsRowId}">
<td>
<div class="name-cell">
<strong>${escapeHtml(playlist.name)}</strong>
@@ -661,16 +990,15 @@ export function updateJellyfinPlaylistsUI(data) {
</div>
</td>
<td>
<span class="track-count">${localCount + externalAvailable}</span>
<div class="meta-text">L ${localCount} E ${externalAvailable}/${externalCount}</div>
<span class="track-count">${statsPending ? "..." : localCount + externalAvailable}</span>
<div class="meta-text">${statsPending ? "Loading track stats..." : `L ${localCount} • E ${externalAvailable}/${externalCount}`}</div>
</td>
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
<td class="row-controls">
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false"
onclick="toggleDetailsRow(event, '${detailsRowId}')">Details</button>
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false">Details</button>
<div class="row-actions-wrap">
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
onclick="toggleRowMenu(event, '${menuId}')">...</button>
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
<div class="row-actions-menu" id="${menuId}" role="menu">
${actionButtons}
</div>
@@ -683,11 +1011,11 @@ export function updateJellyfinPlaylistsUI(data) {
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">Local Tracks</span>
<span class="detail-value">${localCount}</span>
<span class="detail-value">${statsPending ? "..." : localCount}</span>
</div>
<div class="detail-item">
<span class="detail-label">External Tracks</span>
<span class="detail-value">${externalAvailable}/${externalCount}</span>
<span class="detail-value">${statsPending ? "Loading..." : `${externalAvailable}/${externalCount}`}</span>
</div>
<div class="detail-item">
<span class="detail-label">Linked Spotify ID</span>
@@ -783,110 +1111,6 @@ export function updateEndpointUsageUI(data) {
.join("");
}
export function updateSquidWtfEndpointHealthUI(data) {
const tbody = document.getElementById("squidwtf-endpoints-table-body");
const testedAt = document.getElementById("squidwtf-endpoints-tested-at");
const endpoints = data.Endpoints || data.endpoints || [];
const testedAtValue = data.TestedAtUtc || data.testedAtUtc;
if (testedAt) {
testedAt.textContent = testedAtValue
? `Last tested ${new Date(testedAtValue).toLocaleString()}`
: "Not tested yet";
}
if (!tbody) {
return;
}
if (endpoints.length === 0) {
tbody.innerHTML =
'<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:40px;">No SquidWTF endpoints configured.</td></tr>';
return;
}
tbody.innerHTML = endpoints
.map((row) => {
const apiResult = normalizeProbeResult(row.Api || row.api);
const streamingResult = normalizeProbeResult(row.Streaming || row.streaming);
const host = row.Host || row.host || "-";
return `
<tr>
<td>
<strong>${escapeHtml(host)}</strong>
</td>
<td style="text-align:center;">
${renderProbeDot(apiResult, "api")}
</td>
<td style="text-align:center;">
${renderProbeDot(streamingResult, "streaming")}
</td>
</tr>
`;
})
.join("");
}
function normalizeProbeResult(result) {
if (!result) {
return {
configured: false,
isUp: false,
state: "unknown",
statusCode: null,
latencyMs: null,
requestUrl: null,
error: null,
};
}
return {
configured: result.Configured ?? result.configured ?? false,
isUp: result.IsUp ?? result.isUp ?? false,
state: result.State ?? result.state ?? "unknown",
statusCode: result.StatusCode ?? result.statusCode ?? null,
latencyMs: result.LatencyMs ?? result.latencyMs ?? null,
requestUrl: result.RequestUrl ?? result.requestUrl ?? null,
error: result.Error ?? result.error ?? null,
};
}
function renderProbeDot(result, type) {
const state = result.state || "unknown";
const isUp = result.isUp === true;
const variant = isUp
? type
: state === "missing"
? "unknown"
: "down";
const titleParts = [];
if (type === "api") {
titleParts.push(isUp ? "API up" : "API down");
} else {
titleParts.push(isUp ? "Streaming up" : "Streaming down");
}
if (result.statusCode != null) {
titleParts.push(`HTTP ${result.statusCode}`);
}
if (result.latencyMs != null) {
titleParts.push(`${result.latencyMs}ms`);
}
if (result.error) {
titleParts.push(result.error);
}
if (result.requestUrl) {
titleParts.push(result.requestUrl);
}
return `<span class="endpoint-health-dot ${variant}" title="${escapeHtml(titleParts.join(" • "))}"></span>`;
}
export function showErrorState(message) {
const statusBadge = document.getElementById("spotify-status");
if (statusBadge) {
+4
View File
@@ -0,0 +1,4 @@
This folder contains small “view” modules for the admin UI.
Goal: keep `js/main.js` as orchestration only, while view modules encapsulate DOM wiring for each section.
@@ -0,0 +1,22 @@
export function initNavigationView({ switchTab } = {}) {
const doSwitch =
typeof switchTab === "function" ? switchTab : (tab) => window.switchTab?.(tab);
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
doSwitch(tab.dataset.tab);
});
});
document.querySelectorAll(".sidebar-link").forEach((link) => {
link.addEventListener("click", () => {
doSwitch(link.dataset.tab);
});
});
const hash = window.location.hash.substring(1);
if (hash) {
doSwitch(hash);
}
}
@@ -0,0 +1,30 @@
export function initScrobblingView({
isAuthenticated,
loadScrobblingConfig,
} = {}) {
const canLoad =
typeof isAuthenticated === "function" ? isAuthenticated : () => false;
const load =
typeof loadScrobblingConfig === "function"
? loadScrobblingConfig
: () => window.loadScrobblingConfig?.();
function onActivateScrobbling() {
if (canLoad()) {
load();
}
}
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
if (scrobblingTab) {
scrobblingTab.addEventListener("click", onActivateScrobbling);
}
const scrobblingSidebar = document.querySelector(
'.sidebar-link[data-tab="scrobbling"]',
);
if (scrobblingSidebar) {
scrobblingSidebar.addEventListener("click", onActivateScrobbling);
}
}
+23 -5
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spotify Track Mappings - Allstarr</title>
<link rel="stylesheet" href="styles.css" />
<style>
:root {
--bg-primary: #0d1117;
@@ -668,13 +669,30 @@
</div>
<footer class="support-footer">
<p>
<p class="support-text">
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
supporting its development via
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
or
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
supporting its development
</p>
<ul class="support-funding-icons">
<li>
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
rel="noopener noreferrer" aria-label="Support on Ko-fi">
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
</a>
</li>
<li>
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
rel="noopener noreferrer" aria-label="GitHub Sponsors">
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
</a>
</li>
<li>
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
</a>
</li>
</ul>
</footer>
</body>
</html>
+22
View File
@@ -15,6 +15,7 @@ let localMapContext = null;
let localMapResults = [];
let localMapSelectedIndex = -1;
let externalMapContext = null;
const modalFocusState = new Map();
function showToast(message, type = "success", duration = 3000) {
const toast = document.createElement("div");
@@ -247,9 +248,26 @@ function toggleModal(modalId, shouldOpen) {
}
if (shouldOpen) {
const previousActive = document.activeElement;
modalFocusState.set(modalId, previousActive);
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.removeAttribute("aria-hidden");
modal.classList.add("active");
const firstFocusable = modal.querySelector(
'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])',
);
if (firstFocusable) {
firstFocusable.focus();
}
} else {
modal.classList.remove("active");
modal.setAttribute("aria-hidden", "true");
const previousActive = modalFocusState.get(modalId);
if (previousActive && typeof previousActive.focus === "function") {
previousActive.focus();
}
modalFocusState.delete(modalId);
}
}
@@ -627,6 +645,10 @@ function initializeEventListeners() {
closeLocalMapModal();
closeExternalMapModal();
});
document.querySelectorAll(".modal-overlay").forEach((modal) => {
modal.setAttribute("aria-hidden", "true");
});
}
// Initialize on page load
+371 -92
View File
@@ -58,6 +58,26 @@ body {
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 {
color: var(--text-secondary);
font-size: 0.85rem;
@@ -97,88 +117,165 @@ body {
text-decoration: underline;
}
.endpoint-health-toolbar {
.support-text {
margin: 0 0 10px;
}
.support-funding-icons {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.endpoint-health-legend {
display: flex;
gap: 14px;
flex-wrap: wrap;
color: var(--text-secondary);
font-size: 0.85rem;
list-style: none;
margin: 0;
padding: 0;
}
.endpoint-health-legend span {
.support-funding-icons li {
display: flex;
align-items: center;
}
.support-badge .support-funding-icons {
justify-content: flex-start;
}
.support-footer .support-funding-icons {
justify-content: center;
}
.support-funding-link {
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: center;
opacity: 0.9;
line-height: 0;
}
.endpoint-health-last-tested {
color: var(--text-secondary);
font-size: 0.85rem;
.support-badge .support-funding-link:hover,
.support-footer .support-funding-link:hover {
opacity: 1;
text-decoration: none;
color: inherit;
}
.endpoint-health-dot {
width: 12px;
height: 12px;
display: inline-block;
border-radius: 999px;
background: var(--text-secondary);
box-shadow: inset 0 0 0 1px rgba(13, 17, 23, 0.25);
}
.endpoint-health-dot.api,
.endpoint-health-dot.up.api {
background: var(--success);
}
.endpoint-health-dot.streaming,
.endpoint-health-dot.up.streaming {
background: var(--accent);
}
.endpoint-health-dot.down {
background: var(--error);
}
.endpoint-health-dot.unknown {
background: var(--text-secondary);
}
.endpoint-health-table td,
.endpoint-health-table th {
vertical-align: middle;
}
.endpoint-link-cell {
max-width: 260px;
}
.endpoint-url {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: monospace;
font-size: 0.82rem;
}
.endpoint-url.muted {
color: var(--text-secondary);
.support-funding-link img {
display: block;
height: 30px;
width: auto;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
max-width: 1280px;
margin: 0 auto 0 0;
padding: 20px 20px 20px 8px;
}
.app-shell {
display: grid;
grid-template-columns: 260px 1fr;
gap: 18px;
align-items: start;
}
.sidebar {
position: sticky;
top: 16px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(22, 27, 34, 0.8);
backdrop-filter: blur(8px);
padding: 14px;
max-height: calc(100vh - 32px);
overflow: auto;
}
.sidebar-brand {
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.sidebar-title {
font-weight: 700;
font-size: 1.05rem;
letter-spacing: 0.2px;
}
.title-link {
color: inherit;
text-decoration: none;
}
.title-link:hover {
color: var(--accent-hover);
}
.sidebar-subtitle {
margin-top: 2px;
color: var(--text-secondary);
font-size: 0.82rem;
font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
monospace;
}
.sidebar-status {
margin-top: 12px;
}
.sidebar-status .status-badge {
display: flex;
width: 100%;
justify-content: center;
padding: 8px 12px;
border-radius: 8px;
}
.sidebar-nav {
display: grid;
gap: 6px;
}
.sidebar-link {
width: 100%;
text-align: left;
padding: 9px 10px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
}
.sidebar-link:hover {
background: rgba(33, 38, 45, 0.7);
color: var(--text-primary);
}
.sidebar-link.active {
background: rgba(88, 166, 255, 0.12);
border-color: rgba(88, 166, 255, 0.35);
color: #9ecbff;
}
.sidebar-footer {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border);
display: grid;
gap: 10px;
}
.sidebar-footer button {
width: 100%;
}
.app-main {
min-width: 0;
}
.top-tabs,
.tabs.top-tabs {
display: none !important;
}
.support-footer {
@@ -190,21 +287,6 @@ body {
text-align: center;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 30px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.auth-user {
color: var(--text-secondary);
font-size: 0.85rem;
@@ -218,12 +300,6 @@ h1 {
gap: 10px;
}
h1 .version {
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: normal;
}
.status-badge {
display: inline-flex;
align-items: center;
@@ -724,7 +800,8 @@ button.danger:hover {
}
input,
select {
select,
textarea {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
@@ -734,15 +811,24 @@ select {
}
input:focus,
select:focus {
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
}
input::placeholder {
input::placeholder,
textarea::placeholder {
color: var(--text-secondary);
}
textarea {
width: 100%;
resize: vertical;
line-height: 1.5;
font-family: inherit;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr 120px auto;
@@ -975,6 +1061,28 @@ input::placeholder {
}
@media (max-width: 768px) {
.container {
padding: 12px;
}
.app-shell {
grid-template-columns: 1fr;
gap: 12px;
}
.sidebar {
position: static;
max-height: none;
}
.report-issue-layout {
grid-template-columns: 1fr;
}
.report-preview-panel textarea {
min-height: 360px;
}
.support-badge {
right: 12px;
bottom: 12px;
@@ -997,6 +1105,177 @@ input::placeholder {
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 */
.hidden {
display: none;
}
.text-secondary {
color: var(--text-secondary);
}
.text-warning {
color: var(--warning);
}
.text-error {
color: var(--error);
}
.mb-12 {
margin-bottom: 12px;
}
.mb-16 {
margin-bottom: 16px;
}
.mt-8 {
margin-top: 8px;
}
.mt-12 {
margin-top: 12px;
}
.w-full {
width: 100%;
}
.flex-row-wrap {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.flex-row-wrap-8 {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.summary-box {
display: flex;
gap: 20px;
margin-bottom: 16px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 6px;
}
.summary-label {
color: var(--text-secondary);
}
.summary-value {
font-weight: 600;
margin-left: 8px;
}
.summary-value.success {
color: var(--success);
}
.summary-value.warning {
color: var(--warning);
}
.summary-value.accent {
color: var(--accent);
}
.callout {
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
}
.callout.warning {
background: rgba(245, 158, 11, 0.12);
border-color: var(--warning);
color: var(--text-secondary);
}
.callout.warning-strong {
background: rgba(255, 193, 7, 0.15);
border-color: #ffc107;
color: var(--text-primary);
}
.callout.danger {
background: rgba(248, 81, 73, 0.15);
border-color: var(--error);
color: var(--text-primary);
}
.pill-card {
background: var(--bg-tertiary);
padding: 16px;
border-radius: 8px;
}
.stats-grid-auto {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.max-h-600 {
max-height: 600px;
overflow-y: auto;
}
.jellyfin-user-form-group {
margin: 0;
flex: 1;
min-width: 200px;
}
.jellyfin-user-form-group label {
display: block;
margin-bottom: 4px;
font-size: 0.85rem;
}
.tracks-list {
max-height: 400px;
overflow-y: auto;