Compare commits

..

1 Commits

122 changed files with 2056 additions and 10790 deletions
+9 -31
View File
@@ -32,7 +32,6 @@ CORS_ALLOW_CREDENTIALS=false
# Redis data persistence directory (default: ./redis-data)
# Contains Redis RDB snapshots and AOF logs for crash recovery
# Keep this separate from CACHE_PATH / ./cache. It should only contain Valkey persistence files.
REDIS_DATA_PATH=./redis-data
# ===== CACHE TTL SETTINGS =====
@@ -41,8 +40,8 @@ REDIS_DATA_PATH=./redis-data
# All values are configurable via Web UI (Configuration tab > Cache Settings)
# Changes require container restart to apply
# Search results cache duration in minutes (default: 1)
CACHE_SEARCH_RESULTS_MINUTES=1
# Search results cache duration in minutes (default: 120 = 2 hours)
CACHE_SEARCH_RESULTS_MINUTES=120
# Playlist cover images cache duration in hours (default: 168 = 1 week)
CACHE_PLAYLIST_IMAGES_HOURS=168
@@ -69,11 +68,6 @@ CACHE_ODESLI_LOOKUP_DAYS=60
# Jellyfin proxy images cache duration in days (default: 14 = 2 weeks)
CACHE_PROXY_IMAGES_DAYS=14
# Transcoded audio file cache duration in minutes (default: 60 = 1 hour)
# Quality-override files (lower quality streams for cellular/transcoding)
# are cached in {downloads}/transcoded/ and cleaned up after this duration
CACHE_TRANSCODE_MINUTES=60
# ===== SUBSONIC/NAVIDROME CONFIGURATION =====
# Server URL (required if using Subsonic backend)
@@ -100,10 +94,12 @@ JELLYFIN_LIBRARY_ID=
# Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF)
MUSIC_SERVICE=SquidWTF
# Base directory for permanently downloaded tracks (default: ./downloads)
# Note: Temporarily cached tracks are stored in {DOWNLOAD_PATH}/cache. Favorited
# tracks are stored separately in KEPT_PATH (default: ./kept)
DOWNLOAD_PATH=./downloads
# Base directory for all downloads (default: ./downloads)
# This creates three subdirectories:
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
# - downloads/kept/ - Favorited external tracks (always permanent)
Library__DownloadPath=./downloads
# ===== SQUIDWTF CONFIGURATION =====
# Preferred audio quality (optional, default: LOSSLESS)
@@ -114,9 +110,6 @@ DOWNLOAD_PATH=./downloads
# If not specified, LOSSLESS (16-bit FLAC) will be used
SQUIDWTF_QUALITY=LOSSLESS
# Minimum interval between requests in milliseconds (default: 200)
SQUIDWTF_MIN_REQUEST_INTERVAL_MS=200
# ===== DEEZER CONFIGURATION =====
# Deezer ARL token (required if using Deezer)
# See README.md for instructions on how to get this token
@@ -129,9 +122,6 @@ DEEZER_ARL_FALLBACK=
# If not specified, the highest available quality for your account will be used
DEEZER_QUALITY=
# Minimum interval between requests in milliseconds (default: 200)
DEEZER_MIN_REQUEST_INTERVAL_MS=200
# ===== QOBUZ CONFIGURATION =====
# Qobuz user authentication token (required if using Qobuz)
# Get this from your browser after logging into play.qobuz.com
@@ -146,9 +136,6 @@ QOBUZ_USER_ID=
# If not specified, the highest available quality will be used
QOBUZ_QUALITY=
# Minimum interval between requests in milliseconds (default: 200)
QOBUZ_MIN_REQUEST_INTERVAL_MS=200
# ===== MUSICBRAINZ CONFIGURATION =====
# Enable MusicBrainz metadata lookups (optional, default: true)
MUSICBRAINZ_ENABLED=true
@@ -276,11 +263,6 @@ SCROBBLING_ENABLED=false
# This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz)
SCROBBLING_LOCAL_TRACKS_ENABLED=false
# Emit synthetic local "played" events from progress when local scrobbling is disabled (default: false)
# Only enable this if you explicitly need UserPlayedItems-based plugin triggering.
# Keep false to avoid duplicate local scrobbles with Jellyfin plugins.
SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED=false
# ===== LAST.FM SCROBBLING =====
# Enable Last.fm scrobbling (default: false)
SCROBBLING_LASTFM_ENABLED=false
@@ -319,11 +301,7 @@ SCROBBLING_LISTENBRAINZ_USER_TOKEN=
# Enable detailed request logging (default: false)
# When enabled, logs every incoming HTTP request with full details:
# - Method, path, query string
# - Headers
# - Headers (auth tokens are masked)
# - Response status and timing
# Useful for debugging client issues and seeing what API calls are being made
DEBUG_LOG_ALL_REQUESTS=false
# Redact auth/query sensitive values in request logs (default: false).
# Set true if you want DEBUG_LOG_ALL_REQUESTS while still masking tokens.
DEBUG_REDACT_SENSITIVE_REQUEST_VALUES=false
+2 -2
View File
@@ -1,6 +1,6 @@
# These are supported funding model platforms
github: [SoPat712]
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: joshpatra
@@ -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: treeman183
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
-1
View File
@@ -13,7 +13,6 @@ COPY allstarr/ allstarr/
COPY allstarr.Tests/ allstarr.Tests/
RUN dotnet publish allstarr/allstarr.csproj -c Release -o /app/publish
COPY .env.example /app/publish/
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0
+77 -30
View File
@@ -5,7 +5,7 @@
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io%2Fsopat712%2Fallstarr-blue)](https://github.com/SoPat712/allstarr/pkgs/container/allstarr)
[![License](https://img.shields.io/badge/license-GPL--3.0-green)](LICENSE)
A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** servers. When a song isn't in your local library, it gets fetched from your configured provider, downloaded, and served to your client. The downloaded song then lives in your library for next time.
A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** and **Subsonic-compatible** servers. When a song isn't in your local library, it gets fetched from your configured provider, downloaded, and served to your client. The downloaded song then lives in your library for next time.
## Quick Start
@@ -39,6 +39,7 @@ The proxy will be available at `http://localhost:5274`.
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
<img width="1664" height="1101" alt="image" src="https://github.com/user-attachments/assets/9159100b-7e11-449e-8530-517d336d6bd2" />
### Features
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with just a few clicks
@@ -65,16 +66,17 @@ Allstarr includes a web UI for easy configuration and playlist management, acces
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
4. **Restart Allstarr** to apply changes (should be a banner)
4. **Restart** to apply changes (should be a banner)
Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them.
### Configuration Persistence
The web UI updates your `.env` file directly. Allstarr reloads that file on startup, so a normal container restart is enough for UI changes to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
There's an environment variable to modify this.
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
### Nginx Proxy Setup (Optional)
@@ -85,20 +87,20 @@ This service only exposes ports internally. You can use nginx to proxy to it, ho
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Security headers
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
# Streaming settings
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 600s;
location / {
proxy_pass http://allstarr:8080;
proxy_set_header Host $host;
@@ -117,7 +119,7 @@ This project brings together all the music streaming providers into one unified
## Features
- **Dual Backend Support**: Works with Jellyfin
- **Dual Backend Support**: Works with Jellyfin and Subsonic-compatible servers (Navidrome, Airsonic, etc.)
- **Multi-Provider Architecture**: Pluggable system for streaming providers (Deezer, Qobuz, SquidWTF)
- **Transparent Proxy**: Sits between your music clients and media server
- **Automatic Search**: Searches streaming providers when songs aren't local
@@ -137,21 +139,43 @@ This project brings together all the music streaming providers into one unified
## Supported Backends
### Jellyfin
[Jellyfin](https://jellyfin.org/) is a free and open-source media server. Allstarr connects via the Jellyfin API using your Jellyfin user login. (I plan to move this to api key if possible)
**Compatible Jellyfin clients:**
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
<img width="1691" height="1128" alt="image" src="https://github.com/user-attachments/assets/c602f71c-c4dd-49a9-b533-1558e24a9f45" />
<img width="1691" height="1128" alt="image" src="https://github.com/user-attachments/assets/c602f71c-c4dd-49a9-b533-1558e24a9f45" />
- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android)
<img width="523" height="1025" alt="image" src="https://github.com/user-attachments/assets/135e2721-5fd7-482f-bb06-b0736003cfe7" />
<img width="523" height="1025" alt="image" src="https://github.com/user-attachments/assets/135e2721-5fd7-482f-bb06-b0736003cfe7" />
- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS)
- [Finer Player](https://monk-studio.com/finer) (iOS/iPadOS/macOS/tvOS)
_Working on getting more currently_
### Subsonic/Navidrome
[Navidrome](https://www.navidrome.org/) and other Subsonic-compatible servers are supported via the Subsonic API.
**Compatible Subsonic clients:**
#### PC
- [Aonsoku](https://github.com/victoralvesf/aonsoku)
- [Feishin](https://github.com/jeffvli/feishin)
- [Subplayer](https://github.com/peguerosdc/subplayer)
- [Aurial](https://github.com/shrimpza/aurial)
#### Android
- [Tempus](https://github.com/eddyizm/tempus)
- [Substreamer](https://substreamerapp.com/)
#### iOS
- [Narjo](https://www.reddit.com/r/NarjoApp/)
- [Arpeggi](https://www.reddit.com/r/arpeggiApp/)
> **Want to improve client compatibility?** Pull requests are welcome!
### Incompatible Clients
@@ -174,12 +198,13 @@ Choose your preferred provider via the `MUSIC_SERVICE` environment variable. Add
- A running media server:
- **Jellyfin**: Any recent version with API access enabled
- **Subsonic**: Navidrome or other Subsonic-compatible server
- **Docker and Docker Compose** (recommended) - includes Redis and Spotify Lyrics API sidecars
- Redis is used for caching (search results, playlists, lyrics, etc.)
- Spotify Lyrics API provides synchronized lyrics for Spotify tracks
- Credentials for at least one music provider (IF NOT USING SQUIDWTF):
- **Deezer**: ARL token from browser cookies
- **Qobuz**: User ID + User Auth Token from browser localStorage ([see Wiki guide](<https://github.com/V1ck3s/octo-fiesta/wiki/Getting-Qobuz-Credentials-(User-ID-&-Token)>))
- **Qobuz**: User ID + User Auth Token from browser localStorage ([see Wiki guide](https://github.com/V1ck3s/octo-fiesta/wiki/Getting-Qobuz-Credentials-(User-ID-&-Token)))
- **OR** [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) for manual installation (requires separate Redis setup)
## Configuration
@@ -187,39 +212,47 @@ Choose your preferred provider via the `MUSIC_SERVICE` environment variable. Add
### Environment Setup
1. **Create your environment file**
```bash
cp .env.example .env
```
2. **Edit the `.env` file** with your configuration:
**Server Settings:**
**For Jellyfin backend:**
```bash
# Backend selection
BACKEND_TYPE=Jellyfin
# Jellyfin server URL
JELLYFIN_URL=http://localhost:8096
# API key (get from Jellyfin Dashboard > API Keys)
JELLYFIN_API_KEY=your-api-key-here
# User ID (from Jellyfin Dashboard > Users > click user > check URL)
JELLYFIN_USER_ID=your-user-id-here
# Music library ID (optional, auto-detected if not set)
JELLYFIN_LIBRARY_ID=
```
**For Subsonic/Navidrome backend:**
```bash
# Backend selection
BACKEND_TYPE=Subsonic
# Navidrome/Subsonic server URL
SUBSONIC_URL=http://localhost:4533
```
**Common settings (both backends):**
```bash
# Path where downloaded songs will be stored
DOWNLOAD_PATH=./downloads
# Music service to use: SquidWTF, Deezer, or Qobuz
MUSIC_SERVICE=SquidWTF
# Storage mode: Permanent or Cache
STORAGE_MODE=Permanent
```
@@ -227,7 +260,7 @@ Choose your preferred provider via the `MUSIC_SERVICE` environment variable. Add
See the full `.env.example` for all available options including Deezer/Qobuz credentials.
3. **Configure your client**
Point your music client to `http://localhost:5274` instead of your media server directly.
> **Tip**: Make sure the `DOWNLOAD_PATH` points to a directory that your media server can scan, so downloaded songs appear in your library.
@@ -239,24 +272,21 @@ For detailed configuration options, see [CONFIGURATION.md](CONFIGURATION.md).
If you prefer to run Allstarr without Docker:
1. **Clone the repository**
```bash
git clone https://github.com/SoPat712/allstarr.git
cd allstarr
```
2. **Restore dependencies**
```bash
dotnet restore
```
3. **Configure the application**
Edit `allstarr/appsettings.json`:
**For Jellyfin:**
```json
{
"Backend": {
@@ -273,18 +303,33 @@ If you prefer to run Allstarr without Docker:
}
}
```
**For Subsonic/Navidrome:**
```json
{
"Backend": {
"Type": "Subsonic"
},
"Subsonic": {
"Url": "http://localhost:4533",
"MusicService": "SquidWTF"
},
"Library": {
"DownloadPath": "./downloads"
}
}
```
4. **Run the server**
```bash
cd allstarr
dotnet run
```
The proxy will start on `http://localhost:5274` by default.
5. **Configure your client**
Point your music client to `http://localhost:5274` instead of your media server directly.
## Documentation
@@ -296,6 +341,7 @@ If you prefer to run Allstarr without Docker:
## Limitations
- **Playlist Search**: Subsonic clients like Aonsoku filter playlists client-side from a cached `getPlaylists` call. Streaming provider playlists appear in global search (`search3`) but not in the Playlists tab filter.
- **Region Restrictions**: Some tracks may be unavailable depending on your region and provider.
- **Token Expiration**: Provider authentication tokens expire and need periodic refresh.
@@ -310,6 +356,7 @@ GPL-3.0
- [Jellyfin Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file) - The plugin that I **strongly** recommend using alongside this repo
- [Jellyfin](https://jellyfin.org/) - The free and open-source media server
- [Navidrome](https://www.navidrome.org/) - The excellent self-hosted music server
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification
- [Hi-Fi API](https://github.com/binimum/hifi-api) - These people do some great work, and you should thank them for this even existing!
- [Deezer](https://www.deezer.com/) - Music streaming service
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
-16
View File
@@ -39,22 +39,6 @@ public class AuthHeaderHelperTests
Assert.True(request.Headers.Contains("X-Emby-Authorization"));
}
[Fact]
public void ForwardAuthHeaders_ShouldForwardXEmbyToken()
{
var headers = new HeaderDictionary
{
["X-Emby-Token"] = "abc"
};
using var request = new HttpRequestMessage();
var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request);
Assert.True(forwarded);
Assert.True(request.Headers.TryGetValues("X-Emby-Token", out var values));
Assert.Contains("abc", values);
}
[Fact]
public void ForwardAuthHeaders_ShouldForwardStandardAuthorization()
{
@@ -1,60 +0,0 @@
using System.Net;
using allstarr.Middleware;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
namespace allstarr.Tests;
public class BotProbeBlockMiddlewareTests
{
[Fact]
public async Task InvokeAsync_ScannerPath_Returns404WithoutCallingNext()
{
var nextInvoked = false;
var middleware = new BotProbeBlockMiddleware(
_ =>
{
nextInvoked = true;
return Task.CompletedTask;
},
NullLogger<BotProbeBlockMiddleware>.Instance);
var context = CreateContext("/.env");
await middleware.InvokeAsync(context);
Assert.False(nextInvoked);
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
}
[Fact]
public async Task InvokeAsync_NormalPath_CallsNext()
{
var nextInvoked = false;
var middleware = new BotProbeBlockMiddleware(
context =>
{
nextInvoked = true;
context.Response.StatusCode = StatusCodes.Status204NoContent;
return Task.CompletedTask;
},
NullLogger<BotProbeBlockMiddleware>.Instance);
var context = CreateContext("/System/Info/Public");
await middleware.InvokeAsync(context);
Assert.True(nextInvoked);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
}
private static DefaultHttpContext CreateContext(string path)
{
var context = new DefaultHttpContext();
context.Request.Path = path;
context.Request.Method = HttpMethods.Get;
context.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.10");
context.Response.Body = new MemoryStream();
return context;
}
}
-34
View File
@@ -1,34 +0,0 @@
using allstarr.Services.Common;
namespace allstarr.Tests;
public class BotProbeDetectorTests
{
[Theory]
[InlineData("/.env")]
[InlineData("/.git/config")]
[InlineData("/wordpress")]
[InlineData("/wp")]
[InlineData("/wp-admin/install.php")]
[InlineData("/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php")]
[InlineData("/public/vendor/laravel-filemanager/js/script.js")]
[InlineData("/_ignition/execute-solution")]
[InlineData("/debug/default/index")]
[InlineData("https://jellyfin.joshpatra.me/.git/config")]
public void IsHighConfidenceProbeUrl_ScannerPaths_ReturnsTrue(string path)
{
Assert.True(BotProbeDetector.IsHighConfidenceProbeUrl(path));
}
[Theory]
[InlineData("/System/Info/Public")]
[InlineData("/web/index.html")]
[InlineData("/Items/123")]
[InlineData("/Users/AuthenticateByName")]
[InlineData("/new")]
[InlineData("/blog")]
public void IsHighConfidenceProbeUrl_NormalProxyPaths_ReturnsFalse(string path)
{
Assert.False(BotProbeDetector.IsHighConfidenceProbeUrl(path));
}
}
+1 -33
View File
@@ -20,42 +20,10 @@ public class CacheKeyBuilderTests
"1635cd7d23144ba08251ebe22a56119e");
Assert.Equal(
"search:data:musicalbum:500:0:efa26829c37196b030fa31d127e0715b:datecreated,sortname:descending:true:1635cd7d23144ba08251ebe22a56119e:",
"search:data:musicalbum:500:0:efa26829c37196b030fa31d127e0715b:datecreated,sortname:descending:true:1635cd7d23144ba08251ebe22a56119e",
key);
}
[Fact]
public void SearchKey_ShouldDifferentiateFavoriteOnlyQueries()
{
var normalKey = CacheKeyBuilder.BuildSearchKey(
"Sunflower",
"Audio",
100,
0,
"parent",
"SortName",
"Ascending",
true,
"user-1",
"false");
var favoritesOnlyKey = CacheKeyBuilder.BuildSearchKey(
"Sunflower",
"Audio",
100,
0,
"parent",
"SortName",
"Ascending",
true,
"user-1",
"true");
Assert.NotEqual(normalKey, favoritesOnlyKey);
Assert.EndsWith(":false", normalKey);
Assert.EndsWith(":true", favoritesOnlyKey);
}
[Fact]
public void SearchKey_OldOverload_ShouldRemainCompatible()
{
@@ -1,180 +0,0 @@
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 async Task DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected()
public void 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 = await controller.DownloadFile("../kept-malicious/attack.mp3");
var result = 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 async Task DownloadFile_ValidPathInsideKeptFolder_AllowsDownload()
public void 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 = await controller.DownloadFile("Artist/track.mp3");
var result = controller.DownloadFile("Artist/track.mp3");
Assert.IsType<FileStreamResult>(result);
}
@@ -97,13 +97,7 @@ public class DownloadsControllerPathSecurityTests
return new DownloadsController(
NullLogger<DownloadsController>.Instance,
config)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
config);
}
private static string CreateTestRoot()
@@ -1,69 +0,0 @@
using System.Reflection;
using allstarr.Services.Common;
namespace allstarr.Tests;
public class FavoritesMigrationServiceTests
{
[Fact]
public void ParsePendingDeletions_ParsesLegacyDictionaryFormat()
{
var scheduledDeletion = new DateTime(2026, 3, 20, 14, 30, 0, DateTimeKind.Utc);
var parsed = ParsePendingDeletions($$"""
{
"ext-deezer-123": "{{scheduledDeletion:O}}"
}
""");
Assert.Single(parsed);
Assert.Equal(scheduledDeletion, parsed["ext-deezer-123"]);
}
[Fact]
public void ParsePendingDeletions_ParsesSetFormatUsingFallbackDate()
{
var fallbackDeleteAtUtc = new DateTime(2026, 3, 23, 12, 0, 0, DateTimeKind.Utc);
var parsed = ParsePendingDeletions("""
[
"ext-deezer-123",
"ext-qobuz-456"
]
""", fallbackDeleteAtUtc);
Assert.Equal(2, parsed.Count);
Assert.Equal(fallbackDeleteAtUtc, parsed["ext-deezer-123"]);
Assert.Equal(fallbackDeleteAtUtc, parsed["ext-qobuz-456"]);
}
[Fact]
public void ParsePendingDeletions_ThrowsForUnsupportedFormat()
{
var method = typeof(FavoritesMigrationService).GetMethod(
"ParsePendingDeletions",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
var ex = Assert.Throws<TargetInvocationException>(() =>
method!.Invoke(null, new object?[] { """{"bad":42}""", DateTime.UtcNow }));
Assert.IsType<System.Text.Json.JsonException>(ex.InnerException);
}
private static Dictionary<string, DateTime> ParsePendingDeletions(string json, DateTime? fallbackDeleteAtUtc = null)
{
var method = typeof(FavoritesMigrationService).GetMethod(
"ParsePendingDeletions",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
var result = method!.Invoke(null, new object?[]
{
json,
fallbackDeleteAtUtc ?? new DateTime(2026, 3, 23, 0, 0, 0, DateTimeKind.Utc)
});
return Assert.IsType<Dictionary<string, DateTime>>(result);
}
}
@@ -1,64 +0,0 @@
using Microsoft.AspNetCore.Http;
using allstarr.Services.Common;
namespace allstarr.Tests;
public class ImageConditionalRequestHelperTests
{
[Fact]
public void ComputeStrongETag_SamePayload_ReturnsStableQuotedHash()
{
var payload = new byte[] { 1, 2, 3, 4 };
var first = ImageConditionalRequestHelper.ComputeStrongETag(payload);
var second = ImageConditionalRequestHelper.ComputeStrongETag(payload);
Assert.Equal(first, second);
Assert.StartsWith("\"", first);
Assert.EndsWith("\"", first);
}
[Fact]
public void MatchesIfNoneMatch_WithExactMatch_ReturnsTrue()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "\"ABC123\""
};
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"ABC123\""));
}
[Fact]
public void MatchesIfNoneMatch_WithMultipleValues_ReturnsTrueForMatchingEntry()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "\"stale\", \"fresh\""
};
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"fresh\""));
}
[Fact]
public void MatchesIfNoneMatch_WithWildcard_ReturnsTrue()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "*"
};
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"anything\""));
}
[Fact]
public void MatchesIfNoneMatch_WithoutMatch_ReturnsFalse()
{
var headers = new HeaderDictionary
{
["If-None-Match"] = "\"ABC123\""
};
Assert.False(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"XYZ789\""));
}
}
@@ -1,85 +0,0 @@
using allstarr.Services.Common;
namespace allstarr.Tests;
public class InjectedPlaylistItemHelperTests
{
[Fact]
public void LooksLikeSyntheticLocalItem_ReturnsTrue_ForLocalAllstarrItem()
{
var item = new Dictionary<string, object?>
{
["Id"] = "49cf417c0fe00ad9cb1ed59f2debc384",
["ServerId"] = "allstarr"
};
Assert.True(InjectedPlaylistItemHelper.LooksLikeSyntheticLocalItem(item));
}
[Fact]
public void LooksLikeSyntheticLocalItem_ReturnsFalse_ForExternalInjectedItem()
{
var item = new Dictionary<string, object?>
{
["Id"] = "ext-spotify-4h4QlmocP3IuwYEj2j14p8",
["ServerId"] = "allstarr"
};
Assert.False(InjectedPlaylistItemHelper.LooksLikeSyntheticLocalItem(item));
}
[Fact]
public void LooksLikeSyntheticLocalItem_ReturnsFalse_ForRawJellyfinItem()
{
var item = new Dictionary<string, object?>
{
["Id"] = "49cf417c0fe00ad9cb1ed59f2debc384",
["ServerId"] = "c17d351d3af24c678a6d8049c212d522"
};
Assert.False(InjectedPlaylistItemHelper.LooksLikeSyntheticLocalItem(item));
}
[Fact]
public void LooksLikeLocalItemMissingGenreMetadata_ReturnsTrue_ForRawJellyfinItemMissingGenreItems()
{
var item = new Dictionary<string, object?>
{
["Id"] = "49cf417c0fe00ad9cb1ed59f2debc384",
["ServerId"] = "c17d351d3af24c678a6d8049c212d522",
["Genres"] = new[] { "Pop" }
};
Assert.True(InjectedPlaylistItemHelper.LooksLikeLocalItemMissingGenreMetadata(item));
}
[Fact]
public void LooksLikeLocalItemMissingGenreMetadata_ReturnsFalse_WhenGenresAndGenreItemsExist()
{
var item = new Dictionary<string, object?>
{
["Id"] = "49cf417c0fe00ad9cb1ed59f2debc384",
["ServerId"] = "c17d351d3af24c678a6d8049c212d522",
["Genres"] = new[] { "Pop" },
["GenreItems"] = new[]
{
new Dictionary<string, object?> { ["Name"] = "Pop", ["Id"] = "genre-id" }
}
};
Assert.False(InjectedPlaylistItemHelper.LooksLikeLocalItemMissingGenreMetadata(item));
}
[Fact]
public void LooksLikeLocalItemMissingGenreMetadata_ReturnsFalse_ForExternalInjectedItem()
{
var item = new Dictionary<string, object?>
{
["Id"] = "ext-spotify-4h4QlmocP3IuwYEj2j14p8",
["ServerId"] = "allstarr",
["Genres"] = new[] { "Pop" }
};
Assert.False(InjectedPlaylistItemHelper.LooksLikeLocalItemMissingGenreMetadata(item));
}
}
@@ -1,43 +0,0 @@
using System.Reflection;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinControllerSearchLimitTests
{
[Theory]
[InlineData(null, 20, true, 20, 20, 20)]
[InlineData("MusicAlbum", 20, true, 0, 20, 0)]
[InlineData("Audio", 20, true, 20, 0, 0)]
[InlineData("MusicArtist", 20, true, 0, 0, 20)]
[InlineData("Playlist", 20, true, 0, 20, 0)]
[InlineData("Playlist", 20, false, 0, 0, 0)]
[InlineData("Audio,MusicArtist", 15, true, 15, 0, 15)]
[InlineData("BoxSet", 10, true, 0, 0, 0)]
public void GetExternalSearchLimits_UsesRequestedItemTypes(
string? includeItemTypes,
int limit,
bool includePlaylistsAsAlbums,
int expectedSongLimit,
int expectedAlbumLimit,
int expectedArtistLimit)
{
var requestedTypes = string.IsNullOrWhiteSpace(includeItemTypes)
? null
: includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var method = typeof(JellyfinController).GetMethod(
"GetExternalSearchLimits",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
var result = ((int SongLimit, int AlbumLimit, int ArtistLimit))method!.Invoke(
null,
new object?[] { requestedTypes, limit, includePlaylistsAsAlbums })!;
Assert.Equal(expectedSongLimit, result.SongLimit);
Assert.Equal(expectedAlbumLimit, result.AlbumLimit);
Assert.Equal(expectedArtistLimit, result.ArtistLimit);
}
}
@@ -1,65 +0,0 @@
using System.Reflection;
using System.Text.Json;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinImageTagExtractionTests
{
[Fact]
public void ExtractImageTag_WithMatchingImageTagsObject_ReturnsRequestedTag()
{
using var document = JsonDocument.Parse("""
{
"ImageTags": {
"Primary": "playlist-primary-tag",
"Backdrop": "playlist-backdrop-tag"
}
}
""");
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
Assert.Equal("playlist-primary-tag", imageTag);
}
[Fact]
public void ExtractImageTag_WithPrimaryImageTagFallback_ReturnsFallbackTag()
{
using var document = JsonDocument.Parse("""
{
"PrimaryImageTag": "primary-fallback-tag"
}
""");
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
Assert.Equal("primary-fallback-tag", imageTag);
}
[Fact]
public void ExtractImageTag_WithoutMatchingTag_ReturnsNull()
{
using var document = JsonDocument.Parse("""
{
"ImageTags": {
"Backdrop": "playlist-backdrop-tag"
}
}
""");
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
Assert.Null(imageTag);
}
private static string? InvokeExtractImageTag(JsonElement item, string imageType)
{
var method = typeof(JellyfinController).GetMethod(
"ExtractImageTag",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
return (string?)method!.Invoke(null, new object?[] { item, imageType });
}
}
@@ -1,51 +0,0 @@
using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Services.Common;
namespace allstarr.Tests;
public class JellyfinItemSnapshotHelperTests
{
[Fact]
public void TryGetClonedRawItemSnapshot_RoundTripsThroughJsonSerialization()
{
var song = new Song { Id = "song-1", 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);
var roundTripped = JsonSerializer.Deserialize<Song>(JsonSerializer.Serialize(song));
Assert.NotNull(roundTripped);
Assert.True(JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTripped, out var rawItem));
Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString());
Assert.Equal("c17d351d3af24c678a6d8049c212d522", ((JsonElement)rawItem["ServerId"]!).GetString());
Assert.Equal(2234068710L, ((JsonElement)rawItem["RunTimeTicks"]!).GetInt64());
var mediaSources = (JsonElement)rawItem["MediaSources"]!;
Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind);
Assert.Equal(2234068710L, mediaSources[0].GetProperty("RunTimeTicks").GetInt64());
}
[Fact]
public void HasRawItemSnapshot_ReturnsFalse_WhenSnapshotMissing()
{
var song = new Song { Id = "song-1", IsLocal = true };
Assert.False(JellyfinItemSnapshotHelper.HasRawItemSnapshot(song));
Assert.False(JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(song, out _));
}
}
@@ -3,7 +3,6 @@ using Moq;
using allstarr.Models.Domain;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Common;
using allstarr.Services.Jellyfin;
using System.Text.Json;
@@ -221,35 +220,6 @@ public class JellyfinModelMapperTests
Assert.Equal("Main Artist", song.Artist);
}
[Fact]
public void ParseSong_PreservesRawJellyfinItemSnapshot()
{
var json = @"{
""Id"": ""song-abc"",
""Name"": ""Test Song"",
""Type"": ""Audio"",
""Album"": ""Test Album"",
""AlbumId"": ""album-123"",
""RunTimeTicks"": 2450000000,
""Artists"": [""Test Artist""],
""MediaSources"": [
{
""Id"": ""song-abc"",
""RunTimeTicks"": 2450000000
}
]
}";
var element = JsonDocument.Parse(json).RootElement;
var song = _mapper.ParseSong(element);
Assert.True(JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(song, out var rawItem));
Assert.Equal("song-abc", ((JsonElement)rawItem["Id"]!).GetString());
Assert.Equal(2450000000L, ((JsonElement)rawItem["RunTimeTicks"]!).GetInt64());
Assert.NotNull(song.JellyfinMetadata);
Assert.True(song.JellyfinMetadata!.ContainsKey("MediaSources"));
}
[Fact]
public void ParseAlbum_ExtractsArtistId_FromAlbumArtists()
{
@@ -1,41 +0,0 @@
using System.Reflection;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinPlaylistRouteMatchingTests
{
[Theory]
[InlineData("playlists/abc123/items", "abc123")]
[InlineData("Playlists/abc123/Items", "abc123")]
[InlineData("/playlists/abc123/items/", "abc123")]
public void GetExactPlaylistItemsRequestId_ExactPlaylistItemsRoute_ReturnsPlaylistId(string path, string expectedPlaylistId)
{
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
Assert.Equal(expectedPlaylistId, playlistId);
}
[Theory]
[InlineData("playlists/abc123/items/extra")]
[InlineData("users/user-1/playlists/abc123/items")]
[InlineData("items/abc123")]
[InlineData("playlists")]
public void GetExactPlaylistItemsRequestId_NonExactRoute_ReturnsNull(string path)
{
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
Assert.Null(playlistId);
}
private static T InvokePrivateStatic<T>(string methodName, params object?[] args)
{
var method = typeof(JellyfinController).GetMethod(
methodName,
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
var result = method!.Invoke(null, args);
return (T)result!;
}
}
-244
View File
@@ -117,35 +117,6 @@ public class JellyfinProxyServiceTests
Assert.False(captured.Headers.Contains("X-Emby-Authorization"));
}
[Fact]
public async Task GetJsonAsync_WithXEmbyToken_ForwardsTokenHeader()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
});
var headers = new HeaderDictionary
{
["X-Emby-Token"] = "token-123"
};
// Act
await _service.GetJsonAsync("Items", null, headers);
// Assert
Assert.NotNull(captured);
Assert.True(captured!.Headers.TryGetValues("X-Emby-Token", out var values));
Assert.Contains("token-123", values);
}
[Fact]
public async Task GetBytesAsync_ReturnsBodyAndContentType()
{
@@ -283,197 +254,6 @@ public class JellyfinProxyServiceTests
Assert.Equal("DateCreated,PremiereDate,ProductionYear", query.Get("Fields"));
}
[Fact]
public async Task GetJsonAsync_WithRepeatedFields_PreservesAllFieldParameters()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[]}")
});
// Act
await _service.GetJsonAsync(
"Playlists/playlist-123/Items?Fields=Genres&Fields=DateCreated&Fields=MediaSources&UserId=user-abc");
// Assert
Assert.NotNull(captured);
var query = captured!.RequestUri!.Query;
Assert.Contains("Fields=Genres", query);
Assert.Contains("Fields=DateCreated", query);
Assert.Contains("Fields=MediaSources", query);
Assert.Contains("UserId=user-abc", query);
}
[Fact]
public async Task GetPassthroughResponseAsync_WithRepeatedFields_PreservesAllFieldParameters()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[]}")
});
// Act
var response = await _service.GetPassthroughResponseAsync(
"Playlists/playlist-123/Items?Fields=Genres&Fields=DateCreated&Fields=MediaSources&UserId=user-abc");
// Assert
Assert.NotNull(captured);
var query = captured!.RequestUri!.Query;
Assert.Contains("Fields=Genres", query);
Assert.Contains("Fields=DateCreated", query);
Assert.Contains("Fields=MediaSources", query);
Assert.Contains("UserId=user-abc", query);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetPassthroughResponseAsync_WithClientAuth_ForwardsAuthHeader()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[]}")
});
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
};
// Act
var response = await _service.GetPassthroughResponseAsync(
"Playlists/playlist-123/Items?Fields=Genres",
headers);
// Assert
Assert.NotNull(captured);
Assert.True(captured!.Headers.TryGetValues("X-Emby-Authorization", out var values));
Assert.Contains("MediaBrowser Token=\"abc\"", values);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task SendAsync_WithNoBody_PreservesEmptyRequestBody()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
};
// Act
var (_, statusCode) = await _service.SendAsync(
HttpMethod.Post,
"Sessions/session-123/Playing/Pause?controllingUserId=user-123",
null,
headers);
// Assert
Assert.Equal(204, statusCode);
Assert.NotNull(captured);
Assert.Equal(HttpMethod.Post, captured!.Method);
Assert.Null(captured.Content);
}
[Fact]
public async Task SendAsync_WithCustomContentType_PreservesOriginalType()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
};
// Act
await _service.SendAsync(
HttpMethod.Put,
"Sessions/session-123/Command/DisplayMessage",
"{\"Text\":\"hello\"}",
headers,
"application/json; charset=utf-8");
// Assert
Assert.NotNull(captured);
Assert.Equal(HttpMethod.Put, captured!.Method);
Assert.NotNull(captured.Content);
Assert.Equal("application/json; charset=utf-8", captured.Content!.Headers.ContentType!.ToString());
}
[Fact]
public async Task GetPassthroughResponseAsync_WithAcceptEncoding_ForwardsCompressionHeaders()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"Items\":[]}")
});
var headers = new HeaderDictionary
{
["Accept-Encoding"] = "gzip, br",
["User-Agent"] = "Finamp/1.0",
["Accept-Language"] = "en-US"
};
// Act
var response = await _service.GetPassthroughResponseAsync(
"Playlists/playlist-123/Items?Fields=Genres",
headers);
// Assert
Assert.NotNull(captured);
Assert.True(captured!.Headers.TryGetValues("Accept-Encoding", out var encodings));
Assert.Contains("gzip", encodings);
Assert.Contains("br", encodings);
Assert.True(captured.Headers.TryGetValues("User-Agent", out var userAgents));
Assert.Contains("Finamp/1.0", userAgents);
Assert.True(captured.Headers.TryGetValues("Accept-Language", out var languages));
Assert.Contains("en-US", languages);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetJsonAsync_WithEndpointAndExplicitQuery_MergesWithExplicitPrecedence()
{
@@ -558,30 +338,6 @@ public class JellyfinProxyServiceTests
Assert.Contains("maxHeight=300", url);
}
[Fact]
public async Task GetImageAsync_WithTag_IncludesTagInQuery()
{
// Arrange
HttpRequestMessage? captured = null;
_mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[] { 1, 2, 3 })
});
// Act
await _service.GetImageAsync("item-123", "Primary", imageTag: "playlist-art-v2");
// Assert
Assert.NotNull(captured);
var query = System.Web.HttpUtility.ParseQueryString(captured!.RequestUri!.Query);
Assert.Equal("playlist-art-v2", query.Get("tag"));
}
[Fact]
@@ -47,8 +47,6 @@ public class JellyfinResponseBuilderTests
Assert.Equal(1, result["ParentIndexNumber"]);
Assert.Equal(2023, result["ProductionYear"]);
Assert.Equal(245 * TimeSpan.TicksPerSecond, result["RunTimeTicks"]);
Assert.NotNull(result["AudioInfo"]);
Assert.Equal(false, result["CanDelete"]);
}
[Fact]
@@ -77,44 +75,6 @@ public class JellyfinResponseBuilderTests
Assert.Equal("USRC12345678", providerIds["ISRC"]);
}
[Fact]
public void ConvertSongToJellyfinItem_ExternalExplicitSong_AppendsStreamingAndExplicitLabels()
{
var song = new Song
{
Id = "ext-squidwtf-song-12345",
Title = "Sunflower",
Artist = "Artist",
IsLocal = false,
ExternalProvider = "squidwtf",
ExternalId = "12345",
ExplicitContentLyrics = 1
};
var result = _builder.ConvertSongToJellyfinItem(song);
Assert.Equal("Sunflower [S] [E]", result["Name"]);
}
[Fact]
public void ConvertSongToJellyfinItem_ExternalCleanSong_AppendsOnlyStreamingLabel()
{
var song = new Song
{
Id = "ext-squidwtf-song-12345",
Title = "Sunflower",
Artist = "Artist",
IsLocal = false,
ExternalProvider = "squidwtf",
ExternalId = "12345",
ExplicitContentLyrics = 0
};
var result = _builder.ConvertSongToJellyfinItem(song);
Assert.Equal("Sunflower [S]", result["Name"]);
}
[Theory]
[InlineData("deezer")]
[InlineData("qobuz")]
@@ -194,9 +154,6 @@ public class JellyfinResponseBuilderTests
Assert.Equal("Famous Band", result["AlbumArtist"]);
Assert.Equal(2020, result["ProductionYear"]);
Assert.Equal(12, result["ChildCount"]);
Assert.Equal("Greatest Hits", result["SortName"]);
Assert.NotNull(result["DateCreated"]);
Assert.NotNull(result["BasicSyncInfo"]);
}
[Fact]
@@ -220,9 +177,6 @@ public class JellyfinResponseBuilderTests
Assert.Equal("MusicArtist", result["Type"]);
Assert.Equal(true, result["IsFolder"]);
Assert.Equal(5, result["AlbumCount"]);
Assert.Equal("The Rockers", result["SortName"]);
Assert.Equal(1.0, result["PrimaryImageAspectRatio"]);
Assert.NotNull(result["BasicSyncInfo"]);
}
[Fact]
@@ -251,9 +205,6 @@ public class JellyfinResponseBuilderTests
Assert.Equal("DJ Cool", result["AlbumArtist"]);
Assert.Equal(50, result["ChildCount"]);
Assert.Equal(2023, result["ProductionYear"]);
Assert.Equal("Summer Vibes [S/P]", result["SortName"]);
Assert.NotNull(result["DateCreated"]);
Assert.NotNull(result["BasicSyncInfo"]);
}
[Fact]
@@ -1,224 +0,0 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinSearchInterleaveTests
{
[Fact]
public void InterleaveByScore_PrimaryOnly_PreservesOriginalOrder()
{
var controller = CreateController();
var primary = new List<Dictionary<string, object?>>
{
CreateItem("zzz filler"),
CreateItem("BTS Anthem")
};
var result = InvokeInterleaveByScore(controller, primary, [], "bts", 5.0);
Assert.Equal(["zzz filler", "BTS Anthem"], result.Select(GetName));
}
[Fact]
public void InterleaveByScore_SecondaryOnly_PreservesOriginalOrder()
{
var controller = CreateController();
var secondary = new List<Dictionary<string, object?>>
{
CreateItem("zzz filler"),
CreateItem("BTS Anthem")
};
var result = InvokeInterleaveByScore(controller, [], secondary, "bts", 5.0);
Assert.Equal(["zzz filler", "BTS Anthem"], result.Select(GetName));
}
[Fact]
public void InterleaveByScore_StrongerHeadMatch_LeadsWithoutReorderingSource()
{
var controller = CreateController();
var primary = new List<Dictionary<string, object?>>
{
CreateItem("luther remastered"),
CreateItem("zzz filler")
};
var secondary = new List<Dictionary<string, object?>>
{
CreateItem("luther"),
CreateItem("yyy filler")
};
var result = InvokeInterleaveByScore(controller, primary, secondary, "luther", 0.0);
Assert.Equal(["luther", "luther remastered", "zzz filler", "yyy filler"], result.Select(GetName));
}
[Fact]
public void InterleaveByScore_TiedScores_PreferPrimaryQueueHead()
{
var controller = CreateController();
var primary = new List<Dictionary<string, object?>>
{
CreateItem("bts", "p1"),
CreateItem("bts", "p2")
};
var secondary = new List<Dictionary<string, object?>>
{
CreateItem("bts", "s1"),
CreateItem("bts", "s2")
};
var result = InvokeInterleaveByScore(controller, primary, secondary, "bts", 0.0);
Assert.Equal(["p1", "p2", "s1", "s2"], result.Select(GetId));
}
[Fact]
public void InterleaveByScore_StrongerLaterPrimaryHead_DoesNotBypassCurrentQueueHead()
{
var controller = CreateController();
var primary = new List<Dictionary<string, object?>>
{
CreateItem("zzz filler", "p1"),
CreateItem("bts local later", "p2")
};
var secondary = new List<Dictionary<string, object?>>
{
CreateItem("bts", "s1"),
CreateItem("bts live", "s2")
};
var result = InvokeInterleaveByScore(controller, primary, secondary, "bts", 0.0);
Assert.Equal(["s1", "s2", "p1", "p2"], result.Select(GetId));
}
[Fact]
public void InterleaveByScore_JellyfinBoost_CanWinCloseHeadToHead()
{
var controller = CreateController();
var primary = new List<Dictionary<string, object?>>
{
CreateItem("luther remastered", "p1")
};
var secondary = new List<Dictionary<string, object?>>
{
CreateItem("luther", "s1")
};
var result = InvokeInterleaveByScore(controller, primary, secondary, "luther", 5.0);
Assert.Equal(["p1", "s1"], result.Select(GetId));
}
[Fact]
public void CalculateItemRelevanceScore_SongUsesArtistContext()
{
var controller = CreateController();
var withArtist = CreateTypedItem("Audio", "cardigan", "song-with-artist");
withArtist["Artists"] = new[] { "Taylor Swift" };
var withoutArtist = CreateTypedItem("Audio", "cardigan", "song-without-artist");
var withArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withArtist);
var withoutArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withoutArtist);
Assert.True(withArtistScore > withoutArtistScore);
}
[Fact]
public void CalculateItemRelevanceScore_AlbumUsesArtistContext()
{
var controller = CreateController();
var withArtist = CreateTypedItem("MusicAlbum", "folklore", "album-with-artist");
withArtist["AlbumArtist"] = "Taylor Swift";
var withoutArtist = CreateTypedItem("MusicAlbum", "folklore", "album-without-artist");
var withArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withArtist);
var withoutArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withoutArtist);
Assert.True(withArtistScore > withoutArtistScore);
}
[Fact]
public void CalculateItemRelevanceScore_ArtistIgnoresNonNameMetadata()
{
var controller = CreateController();
var plainArtist = CreateTypedItem("MusicArtist", "Taylor Swift", "artist-plain");
var noisyArtist = CreateTypedItem("MusicArtist", "Taylor Swift", "artist-noisy");
noisyArtist["AlbumArtist"] = "Completely Different";
noisyArtist["Artists"] = new[] { "Someone Else" };
var plainScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", plainArtist);
var noisyScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", noisyArtist);
Assert.Equal(plainScore, noisyScore);
}
private static JellyfinController CreateController()
{
return (JellyfinController)RuntimeHelpers.GetUninitializedObject(typeof(JellyfinController));
}
private static List<Dictionary<string, object?>> InvokeInterleaveByScore(
JellyfinController controller,
List<Dictionary<string, object?>> primary,
List<Dictionary<string, object?>> secondary,
string query,
double primaryBoost)
{
var method = typeof(JellyfinController).GetMethod(
"InterleaveByScore",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
return (List<Dictionary<string, object?>>)method!.Invoke(
controller,
[primary, secondary, query, primaryBoost])!;
}
private static double InvokeCalculateItemRelevanceScore(
JellyfinController controller,
string query,
Dictionary<string, object?> item)
{
var method = typeof(JellyfinController).GetMethod(
"CalculateItemRelevanceScore",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
return (double)method!.Invoke(controller, [query, item])!;
}
private static Dictionary<string, object?> CreateItem(string name, string? id = null)
{
return new Dictionary<string, object?>
{
["Name"] = name,
["Id"] = id ?? name
};
}
private static Dictionary<string, object?> CreateTypedItem(string type, string name, string id)
{
var item = CreateItem(name, id);
item["Type"] = type;
return item;
}
private static string GetName(Dictionary<string, object?> item)
{
return item["Name"]?.ToString() ?? string.Empty;
}
private static string GetId(Dictionary<string, object?> item)
{
return item["Id"]?.ToString() ?? string.Empty;
}
}
@@ -1,38 +0,0 @@
using System.Reflection;
using allstarr.Controllers;
namespace allstarr.Tests;
public class JellyfinSearchResponseSerializationTests
{
[Fact]
public void SerializeSearchResponseJson_PreservesPascalCaseShape()
{
var payload = new
{
Items = new[]
{
new Dictionary<string, object?>
{
["Name"] = "BTS",
["Type"] = "MusicAlbum"
}
},
TotalRecordCount = 1,
StartIndex = 0
};
var method = typeof(JellyfinController).GetMethod(
"SerializeSearchResponseJson",
BindingFlags.NonPublic | BindingFlags.Static);
Assert.NotNull(method);
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);
}
}
@@ -1,226 +0,0 @@
using System.Collections.Concurrent;
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Common;
using allstarr.Services.Jellyfin;
namespace allstarr.Tests;
public class JellyfinSessionManagerTests
{
[Fact]
public async Task MarkSessionPotentiallyEnded_DoesNotAutoRemoveSession()
{
var handler = new DelegateHttpMessageHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)));
var settings = new JellyfinSettings
{
Url = "http://127.0.0.1:1",
ApiKey = "server-api-key",
ClientName = "Allstarr",
DeviceName = "Allstarr",
DeviceId = "allstarr",
ClientVersion = "1.0"
};
var proxyService = CreateProxyService(handler, settings);
using var manager = new JellyfinSessionManager(
proxyService,
Options.Create(settings),
NullLogger<JellyfinSessionManager>.Instance);
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
};
var ensured = await manager.EnsureSessionAsync("dev-123", "Feishin", "Desktop", "1.0", headers);
Assert.True(ensured);
manager.MarkSessionPotentiallyEnded("dev-123", TimeSpan.FromMilliseconds(25));
await Task.Delay(100);
Assert.True(manager.HasSession("dev-123"));
}
[Fact]
public async Task RemoveSessionAsync_ReportsPlaybackStopButDoesNotLogoutUserSession()
{
var requestedPaths = new ConcurrentBag<string>();
var handler = new DelegateHttpMessageHandler((request, _) =>
{
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
});
var settings = new JellyfinSettings
{
Url = "http://127.0.0.1:1",
ApiKey = "server-api-key",
ClientName = "Allstarr",
DeviceName = "Allstarr",
DeviceId = "allstarr",
ClientVersion = "1.0"
};
var proxyService = CreateProxyService(handler, settings);
using var manager = new JellyfinSessionManager(
proxyService,
Options.Create(settings),
NullLogger<JellyfinSessionManager>.Instance);
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
};
var ensured = await manager.EnsureSessionAsync("dev-123", "Feishin", "Desktop", "1.0", headers);
Assert.True(ensured);
manager.UpdatePlayingItem("dev-123", "item-123", 42);
await manager.RemoveSessionAsync("dev-123");
Assert.Contains("/Sessions/Capabilities/Full", requestedPaths);
Assert.Contains("/Sessions/Playing/Stopped", requestedPaths);
Assert.DoesNotContain("/Sessions/Logout", requestedPaths);
}
[Fact]
public async Task GetActivePlaybackStates_ReturnsTrackedPlayingItems()
{
var handler = new DelegateHttpMessageHandler((_, _) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)));
var settings = new JellyfinSettings
{
Url = "http://127.0.0.1:1",
ApiKey = "server-api-key",
ClientName = "Allstarr",
DeviceName = "Allstarr",
DeviceId = "allstarr",
ClientVersion = "1.0"
};
var proxyService = CreateProxyService(handler, settings);
using var manager = new JellyfinSessionManager(
proxyService,
Options.Create(settings),
NullLogger<JellyfinSessionManager>.Instance);
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
};
var ensured = await manager.EnsureSessionAsync("dev-123", "Feishin", "Desktop", "1.0", headers);
Assert.True(ensured);
manager.UpdatePlayingItem("dev-123", "ext-squidwtf-song-35734823", 45 * TimeSpan.TicksPerSecond);
var states = manager.GetActivePlaybackStates(TimeSpan.FromMinutes(1));
var state = Assert.Single(states);
Assert.Equal("dev-123", state.DeviceId);
Assert.Equal("ext-squidwtf-song-35734823", state.ItemId);
Assert.Equal(45 * TimeSpan.TicksPerSecond, state.PositionTicks);
}
[Fact]
public async Task EnsureSessionAsync_WithProxiedWebSocket_DoesNotPostSyntheticCapabilities()
{
var requestedPaths = new ConcurrentBag<string>();
var handler = new DelegateHttpMessageHandler((request, _) =>
{
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
});
var settings = new JellyfinSettings
{
Url = "http://127.0.0.1:1",
ApiKey = "server-api-key",
ClientName = "Allstarr",
DeviceName = "Allstarr",
DeviceId = "allstarr",
ClientVersion = "1.0"
};
var proxyService = CreateProxyService(handler, settings);
using var manager = new JellyfinSessionManager(
proxyService,
Options.Create(settings),
NullLogger<JellyfinSessionManager>.Instance);
var headers = new HeaderDictionary
{
["X-Emby-Authorization"] =
"MediaBrowser Client=\"Finamp\", Device=\"Android Auto\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
};
await manager.RegisterProxiedWebSocketAsync("dev-123");
var ensured = await manager.EnsureSessionAsync("dev-123", "Finamp", "Android Auto", "1.0", headers);
Assert.True(ensured);
Assert.DoesNotContain("/Sessions/Capabilities/Full", requestedPaths);
}
private static JellyfinProxyService CreateProxyService(HttpMessageHandler handler, JellyfinSettings settings)
{
var httpClientFactory = new TestHttpClientFactory(handler);
var httpContextAccessor = new HttpContextAccessor
{
HttpContext = new DefaultHttpContext()
};
var cache = new RedisCacheService(
Options.Create(new RedisSettings { Enabled = false }),
NullLogger<RedisCacheService>.Instance);
return new JellyfinProxyService(
httpClientFactory,
Options.Create(settings),
httpContextAccessor,
NullLogger<JellyfinProxyService>.Instance,
cache);
}
private sealed class TestHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;
public TestHttpClientFactory(HttpMessageHandler handler)
{
_client = new HttpClient(handler, disposeHandler: false);
}
public HttpClient CreateClient(string name)
{
return _client;
}
}
private sealed class DelegateHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;
public DelegateHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return _handler(request, cancellationToken);
}
}
}
-64
View File
@@ -1,64 +0,0 @@
using allstarr.Models.Scrobbling;
using Xunit;
namespace allstarr.Tests;
public class PlaybackSessionTests
{
[Fact]
public void ShouldScrobble_ExternalTrackStartedFromBeginning_ScrobblesWhenThresholdMet()
{
var session = CreateSession(isExternal: true, startPositionSeconds: 0, durationSeconds: 300, playedSeconds: 240);
Assert.True(session.ShouldScrobble());
}
[Fact]
public void ShouldScrobble_ExternalTrackResumedMidTrack_DoesNotScrobble()
{
var session = CreateSession(isExternal: true, startPositionSeconds: 90, durationSeconds: 300, playedSeconds: 240);
Assert.False(session.ShouldScrobble());
}
[Fact]
public void ShouldScrobble_ExternalTrackAtToleranceBoundary_Scrobbles()
{
var session = CreateSession(isExternal: true, startPositionSeconds: 5, durationSeconds: 240, playedSeconds: 120);
Assert.True(session.ShouldScrobble());
}
[Fact]
public void ShouldScrobble_LocalTrackIgnoresStartPosition_ScrobblesWhenThresholdMet()
{
var session = CreateSession(isExternal: false, startPositionSeconds: 120, durationSeconds: 300, playedSeconds: 150);
Assert.True(session.ShouldScrobble());
}
private static PlaybackSession CreateSession(
bool isExternal,
int startPositionSeconds,
int durationSeconds,
int playedSeconds)
{
return new PlaybackSession
{
SessionId = "session-1",
DeviceId = "device-1",
StartTime = DateTime.UtcNow,
LastActivity = DateTime.UtcNow,
LastPositionSeconds = playedSeconds,
Track = new ScrobbleTrack
{
Title = "Track",
Artist = "Artist",
DurationSeconds = durationSeconds,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
IsExternal = isExternal,
StartPositionSeconds = startPositionSeconds
}
};
}
}
@@ -1,88 +0,0 @@
using allstarr.Services.Common;
using Microsoft.Extensions.Configuration;
namespace allstarr.Tests;
public sealed class RuntimeEnvConfigurationTests : IDisposable
{
private readonly string _envFilePath = Path.Combine(
Path.GetTempPath(),
$"allstarr-runtime-{Guid.NewGuid():N}.env");
[Fact]
public void MapEnvVarToConfiguration_MapsFlatKeyToNestedConfigKey()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS", "7")
.ToList();
var mapping = Assert.Single(mappings);
Assert.Equal("SpotifyImport:MatchingIntervalHours", mapping.Key);
Assert.Equal("7", mapping.Value);
}
[Fact]
public void MapEnvVarToConfiguration_MapsSharedBackendKeysToBothSections()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("MUSIC_SERVICE", "Qobuz")
.OrderBy(x => x.Key, StringComparer.Ordinal)
.ToList();
Assert.Equal(2, mappings.Count);
Assert.Equal("Jellyfin:MusicService", mappings[0].Key);
Assert.Equal("Qobuz", mappings[0].Value);
Assert.Equal("Subsonic:MusicService", mappings[1].Key);
Assert.Equal("Qobuz", mappings[1].Value);
}
[Fact]
public void MapEnvVarToConfiguration_IgnoresComposeOnlyMountKeys()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("DOWNLOAD_PATH", "./downloads")
.ToList();
Assert.Empty(mappings);
}
[Fact]
public void LoadDotEnvOverrides_StripsQuotesAndSupportsDoubleUnderscoreKeys()
{
File.WriteAllText(
_envFilePath,
"""
SPOTIFY_API_SESSION_COOKIE="secret-cookie"
Admin__EnableEnvExport=true
""");
var overrides = RuntimeEnvConfiguration.LoadDotEnvOverrides(_envFilePath);
Assert.Equal("secret-cookie", overrides["SpotifyApi:SessionCookie"]);
Assert.Equal("true", overrides["Admin:EnableEnvExport"]);
}
[Fact]
public void AddDotEnvOverrides_OverridesEarlierConfigurationValues()
{
File.WriteAllText(_envFilePath, "SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=7\n");
var configuration = new ConfigurationManager();
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["SpotifyImport:MatchingIntervalHours"] = "24"
});
RuntimeEnvConfiguration.AddDotEnvOverrides(configuration, _envFilePath);
Assert.Equal(7, configuration.GetValue<int>("SpotifyImport:MatchingIntervalHours"));
}
public void Dispose()
{
if (File.Exists(_envFilePath))
{
File.Delete(_envFilePath);
}
}
}
@@ -1,54 +0,0 @@
using allstarr.Models.Scrobbling;
using allstarr.Models.Settings;
using allstarr.Services.Scrobbling;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
namespace allstarr.Tests;
public class ScrobblingOrchestratorTests
{
[Fact]
public async Task OnPlaybackStartAsync_DuplicateStartForSameTrack_SendsNowPlayingOnce()
{
var service = new Mock<IScrobblingService>();
service.SetupGet(s => s.IsEnabled).Returns(true);
service.SetupGet(s => s.ServiceName).Returns("MockService");
service.Setup(s => s.UpdateNowPlayingAsync(It.IsAny<ScrobbleTrack>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(ScrobbleResult.CreateSuccess());
var orchestrator = CreateOrchestrator(service.Object);
var track = CreateTrack();
await orchestrator.OnPlaybackStartAsync("device-1", track);
await orchestrator.OnPlaybackStartAsync("device-1", track);
service.Verify(
s => s.UpdateNowPlayingAsync(It.IsAny<ScrobbleTrack>(), It.IsAny<CancellationToken>()),
Times.Once);
}
private static ScrobblingOrchestrator CreateOrchestrator(IScrobblingService service)
{
var settings = Options.Create(new ScrobblingSettings
{
Enabled = true
});
var logger = Mock.Of<ILogger<ScrobblingOrchestrator>>();
return new ScrobblingOrchestrator(new[] { service }, settings, logger);
}
private static ScrobbleTrack CreateTrack()
{
return new ScrobbleTrack
{
Title = "Sad Girl Summer",
Artist = "Maisie Peters",
DurationSeconds = 180,
IsExternal = true,
StartPositionSeconds = 0
};
}
}
-25
View File
@@ -157,31 +157,6 @@ 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,115 +0,0 @@
using allstarr.Models.Domain;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using System.Text.Json;
namespace allstarr.Tests;
public class SpotifyPlaylistCountHelperTests
{
[Fact]
public void ComputeServedItemCount_UsesExactCachedCount_WhenAvailable()
{
var matchedTracks = new List<MatchedTrack>
{
new() { MatchedSong = new Song { IsLocal = false } },
new() { MatchedSong = new Song { IsLocal = false } }
};
var count = SpotifyPlaylistCountHelper.ComputeServedItemCount(50, 9, matchedTracks);
Assert.Equal(50, count);
}
[Fact]
public void ComputeServedItemCount_FallsBackToLocalPlusExternalMatched()
{
var matchedTracks = new List<MatchedTrack>
{
new() { MatchedSong = new Song { IsLocal = true } },
new() { MatchedSong = new Song { IsLocal = false } },
new() { MatchedSong = new Song { IsLocal = false } }
};
var count = SpotifyPlaylistCountHelper.ComputeServedItemCount(null, 9, matchedTracks);
Assert.Equal(11, count);
}
[Fact]
public void CountExternalMatchedTracks_IgnoresLocalMatches()
{
var matchedTracks = new List<MatchedTrack>
{
new() { MatchedSong = new Song { IsLocal = true } },
new() { MatchedSong = new Song { IsLocal = false } },
new() { MatchedSong = new Song { IsLocal = false } }
};
Assert.Equal(2, SpotifyPlaylistCountHelper.CountExternalMatchedTracks(matchedTracks));
}
[Fact]
public void SumExternalMatchedRunTimeTicks_IgnoresLocalMatches()
{
var matchedTracks = new List<MatchedTrack>
{
new() { MatchedSong = new Song { IsLocal = true, Duration = 100 } },
new() { MatchedSong = new Song { IsLocal = false, Duration = 180 } },
new() { MatchedSong = new Song { IsLocal = false, Duration = 240 } }
};
var runTimeTicks = SpotifyPlaylistCountHelper.SumExternalMatchedRunTimeTicks(matchedTracks);
Assert.Equal((180L + 240L) * TimeSpan.TicksPerSecond, runTimeTicks);
}
[Fact]
public void SumCachedPlaylistRunTimeTicks_HandlesJsonElementsFromCache()
{
var cachedPlaylistItems = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>("""
[
{ "RunTimeTicks": 1800000000 },
{ "RunTimeTicks": 2400000000 }
]
""")!;
var runTimeTicks = SpotifyPlaylistCountHelper.SumCachedPlaylistRunTimeTicks(cachedPlaylistItems);
Assert.Equal(4200000000L, runTimeTicks);
}
[Fact]
public void ComputeServedRunTimeTicks_UsesExactCachedRuntime_WhenAvailable()
{
var matchedTracks = new List<MatchedTrack>
{
new() { MatchedSong = new Song { IsLocal = false, Duration = 180 } }
};
var runTimeTicks = SpotifyPlaylistCountHelper.ComputeServedRunTimeTicks(
5000000000L,
900000000L,
matchedTracks);
Assert.Equal(5000000000L, runTimeTicks);
}
[Fact]
public void ComputeServedRunTimeTicks_FallsBackToLocalPlusExternalMatched()
{
var matchedTracks = new List<MatchedTrack>
{
new() { MatchedSong = new Song { IsLocal = true, Duration = 100 } },
new() { MatchedSong = new Song { IsLocal = false, Duration = 180 } },
new() { MatchedSong = new Song { IsLocal = false, Duration = 240 } }
};
var runTimeTicks = SpotifyPlaylistCountHelper.ComputeServedRunTimeTicks(
null,
900000000L,
matchedTracks);
Assert.Equal(5100000000L, runTimeTicks);
}
}
@@ -1,181 +0,0 @@
using System.Net;
using System.Reflection;
using System.Text;
using allstarr.Models.Settings;
using allstarr.Services;
using allstarr.Services.Common;
using allstarr.Services.Local;
using allstarr.Services.SquidWTF;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
namespace allstarr.Tests;
public class SquidWTFDownloadServiceTests : IDisposable
{
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock = new();
private readonly Mock<ILocalLibraryService> _localLibraryServiceMock = new();
private readonly Mock<IMusicMetadataService> _metadataServiceMock = new();
private readonly Mock<IServiceProvider> _serviceProviderMock = new();
private readonly Mock<ILogger<SquidWTFDownloadService>> _loggerMock = new();
private readonly Mock<ILogger<OdesliService>> _odesliLoggerMock = new();
private readonly Mock<ILogger<RedisCacheService>> _redisLoggerMock = new();
private readonly string _testDownloadPath;
private readonly List<string> _apiUrls =
[
"http://127.0.0.1:18081",
"http://127.0.0.1:18082"
];
public SquidWTFDownloadServiceTests()
{
_testDownloadPath = Path.Combine(Path.GetTempPath(), "allstarr-squidwtf-download-tests-" + Guid.NewGuid());
Directory.CreateDirectory(_testDownloadPath);
_serviceProviderMock
.Setup(sp => sp.GetService(typeof(allstarr.Services.Subsonic.PlaylistSyncService)))
.Returns((object?)null);
}
public void Dispose()
{
if (Directory.Exists(_testDownloadPath))
{
Directory.Delete(_testDownloadPath, true);
}
}
[Fact]
public void BuildQualityFallbackOrder_MapsConfiguredQualityToDescendingFallbacks()
{
var order = InvokePrivateStaticMethod<IReadOnlyList<string>>(
typeof(SquidWTFDownloadService),
"BuildQualityFallbackOrder",
"HI_RES");
Assert.Equal(["HI_RES_LOSSLESS", "LOSSLESS", "HIGH", "LOW"], order);
}
private SquidWTFDownloadService CreateService(HttpMessageHandler handler, string quality)
{
var httpClient = new HttpClient(handler);
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Library:DownloadPath"] = _testDownloadPath
})
.Build();
var subsonicSettings = Options.Create(new SubsonicSettings
{
DownloadMode = DownloadMode.Track,
StorageMode = StorageMode.Cache
});
var squidwtfSettings = Options.Create(new SquidWTFSettings
{
Quality = quality
});
var cache = new RedisCacheService(
Options.Create(new RedisSettings { Enabled = false }),
_redisLoggerMock.Object);
var odesliService = new OdesliService(_httpClientFactoryMock.Object, _odesliLoggerMock.Object, cache);
return new SquidWTFDownloadService(
_httpClientFactoryMock.Object,
configuration,
_localLibraryServiceMock.Object,
_metadataServiceMock.Object,
subsonicSettings,
squidwtfSettings,
_serviceProviderMock.Object,
_loggerMock.Object,
odesliService,
_apiUrls);
}
private static string CreateTrackResponseJson(string audioQuality, string mimeType, string downloadUrl)
{
var manifestJson = $$"""
{
"mimeType": "{{mimeType}}",
"codecs": "aac",
"encryptionType": "NONE",
"urls": ["{{downloadUrl}}"]
}
""";
var manifestBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson));
return $$"""
{
"version": "2.4",
"data": {
"audioQuality": "{{audioQuality}}",
"manifest": "{{manifestBase64}}"
}
}
""";
}
private static async Task<object> InvokePrivateAsync(object target, string methodName, params object?[] parameters)
{
var method = target.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
var task = method!.Invoke(target, parameters) as Task;
Assert.NotNull(task);
await task!;
var resultProperty = task.GetType().GetProperty("Result");
Assert.NotNull(resultProperty);
var result = resultProperty!.GetValue(task);
Assert.NotNull(result);
return result!;
}
private static T InvokePrivateStaticMethod<T>(Type targetType, string methodName, params object?[] parameters)
{
var method = targetType.GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
var result = method!.Invoke(null, parameters);
Assert.NotNull(result);
return (T)result!;
}
private static T GetProperty<T>(object target, string propertyName)
{
var property = target.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
Assert.NotNull(property);
var value = property!.GetValue(target);
Assert.NotNull(value);
return (T)value!;
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(_handler(request));
}
}
}
@@ -7,11 +7,8 @@ using allstarr.Services.Common;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using System.Collections.Generic;
using System.Net;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace allstarr.Tests;
@@ -299,65 +296,6 @@ public class SquidWTFMetadataServiceTests
Assert.NotNull(result);
}
[Fact]
public async Task SearchAllAsync_WithZeroLimits_SkipsUnusedBuckets()
{
var requestKinds = new List<string>();
var handler = new StubHttpMessageHandler(request =>
{
var trackQuery = GetQueryParameter(request.RequestUri!, "s");
var albumQuery = GetQueryParameter(request.RequestUri!, "al");
var artistQuery = GetQueryParameter(request.RequestUri!, "a");
if (!string.IsNullOrWhiteSpace(trackQuery))
{
requestKinds.Add("song");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(1, "Song", "USRC12345678")))
};
}
if (!string.IsNullOrWhiteSpace(albumQuery))
{
requestKinds.Add("album");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateAlbumSearchResponse())
};
}
if (!string.IsNullOrWhiteSpace(artistQuery))
{
requestKinds.Add("artist");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateArtistSearchResponse())
};
}
throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}");
});
var httpClient = new HttpClient(handler);
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var service = new SquidWTFMetadataService(
_mockHttpClientFactory.Object,
_subsonicSettings,
_squidwtfSettings,
_mockLogger.Object,
_mockCache.Object,
new List<string> { "https://test1.example.com" });
var result = await service.SearchAllAsync("OK Computer", 0, 5, 0);
Assert.Empty(result.Songs);
Assert.Single(result.Albums);
Assert.Empty(result.Artists);
Assert.Equal(new[] { "album" }, requestKinds);
}
[Fact]
public void ExplicitFilter_RespectsSettings()
{
@@ -405,440 +343,6 @@ public class SquidWTFMetadataServiceTests
Assert.NotNull(service);
}
[Fact]
public async Task GetTrackRecommendationsAsync_FallsBackWhenFirstEndpointReturnsEmpty()
{
var handler = new StubHttpMessageHandler(request =>
{
var port = request.RequestUri?.Port;
if (port == 5011)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"version": "2.4",
"data": {
"limit": 20,
"offset": 0,
"totalNumberOfItems": 0,
"items": []
}
}
""")
};
}
if (port == 5012)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"version": "2.4",
"data": {
"limit": 20,
"offset": 0,
"totalNumberOfItems": 1,
"items": [
{
"track": {
"id": 371921532,
"title": "Take It Slow",
"duration": 139,
"trackNumber": 1,
"volumeNumber": 1,
"explicit": false,
"artist": { "id": 10330497, "name": "Isaac Dunbar" },
"artists": [
{ "id": 10330497, "name": "Isaac Dunbar" }
],
"album": {
"id": 371921525,
"title": "Take It Slow",
"cover": "aeb70f15-78ef-4230-929d-2d62c70ac00c"
}
}
}
]
}
}
""")
};
}
throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}");
});
var httpClient = new HttpClient(handler);
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var service = new SquidWTFMetadataService(
_mockHttpClientFactory.Object,
_subsonicSettings,
_squidwtfSettings,
_mockLogger.Object,
_mockCache.Object,
new List<string>
{
"http://127.0.0.1:5011",
"http://127.0.0.1:5012"
});
var result = await service.GetTrackRecommendationsAsync("227242909", 20);
Assert.Single(result);
Assert.Equal("371921532", result[0].ExternalId);
Assert.Equal("Take It Slow", result[0].Title);
}
[Fact]
public async Task GetSongAsync_FallsBackWhenFirstEndpointReturnsErrorPayload()
{
var handler = new StubHttpMessageHandler(request =>
{
var port = request.RequestUri?.Port;
if (port == 5021)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"detail": "Upstream API error"
}
""")
};
}
if (port == 5022)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"version": "2.4",
"data": {
"id": 227242909,
"title": "Monica Lewinsky",
"duration": 132,
"trackNumber": 1,
"volumeNumber": 1,
"explicit": true,
"artist": { "id": 8420542, "name": "UPSAHL" },
"artists": [
{ "id": 8420542, "name": "UPSAHL" }
],
"album": {
"id": 227242908,
"title": "Monica Lewinsky",
"cover": "32522342-3903-42ab-aaea-a6f4f46ca0cc"
}
}
}
""")
};
}
throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}");
});
var httpClient = new HttpClient(handler);
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var service = new SquidWTFMetadataService(
_mockHttpClientFactory.Object,
_subsonicSettings,
_squidwtfSettings,
_mockLogger.Object,
_mockCache.Object,
new List<string>
{
"http://127.0.0.1:5021",
"http://127.0.0.1:5022"
});
var song = await service.GetSongAsync("squidwtf", "227242909");
Assert.NotNull(song);
Assert.Equal("227242909", song!.ExternalId);
Assert.Equal("Monica Lewinsky", song.Title);
Assert.Equal(1, song.ExplicitContentLyrics);
}
[Fact]
public async Task FindSongByIsrcAsync_UsesExactIsrcEndpoint()
{
var requests = new List<string>();
var handler = new StubHttpMessageHandler(request =>
{
requests.Add(request.RequestUri!.PathAndQuery);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(
144371283,
"Don't Look Back In Anger",
"GBBQY0002027",
artistName: "Oasis",
artistId: 109,
albumTitle: "Familiar To Millions (Live)",
albumId: 144371273)))
};
});
var httpClient = new HttpClient(handler);
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var service = new SquidWTFMetadataService(
_mockHttpClientFactory.Object,
_subsonicSettings,
_squidwtfSettings,
_mockLogger.Object,
_mockCache.Object,
new List<string> { "http://127.0.0.1:5031" });
var song = await service.FindSongByIsrcAsync("GBBQY0002027");
Assert.NotNull(song);
Assert.Equal("GBBQY0002027", song!.Isrc);
Assert.Equal("144371283", song.ExternalId);
Assert.Contains("/search/?i=GBBQY0002027&limit=1&offset=0", requests);
}
[Fact]
public async Task FindSongByIsrcAsync_FallsBackToTextSearchWhenExactEndpointPayloadIsUnexpected()
{
var requests = new List<string>();
var handler = new StubHttpMessageHandler(request =>
{
requests.Add(request.RequestUri!.PathAndQuery);
if (!string.IsNullOrWhiteSpace(GetQueryParameter(request.RequestUri, "i")))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{ "version": "2.6", "unexpected": {} }""")
};
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(
427520487,
"Azizam",
"GBAHS2500081",
artistName: "Ed Sheeran",
artistId: 3995478,
albumTitle: "Azizam",
albumId: 427520486)))
};
});
var httpClient = new HttpClient(handler);
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var service = new SquidWTFMetadataService(
_mockHttpClientFactory.Object,
_subsonicSettings,
_squidwtfSettings,
_mockLogger.Object,
_mockCache.Object,
new List<string> { "http://127.0.0.1:5032" });
var song = await service.FindSongByIsrcAsync("GBAHS2500081");
Assert.NotNull(song);
Assert.Equal("GBAHS2500081", song!.Isrc);
Assert.Contains("/search/?i=GBAHS2500081&limit=1&offset=0", requests);
Assert.Contains("/search/?s=isrc%3AGBAHS2500081&limit=1&offset=0", requests);
}
[Fact]
public async Task SearchEndpoints_IncludeRequestedRemoteLimitAndOffset()
{
var requests = new List<string>();
var handler = new StubHttpMessageHandler(request =>
{
requests.Add(request.RequestUri!.PathAndQuery);
var trackQuery = GetQueryParameter(request.RequestUri, "s");
var albumQuery = GetQueryParameter(request.RequestUri, "al");
var artistQuery = GetQueryParameter(request.RequestUri, "a");
var playlistQuery = GetQueryParameter(request.RequestUri, "p");
if (!string.IsNullOrWhiteSpace(trackQuery))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(1, "Song", "USRC12345678")))
};
}
if (!string.IsNullOrWhiteSpace(albumQuery))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateAlbumSearchResponse())
};
}
if (!string.IsNullOrWhiteSpace(artistQuery))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateArtistSearchResponse())
};
}
if (!string.IsNullOrWhiteSpace(playlistQuery))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreatePlaylistSearchResponse())
};
}
throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}");
});
var httpClient = new HttpClient(handler);
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var service = new SquidWTFMetadataService(
_mockHttpClientFactory.Object,
_subsonicSettings,
_squidwtfSettings,
_mockLogger.Object,
_mockCache.Object,
new List<string> { "http://127.0.0.1:5033" });
await service.SearchSongsAsync("Take Five", 7);
await service.SearchAlbumsAsync("Time Out", 8);
await service.SearchArtistsAsync("Dave Brubeck", 9);
await service.SearchPlaylistsAsync("Jazz Essentials", 10);
Assert.Contains("/search/?s=Take%20Five&limit=7&offset=0", requests);
Assert.Contains("/search/?al=Time%20Out&limit=8&offset=0", requests);
Assert.Contains("/search/?a=Dave%20Brubeck&limit=9&offset=0", requests);
Assert.Contains("/search/?p=Jazz%20Essentials&limit=10&offset=0", requests);
}
[Fact]
public async Task GetArtistAsync_UsesLightweightArtistEndpointAndCoverFallback()
{
var requests = new List<string>();
var handler = new StubHttpMessageHandler(request =>
{
requests.Add(request.RequestUri!.PathAndQuery);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"version": "2.6",
"artist": {
"id": 25022,
"name": "Kanye West",
"picture": null
},
"cover": {
"750": "https://example.com/kanye-750.jpg"
}
}
""")
};
});
var httpClient = new HttpClient(handler);
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var service = new SquidWTFMetadataService(
_mockHttpClientFactory.Object,
_subsonicSettings,
_squidwtfSettings,
_mockLogger.Object,
_mockCache.Object,
new List<string> { "http://127.0.0.1:5034" });
var artist = await service.GetArtistAsync("squidwtf", "25022");
Assert.Contains("/artist/?id=25022", requests);
Assert.NotNull(artist);
Assert.Equal("Kanye West", artist!.Name);
Assert.Equal("https://example.com/kanye-750.jpg", artist.ImageUrl);
Assert.Null(artist.AlbumCount);
}
[Fact]
public async Task GetAlbumAsync_PaginatesBeyondFirstPage()
{
var requests = new List<string>();
var handler = new StubHttpMessageHandler(request =>
{
requests.Add(request.RequestUri!.PathAndQuery);
var offset = int.Parse(GetQueryParameter(request.RequestUri, "offset") ?? "0");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateAlbumPageResponse(offset, offset == 0 ? 500 : 1, totalTracks: 501))
};
});
var httpClient = new HttpClient(handler);
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var service = new SquidWTFMetadataService(
_mockHttpClientFactory.Object,
_subsonicSettings,
_squidwtfSettings,
_mockLogger.Object,
_mockCache.Object,
new List<string> { "http://127.0.0.1:5035" });
var album = await service.GetAlbumAsync("squidwtf", "58990510");
Assert.Contains("/album/?id=58990510&limit=500&offset=0", requests);
Assert.Contains("/album/?id=58990510&limit=500&offset=500", requests);
Assert.NotNull(album);
Assert.Equal(501, album!.Songs.Count);
}
[Fact]
public async Task GetPlaylistTracksAsync_PaginatesBeyondFirstPage()
{
var requests = new List<string>();
var handler = new StubHttpMessageHandler(request =>
{
requests.Add(request.RequestUri!.PathAndQuery);
var offset = int.Parse(GetQueryParameter(request.RequestUri, "offset") ?? "0");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreatePlaylistPageResponse(offset, offset == 0 ? 500 : 1, totalTracks: 501))
};
});
var httpClient = new HttpClient(handler);
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
var service = new SquidWTFMetadataService(
_mockHttpClientFactory.Object,
_subsonicSettings,
_squidwtfSettings,
_mockLogger.Object,
_mockCache.Object,
new List<string> { "http://127.0.0.1:5036" });
var songs = await service.GetPlaylistTracksAsync("squidwtf", "playlist123");
Assert.Equal(501, songs.Count);
Assert.Equal("Big Playlist", songs[0].Album);
Assert.Equal("Big Playlist", songs[^1].Album);
Assert.Contains("/playlist/?id=playlist123&limit=500&offset=0", requests);
Assert.Contains("/playlist/?id=playlist123&limit=500&offset=500", requests);
}
[Fact]
public void BuildSearchQueryVariants_WithAmpersand_AddsAndVariant()
{
@@ -1057,255 +561,4 @@ public class SquidWTFMetadataServiceTests
Assert.NotNull(result);
return (T)result!;
}
private static string CreateTrackSearchResponse(object trackPayload)
{
return JsonSerializer.Serialize(new Dictionary<string, object?>
{
["version"] = "2.6",
["data"] = new Dictionary<string, object?>
{
["limit"] = 25,
["offset"] = 0,
["totalNumberOfItems"] = 1,
["items"] = new[] { trackPayload }
}
});
}
private static string CreateAlbumSearchResponse()
{
return JsonSerializer.Serialize(new Dictionary<string, object?>
{
["version"] = "2.6",
["data"] = new Dictionary<string, object?>
{
["albums"] = new Dictionary<string, object?>
{
["limit"] = 25,
["offset"] = 0,
["totalNumberOfItems"] = 1,
["items"] = new[]
{
new Dictionary<string, object?>
{
["id"] = 58990510,
["title"] = "OK Computer",
["numberOfTracks"] = 12,
["cover"] = "e77e4cc0-6cd0-4522-807d-88aeac488065",
["artist"] = new Dictionary<string, object?>
{
["id"] = 64518,
["name"] = "Radiohead"
}
}
}
}
}
});
}
private static string CreateArtistSearchResponse()
{
return JsonSerializer.Serialize(new Dictionary<string, object?>
{
["version"] = "2.6",
["data"] = new Dictionary<string, object?>
{
["artists"] = new Dictionary<string, object?>
{
["limit"] = 25,
["offset"] = 0,
["totalNumberOfItems"] = 1,
["items"] = new[]
{
new Dictionary<string, object?>
{
["id"] = 8812,
["name"] = "Coldplay",
["picture"] = "b4579672-5b91-4679-a27a-288f097a4da5"
}
}
}
}
});
}
private static string CreatePlaylistSearchResponse()
{
return JsonSerializer.Serialize(new Dictionary<string, object?>
{
["version"] = "2.6",
["data"] = new Dictionary<string, object?>
{
["playlists"] = new Dictionary<string, object?>
{
["limit"] = 25,
["offset"] = 0,
["totalNumberOfItems"] = 1,
["items"] = new[]
{
new Dictionary<string, object?>
{
["uuid"] = "playlist123",
["title"] = "Jazz Essentials",
["creator"] = new Dictionary<string, object?>
{
["id"] = 0
},
["numberOfTracks"] = 1,
["duration"] = 180,
["squareImage"] = "b15bb487-dd6e-45ff-9e50-ee5083f20669"
}
}
}
}
});
}
private static string CreateAlbumPageResponse(int offset, int count, int totalTracks)
{
var items = Enumerable.Range(offset + 1, count)
.Select(index => (object)new Dictionary<string, object?>
{
["item"] = CreateTrackPayload(
index,
$"Album Track {index}",
$"USRC{index:00000000}",
albumTitle: "Paginated Album",
albumId: 58990510)
})
.ToArray();
return JsonSerializer.Serialize(new Dictionary<string, object?>
{
["version"] = "2.6",
["data"] = new Dictionary<string, object?>
{
["id"] = 58990510,
["title"] = "Paginated Album",
["numberOfTracks"] = totalTracks,
["cover"] = "e77e4cc0-6cd0-4522-807d-88aeac488065",
["artist"] = new Dictionary<string, object?>
{
["id"] = 64518,
["name"] = "Radiohead"
},
["items"] = items
}
});
}
private static string CreatePlaylistPageResponse(int offset, int count, int totalTracks)
{
var items = Enumerable.Range(offset + 1, count)
.Select(index => (object)new Dictionary<string, object?>
{
["item"] = CreateTrackPayload(
index,
$"Playlist Track {index}",
$"GBARL{index:0000000}",
artistName: "Mark Ronson",
artistId: 8722,
albumTitle: "Uptown Special",
albumId: 39249709)
})
.ToArray();
return JsonSerializer.Serialize(new Dictionary<string, object?>
{
["version"] = "2.6",
["playlist"] = new Dictionary<string, object?>
{
["uuid"] = "playlist123",
["title"] = "Big Playlist",
["creator"] = new Dictionary<string, object?>
{
["id"] = 0
},
["numberOfTracks"] = totalTracks,
["duration"] = totalTracks * 180,
["squareImage"] = "b15bb487-dd6e-45ff-9e50-ee5083f20669"
},
["items"] = items
});
}
private static Dictionary<string, object?> CreateTrackPayload(
int id,
string title,
string isrc,
string artistName = "Artist",
int artistId = 1,
string albumTitle = "Album",
int albumId = 10)
{
return new Dictionary<string, object?>
{
["id"] = id,
["title"] = title,
["duration"] = 180,
["trackNumber"] = (id % 12) + 1,
["volumeNumber"] = 1,
["explicit"] = false,
["isrc"] = isrc,
["artist"] = new Dictionary<string, object?>
{
["id"] = artistId,
["name"] = artistName
},
["artists"] = new object[]
{
new Dictionary<string, object?>
{
["id"] = artistId,
["name"] = artistName
}
},
["album"] = new Dictionary<string, object?>
{
["id"] = albumId,
["title"] = albumTitle,
["cover"] = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
}
};
}
private static string? GetQueryParameter(Uri uri, string name)
{
var query = uri.Query.TrimStart('?');
if (string.IsNullOrWhiteSpace(query))
{
return null;
}
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var parts = pair.Split('=', 2);
var key = Uri.UnescapeDataString(parts[0]);
if (!key.Equals(name, StringComparison.Ordinal))
{
continue;
}
return parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
}
return null;
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(_handler(request));
}
}
}
+1 -1
View File
@@ -9,5 +9,5 @@ public static class AppVersion
/// <summary>
/// Current application version.
/// </summary>
public const string Version = "1.5.3";
public const string Version = "1.2.1";
}
+54 -103
View File
@@ -144,11 +144,7 @@ public class ConfigController : ControllerBase
redisEnabled = GetEnvBool(envVars, "REDIS_ENABLED", _configuration.GetValue<bool>("Redis:Enabled", false)),
debug = new
{
logAllRequests = GetEnvBool(envVars, "DEBUG_LOG_ALL_REQUESTS", _configuration.GetValue<bool>("Debug:LogAllRequests", false)),
redactSensitiveRequestValues = GetEnvBool(
envVars,
"DEBUG_REDACT_SENSITIVE_REQUEST_VALUES",
_configuration.GetValue<bool>("Debug:RedactSensitiveRequestValues", false))
logAllRequests = GetEnvBool(envVars, "DEBUG_LOG_ALL_REQUESTS", _configuration.GetValue<bool>("Debug:LogAllRequests", false))
},
admin = new
{
@@ -198,20 +194,17 @@ public class ConfigController : ControllerBase
{
arl = AdminHelperService.MaskValue(GetEnvString(envVars, "DEEZER_ARL", _deezerSettings.Arl ?? string.Empty), showLast: 8),
arlFallback = AdminHelperService.MaskValue(GetEnvString(envVars, "DEEZER_ARL_FALLBACK", _deezerSettings.ArlFallback ?? string.Empty), showLast: 8),
quality = GetEnvString(envVars, "DEEZER_QUALITY", _deezerSettings.Quality ?? "FLAC"),
minRequestIntervalMs = GetEnvInt(envVars, "DEEZER_MIN_REQUEST_INTERVAL_MS", _deezerSettings.MinRequestIntervalMs)
quality = GetEnvString(envVars, "DEEZER_QUALITY", _deezerSettings.Quality ?? "FLAC")
},
qobuz = new
{
userAuthToken = AdminHelperService.MaskValue(GetEnvString(envVars, "QOBUZ_USER_AUTH_TOKEN", _qobuzSettings.UserAuthToken ?? string.Empty), showLast: 8),
userId = GetEnvString(envVars, "QOBUZ_USER_ID", _qobuzSettings.UserId ?? string.Empty),
quality = GetEnvString(envVars, "QOBUZ_QUALITY", _qobuzSettings.Quality ?? "FLAC"),
minRequestIntervalMs = GetEnvInt(envVars, "QOBUZ_MIN_REQUEST_INTERVAL_MS", _qobuzSettings.MinRequestIntervalMs)
quality = GetEnvString(envVars, "QOBUZ_QUALITY", _qobuzSettings.Quality ?? "FLAC")
},
squidWtf = new
{
quality = GetEnvString(envVars, "SQUIDWTF_QUALITY", _squidWtfSettings.Quality ?? "LOSSLESS"),
minRequestIntervalMs = GetEnvInt(envVars, "SQUIDWTF_MIN_REQUEST_INTERVAL_MS", _squidWtfSettings.MinRequestIntervalMs)
quality = GetEnvString(envVars, "SQUIDWTF_QUALITY", _squidWtfSettings.Quality ?? "LOSSLESS")
},
musicBrainz = new
{
@@ -223,7 +216,7 @@ public class ConfigController : ControllerBase
},
cache = new
{
searchResultsMinutes = GetEnvInt(envVars, "CACHE_SEARCH_RESULTS_MINUTES", _configuration.GetValue<int>("Cache:SearchResultsMinutes", 1)),
searchResultsMinutes = GetEnvInt(envVars, "CACHE_SEARCH_RESULTS_MINUTES", _configuration.GetValue<int>("Cache:SearchResultsMinutes", 120)),
playlistImagesHours = GetEnvInt(envVars, "CACHE_PLAYLIST_IMAGES_HOURS", _configuration.GetValue<int>("Cache:PlaylistImagesHours", 168)),
spotifyPlaylistItemsHours = GetEnvInt(envVars, "CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS", _configuration.GetValue<int>("Cache:SpotifyPlaylistItemsHours", 168)),
spotifyMatchedTracksDays = GetEnvInt(envVars, "CACHE_SPOTIFY_MATCHED_TRACKS_DAYS", _configuration.GetValue<int>("Cache:SpotifyMatchedTracksDays", 30)),
@@ -231,8 +224,7 @@ public class ConfigController : ControllerBase
genreDays = GetEnvInt(envVars, "CACHE_GENRE_DAYS", _configuration.GetValue<int>("Cache:GenreDays", 30)),
metadataDays = GetEnvInt(envVars, "CACHE_METADATA_DAYS", _configuration.GetValue<int>("Cache:MetadataDays", 7)),
odesliLookupDays = GetEnvInt(envVars, "CACHE_ODESLI_LOOKUP_DAYS", _configuration.GetValue<int>("Cache:OdesliLookupDays", 60)),
proxyImagesDays = GetEnvInt(envVars, "CACHE_PROXY_IMAGES_DAYS", _configuration.GetValue<int>("Cache:ProxyImagesDays", 14)),
transcodeCacheMinutes = GetEnvInt(envVars, "CACHE_TRANSCODE_MINUTES", _configuration.GetValue<int>("Cache:TranscodeCacheMinutes", 60))
proxyImagesDays = GetEnvInt(envVars, "CACHE_PROXY_IMAGES_DAYS", _configuration.GetValue<int>("Cache:ProxyImagesDays", 14))
},
scrobbling = await GetScrobblingSettingsFromEnvAsync()
});
@@ -343,8 +335,6 @@ public class ConfigController : ControllerBase
return new
{
enabled = _scrobblingSettings.Enabled,
localTracksEnabled = _scrobblingSettings.LocalTracksEnabled,
syntheticLocalPlayedSignalEnabled = _scrobblingSettings.SyntheticLocalPlayedSignalEnabled,
lastFm = new
{
enabled = _scrobblingSettings.LastFm.Enabled,
@@ -382,12 +372,6 @@ public class ConfigController : ControllerBase
enabled = envVars.TryGetValue("SCROBBLING_ENABLED", out var scrobblingEnabled)
? scrobblingEnabled.Equals("true", StringComparison.OrdinalIgnoreCase)
: _scrobblingSettings.Enabled,
localTracksEnabled = envVars.TryGetValue("SCROBBLING_LOCAL_TRACKS_ENABLED", out var localTracksEnabled)
? localTracksEnabled.Equals("true", StringComparison.OrdinalIgnoreCase)
: _scrobblingSettings.LocalTracksEnabled,
syntheticLocalPlayedSignalEnabled = envVars.TryGetValue("SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED", out var syntheticPlayedSignalEnabled)
? syntheticPlayedSignalEnabled.Equals("true", StringComparison.OrdinalIgnoreCase)
: _scrobblingSettings.SyntheticLocalPlayedSignalEnabled,
lastFm = new
{
enabled = envVars.TryGetValue("SCROBBLING_LASTFM_ENABLED", out var lastFmEnabled)
@@ -427,8 +411,6 @@ public class ConfigController : ControllerBase
return new
{
enabled = _scrobblingSettings.Enabled,
localTracksEnabled = _scrobblingSettings.LocalTracksEnabled,
syntheticLocalPlayedSignalEnabled = _scrobblingSettings.SyntheticLocalPlayedSignalEnabled,
lastFm = new
{
enabled = _scrobblingSettings.LastFm.Enabled,
@@ -474,101 +456,70 @@ public class ConfigController : ControllerBase
_logger.LogWarning(".env file not found at {Path}, creating new file", _helperService.GetEnvFilePath());
}
var envFilePath = _helperService.GetEnvFilePath();
var envLines = new List<string>();
// Read current .env file or create new one
var envContent = new Dictionary<string, string>();
if (System.IO.File.Exists(envFilePath))
if (System.IO.File.Exists(_helperService.GetEnvFilePath()))
{
envLines = (await System.IO.File.ReadAllLinesAsync(envFilePath)).ToList();
}
else
{
// Fallback to reading .env.example if .env doesn't exist to preserve structure
var examplePath = Path.Combine(Directory.GetCurrentDirectory(), ".env.example");
if (!System.IO.File.Exists(examplePath))
var lines = await System.IO.File.ReadAllLinesAsync(_helperService.GetEnvFilePath());
foreach (var line in lines)
{
examplePath = Path.Combine(Directory.GetParent(Directory.GetCurrentDirectory())?.FullName ?? "", ".env.example");
}
if (System.IO.File.Exists(examplePath))
{
_logger.LogInformation("Creating new .env from .env.example to preserve formatting");
envLines = (await System.IO.File.ReadAllLinesAsync(examplePath)).ToList();
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
continue;
var eqIndex = line.IndexOf('=');
if (eqIndex > 0)
{
var key = line[..eqIndex].Trim();
var value = line[(eqIndex + 1)..].Trim();
// Remove surrounding quotes if present (for proper re-quoting)
if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2)
{
value = value[1..^1];
}
envContent[key] = value;
}
}
_logger.LogDebug("Loaded {Count} existing env vars from {Path}", envContent.Count, _helperService.GetEnvFilePath());
}
// Apply updates with validation
var appliedUpdates = new List<string>();
var updatesToProcess = new Dictionary<string, string>(request.Updates);
// Auto-set cookie date when Spotify session cookie is updated
if (updatesToProcess.TryGetValue("SPOTIFY_API_SESSION_COOKIE", out var cookieVal) && !string.IsNullOrEmpty(cookieVal))
{
updatesToProcess["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = DateTime.UtcNow.ToString("o");
_logger.LogInformation("Auto-setting SPOTIFY_API_SESSION_COOKIE_SET_DATE");
}
foreach (var (key, value) in updatesToProcess)
foreach (var (key, value) in request.Updates)
{
// Validate key format
if (!AdminHelperService.IsValidEnvKey(key))
{
_logger.LogWarning("Invalid env key rejected: {Key}", key);
return BadRequest(new { error = $"Invalid environment variable key: {key}" });
}
// IMPORTANT: Docker Compose does NOT need quotes in .env files
// It handles special characters correctly without them
// When quotes are used, they become part of the value itself
envContent[key] = value;
appliedUpdates.Add(key);
var maskedValue = key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") || key.Contains("PASSWORD")
? "***" + (value.Length > 8 ? value[^8..] : "")
: value;
_logger.LogInformation(" Setting {Key} = {Value}", key, maskedValue);
_logger.LogInformation(" Setting {Key} = {Value}", key,
key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") || key.Contains("PASSWORD")
? "***" + (value.Length > 8 ? value[^8..] : "")
: value);
var keyPrefix = $"{key}=";
var found = false;
// 1. Look for active exact key
for (int i = 0; i < envLines.Count; i++)
// Auto-set cookie date when Spotify session cookie is updated
if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value))
{
var trimmedLine = envLines[i].TrimStart();
if (trimmedLine.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase))
{
envLines[i] = $"{key}={value}";
found = true;
break;
}
}
// 2. Look for commented out key
if (!found)
{
var commentedPrefix1 = $"# {key}=";
var commentedPrefix2 = $"#{key}=";
for (int i = 0; i < envLines.Count; i++)
{
var trimmedLine = envLines[i].TrimStart();
if (trimmedLine.StartsWith(commentedPrefix1, StringComparison.OrdinalIgnoreCase) ||
trimmedLine.StartsWith(commentedPrefix2, StringComparison.OrdinalIgnoreCase))
{
envLines[i] = $"{key}={value}";
found = true;
break;
}
}
}
// 3. Append to end of file if entirely missing
if (!found)
{
if (envLines.Count > 0 && !string.IsNullOrWhiteSpace(envLines.Last()))
{
envLines.Add("");
}
envLines.Add($"{key}={value}");
var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE";
var dateValue = DateTime.UtcNow.ToString("o"); // ISO 8601 format
envContent[dateKey] = dateValue;
appliedUpdates.Add(dateKey);
_logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue);
}
}
await System.IO.File.WriteAllLinesAsync(envFilePath, envLines);
// Write back to .env file (no quoting needed - Docker Compose handles special chars)
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
await System.IO.File.WriteAllTextAsync(_helperService.GetEnvFilePath(), newContent + "\n");
_logger.LogDebug("Config file updated successfully at {Path}", _helperService.GetEnvFilePath());
@@ -580,7 +531,7 @@ public class ConfigController : ControllerBase
return Ok(new
{
message = "Configuration updated. Restart Allstarr to apply changes.",
message = "Configuration updated. Restart container to apply changes.",
updatedKeys = appliedUpdates,
requiresRestart = true,
envFilePath = _helperService.GetEnvFilePath()
@@ -696,7 +647,7 @@ public class ConfigController : ControllerBase
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
return StatusCode(503, new {
error = "Docker socket not available",
message = "Please restart manually: docker restart allstarr"
message = "Please restart manually: docker-compose restart allstarr"
});
}
@@ -749,7 +700,7 @@ public class ConfigController : ControllerBase
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new {
error = "Failed to restart container",
message = "Please restart manually: docker restart allstarr"
message = "Please restart manually: docker-compose restart allstarr"
});
}
}
@@ -758,7 +709,7 @@ public class ConfigController : ControllerBase
_logger.LogError(ex, "Error restarting container");
return StatusCode(500, new {
error = "Failed to restart container",
message = "Please restart manually: docker restart allstarr"
message = "Please restart manually: docker-compose restart allstarr"
});
}
}
@@ -890,7 +841,7 @@ public class ConfigController : ControllerBase
return Ok(new
{
success = true,
message = ".env file imported successfully. Restart Allstarr for changes to take effect."
message = ".env file imported successfully. Restart the application for changes to take effect."
});
}
catch (Exception ex)
@@ -1,181 +0,0 @@
using System.Text.Json;
using allstarr.Models.Download;
using allstarr.Services;
using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers;
[ApiController]
[Route("api/admin/downloads")]
public class DownloadActivityController : ControllerBase
{
private readonly IEnumerable<IDownloadService> _downloadServices;
private readonly JellyfinSessionManager _sessionManager;
private readonly ILogger<DownloadActivityController> _logger;
public DownloadActivityController(
IEnumerable<IDownloadService> downloadServices,
JellyfinSessionManager sessionManager,
ILogger<DownloadActivityController> logger)
{
_downloadServices = downloadServices;
_sessionManager = sessionManager;
_logger = logger;
}
/// <summary>
/// Returns the current download queue as JSON.
/// </summary>
[HttpGet("queue")]
public IActionResult GetDownloadQueue()
{
var allDownloads = GetAllActivityEntries();
return Ok(allDownloads);
}
/// <summary>
/// Server-Sent Events (SSE) endpoint that pushes the download queue state
/// in real-time.
/// </summary>
[HttpGet("activity")]
public async Task GetDownloadActivity(CancellationToken cancellationToken)
{
Response.Headers.Append("Content-Type", "text/event-stream");
Response.Headers.Append("Cache-Control", "no-cache");
Response.Headers.Append("Connection", "keep-alive");
// Use the request aborted token or the provided cancellation token.
var requestAborted = HttpContext.RequestAborted;
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, requestAborted);
var token = linkedCts.Token;
_logger.LogInformation("Download activity SSE connection opened.");
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
try
{
while (!token.IsCancellationRequested)
{
var allDownloads = GetAllActivityEntries();
var payload = JsonSerializer.Serialize(allDownloads, jsonOptions);
var message = $"data: {payload}\n\n";
await Response.WriteAsync(message, token);
await Response.Body.FlushAsync(token);
await Task.Delay(1000, token); // Poll every 1 second
}
}
catch (TaskCanceledException)
{
// Client gracefully disconnected or requested cancellation
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while pushing download activity stream.");
}
finally
{
_logger.LogInformation("Download activity SSE connection closed.");
}
}
private List<DownloadActivityEntry> GetAllActivityEntries()
{
var allDownloads = new List<DownloadInfo>();
foreach (var service in _downloadServices)
{
allDownloads.AddRange(service.GetActiveDownloads());
}
var orderedDownloads = allDownloads
.OrderByDescending(d => d.Status == DownloadStatus.InProgress)
.ThenByDescending(d => d.StartedAt)
.ToList();
var playbackByItemId = _sessionManager
.GetActivePlaybackStates(TimeSpan.FromMinutes(5))
.GroupBy(state => NormalizeExternalItemId(state.ItemId))
.ToDictionary(
group => group.Key,
group => group.OrderByDescending(state => state.LastActivity).First());
return orderedDownloads
.Select(download =>
{
var normalizedSongId = NormalizeExternalItemId(download.SongId);
var hasPlayback = playbackByItemId.TryGetValue(normalizedSongId, out var playbackState);
var playbackProgress = hasPlayback && download.DurationSeconds.GetValueOrDefault() > 0
? Math.Clamp(
playbackState!.PositionTicks / (double)TimeSpan.TicksPerSecond / download.DurationSeconds!.Value,
0d,
1d)
: (double?)null;
return new DownloadActivityEntry
{
SongId = download.SongId,
ExternalId = download.ExternalId,
ExternalProvider = download.ExternalProvider,
Title = download.Title,
Artist = download.Artist,
Status = download.Status,
Progress = download.Progress,
RequestedForStreaming = download.RequestedForStreaming,
DurationSeconds = download.DurationSeconds,
LocalPath = download.LocalPath,
ErrorMessage = download.ErrorMessage,
StartedAt = download.StartedAt,
CompletedAt = download.CompletedAt,
IsPlaying = hasPlayback,
PlaybackPositionSeconds = hasPlayback
? (int)Math.Max(0, playbackState!.PositionTicks / TimeSpan.TicksPerSecond)
: null,
PlaybackProgress = playbackProgress
};
})
.ToList();
}
private static string NormalizeExternalItemId(string itemId)
{
if (string.IsNullOrWhiteSpace(itemId) || !itemId.StartsWith("ext-", StringComparison.OrdinalIgnoreCase))
{
return itemId;
}
var parts = itemId.Split('-', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 3)
{
return itemId;
}
var knownTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"song",
"album",
"artist"
};
if (parts.Length >= 4 && knownTypes.Contains(parts[2]))
{
return itemId;
}
return $"ext-{parts[1]}-song-{string.Join("-", parts.Skip(2))}";
}
private sealed class DownloadActivityEntry : DownloadInfo
{
public bool IsPlaying { get; init; }
public int? PlaybackPositionSeconds { get; init; }
public double? PlaybackProgress { get; init; }
}
}
+13 -136
View File
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using allstarr.Filters;
using allstarr.Services.Admin;
using allstarr.Services.Lyrics;
namespace allstarr.Controllers;
@@ -10,20 +9,15 @@ 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,
IKeptLyricsSidecarService? keptLyricsSidecarService = null)
IConfiguration configuration)
{
_logger = logger;
_configuration = configuration;
_keptLyricsSidecarService = keptLyricsSidecarService;
}
[HttpGet("downloads")]
@@ -42,8 +36,10 @@ 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(IsSupportedAudioFile)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
foreach (var filePath in allFiles)
@@ -116,11 +112,6 @@ 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);
@@ -148,67 +139,12 @@ 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 async Task<IActionResult> DownloadFile([FromQuery] string path)
public IActionResult DownloadFile([FromQuery] string path)
{
try
{
@@ -230,16 +166,8 @@ public class DownloadsController : ControllerBase
}
var fileName = Path.GetFileName(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)
@@ -254,7 +182,7 @@ public class DownloadsController : ControllerBase
/// Downloads all kept files as a zip archive
/// </summary>
[HttpGet("downloads/all")]
public async Task<IActionResult> DownloadAllFiles()
public IActionResult DownloadAllFiles()
{
try
{
@@ -265,8 +193,9 @@ 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(IsSupportedAudioFile)
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
if (allFiles.Count == 0)
@@ -280,18 +209,14 @@ 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);
await AddFileToArchiveAsync(archive, filePath, relativePath, addedEntries);
var entry = archive.CreateEntry(relativePath, System.IO.Compression.CompressionLevel.NoCompression);
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);
}
using var entryStream = entry.Open();
using var fileStream = System.IO.File.OpenRead(filePath);
fileStream.CopyTo(entryStream);
}
}
@@ -355,54 +280,6 @@ 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>
+50 -214
View File
@@ -1,11 +1,9 @@
using System.Text.Json;
using System.Text;
using System.Net.Http;
using allstarr.Models.Domain;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http.Features;
namespace allstarr.Controllers;
@@ -13,20 +11,6 @@ public partial class JellyfinController
{
#region Helpers
private static readonly HashSet<string> PassthroughResponseHeadersToSkip = new(StringComparer.OrdinalIgnoreCase)
{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"TE",
"Trailer",
"Transfer-Encoding",
"Upgrade",
"Content-Type",
"Content-Length"
};
/// <summary>
/// Helper to handle proxy responses with proper status code handling.
/// </summary>
@@ -64,60 +48,6 @@ public partial class JellyfinController
return NoContent();
}
private async Task<IActionResult> ProxyJsonPassthroughAsync(string endpoint)
{
try
{
// Match the previous proxy semantics for client compatibility.
// Some Jellyfin clients/proxies cancel the ASP.NET request token aggressively
// even though the upstream request would still complete successfully.
var upstreamResponse = await _proxyService.GetPassthroughResponseAsync(
endpoint,
Request.Headers);
HttpContext.Response.RegisterForDispose(upstreamResponse);
HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
Response.StatusCode = (int)upstreamResponse.StatusCode;
Response.Headers["X-Accel-Buffering"] = "no";
CopyPassthroughResponseHeaders(upstreamResponse);
if (upstreamResponse.Content.Headers.ContentLength.HasValue)
{
Response.ContentLength = upstreamResponse.Content.Headers.ContentLength.Value;
}
var contentType = upstreamResponse.Content.Headers.ContentType?.ToString() ?? "application/json";
var stream = await upstreamResponse.Content.ReadAsStreamAsync();
return new FileStreamResult(stream, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to transparently proxy Jellyfin request for {Endpoint}", endpoint);
return StatusCode(502, new { error = "Failed to connect to Jellyfin server" });
}
}
private void CopyPassthroughResponseHeaders(HttpResponseMessage upstreamResponse)
{
foreach (var header in upstreamResponse.Headers)
{
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
{
Response.Headers[header.Key] = header.Value.ToArray();
}
}
foreach (var header in upstreamResponse.Content.Headers)
{
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
{
Response.Headers[header.Key] = header.Value.ToArray();
}
}
}
/// <summary>
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
/// </summary>
@@ -153,14 +83,14 @@ public partial class JellyfinController
if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId))
{
_logger.LogDebug("Found Spotify playlist: {Id}", playlistId);
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
// This is a Spotify playlist - get the actual track count
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
if (playlistConfig != null)
{
_logger.LogDebug(
_logger.LogInformation(
"Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})",
playlistId, playlistConfig.Name, playlistConfig.Id);
var playlistName = playlistConfig.Name;
@@ -199,46 +129,35 @@ 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);
var cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
var exactServedRunTimeTicks = 0L;
if (cachedPlaylistItems != null &&
cachedPlaylistItems.Count > 0 &&
!InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(cachedPlaylistItems))
// Try loading from file cache if Redis is empty
if (matchedTracks == null || matchedTracks.Count == 0)
{
exactServedCount = cachedPlaylistItems.Count;
exactServedRunTimeTicks =
SpotifyPlaylistCountHelper.SumCachedPlaylistRunTimeTicks(cachedPlaylistItems);
_logger.LogDebug(
"Using Redis playlist items cache metrics for {Playlist}: count={Count}, runtimeTicks={RunTimeTicks}",
playlistName, exactServedCount, exactServedRunTimeTicks);
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
if (fileItems != null && fileItems.Count > 0)
{
_logger.LogDebug(
"💿 Loaded {Count} playlist items from file cache for count update",
fileItems.Count);
// Use file cache count directly
itemDict["ChildCount"] = fileItems.Count;
modified = true;
}
}
if (exactServedCount > 0)
// Only fetch from Jellyfin if we didn't get count from file cache
if (!itemDict.ContainsKey("ChildCount") ||
(itemDict["ChildCount"] is JsonElement childCountElement &&
childCountElement.GetInt32() == 0) ||
(itemDict["ChildCount"] is int childCountInt && childCountInt == 0))
{
itemDict["ChildCount"] = exactServedCount;
itemDict["RunTimeTicks"] = exactServedRunTimeTicks;
modified = true;
}
else
{
// Recompute ChildCount for injected playlists instead of trusting
// Jellyfin/plugin values, which only reflect local tracks.
// Get local tracks count from Jellyfin
var localTracksCount = 0;
var localRunTimeTicks = 0L;
try
{
// Include UserId parameter to avoid 401 Unauthorized
var userId = _settings.UserId;
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
var queryParams = new Dictionary<string, string>
{
["Fields"] = "Id,RunTimeTicks",
["Limit"] = "10000"
};
var queryParams = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(userId))
{
queryParams["UserId"] = userId;
@@ -251,16 +170,8 @@ public partial class JellyfinController
if (localTracksResponse != null &&
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
{
foreach (var localItem in localItems.EnumerateArray())
{
localTracksCount++;
localRunTimeTicks += SpotifyPlaylistCountHelper.ExtractRunTimeTicks(
localItem.TryGetProperty("RunTimeTicks", out var runTimeTicks)
? runTimeTicks
: null);
}
_logger.LogDebug("Found {Count} local Jellyfin items in playlist {Name}",
localTracksCount = localItems.GetArrayLength();
_logger.LogDebug("Found {Count} total items in Jellyfin playlist {Name}",
localTracksCount, playlistName);
}
}
@@ -269,25 +180,33 @@ public partial class JellyfinController
_logger.LogError(ex, "Failed to get local tracks count for {Name}", playlistName);
}
var totalAvailableCount = SpotifyPlaylistCountHelper.ComputeServedItemCount(
exactServedCount > 0 ? exactServedCount : null,
localTracksCount,
matchedTracks);
var totalRunTimeTicks = SpotifyPlaylistCountHelper.ComputeServedRunTimeTicks(
exactServedCount > 0 ? exactServedRunTimeTicks : null,
localRunTimeTicks,
matchedTracks);
// Count external matched tracks (not local)
var externalMatchedCount = 0;
if (matchedTracks != null)
{
externalMatchedCount = matchedTracks.Count(t =>
t.MatchedSong != null && !t.MatchedSong.IsLocal);
}
itemDict["ChildCount"] = totalAvailableCount;
itemDict["RunTimeTicks"] = totalRunTimeTicks;
modified = true;
_logger.LogDebug(
"✓ Updated Spotify playlist metrics for {Name}: count={Total} ({Local} local + {External} external), runtimeTicks={RunTimeTicks}",
playlistName,
totalAvailableCount,
localTracksCount,
SpotifyPlaylistCountHelper.CountExternalMatchedTracks(matchedTracks),
totalRunTimeTicks);
// Total available tracks = local tracks in Jellyfin + external matched tracks
// This represents what users will actually hear when playing the playlist
var totalAvailableCount = localTracksCount + externalMatchedCount;
if (totalAvailableCount > 0)
{
// Update ChildCount to show actual available tracks
itemDict["ChildCount"] = totalAvailableCount;
modified = true;
_logger.LogDebug(
"✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
}
else
{
_logger.LogWarning(
"No tracks found for {Name} ({Local} local + {External} external = {Total} total)",
playlistName, localTracksCount, externalMatchedCount, totalAvailableCount);
}
}
}
else
@@ -477,89 +396,6 @@ public partial class JellyfinController
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string? GetExactPlaylistItemsRequestId(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3 ||
!parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase) ||
!parts[2].Equals("items", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return parts[1];
}
private static string? ExtractImageTag(JsonElement item, string imageType)
{
if (item.TryGetProperty("ImageTags", out var imageTags) &&
imageTags.ValueKind == JsonValueKind.Object)
{
foreach (var imageTag in imageTags.EnumerateObject())
{
if (string.Equals(imageTag.Name, imageType, StringComparison.OrdinalIgnoreCase))
{
return imageTag.Value.GetString();
}
}
}
if (string.Equals(imageType, "Primary", StringComparison.OrdinalIgnoreCase) &&
item.TryGetProperty("PrimaryImageTag", out var primaryImageTag))
{
return primaryImageTag.GetString();
}
return null;
}
/// <summary>
/// Determines whether Spotify playlist count enrichment should run for a response.
/// We only run enrichment for playlist-oriented payloads to avoid mutating unrelated item lists
/// (for example, album browse responses requested by clients like Finer).
/// </summary>
private bool ShouldProcessSpotifyPlaylistCounts(JsonDocument response, string? includeItemTypes)
{
if (!_spotifySettings.Enabled)
{
return false;
}
if (response.RootElement.ValueKind != JsonValueKind.Object ||
!response.RootElement.TryGetProperty("Items", out var items) ||
items.ValueKind != JsonValueKind.Array)
{
return false;
}
var requestedTypes = ParseItemTypes(includeItemTypes);
if (requestedTypes != null && requestedTypes.Length > 0)
{
return requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase);
}
// If the request did not explicitly constrain types, inspect payload types.
foreach (var item in items.EnumerateArray())
{
if (!item.TryGetProperty("Type", out var typeProp))
{
continue;
}
if (string.Equals(typeProp.GetString(), "Playlist", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
/// <summary>
/// Recovers SearchTerm directly from raw query string.
/// Handles malformed clients that do not URL-encode '&' inside SearchTerm.
@@ -245,9 +245,7 @@ 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,
[FromQuery] bool includeStats = true)
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
{
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
{
@@ -332,13 +330,13 @@ public class JellyfinAdminController : ControllerBase
var statsUserId = requestedUserId;
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
if (isConfigured && includeStats)
if (isConfigured)
{
trackStats = await GetPlaylistTrackStats(id!, session, statsUserId);
}
var actualTrackCount = isConfigured
? (includeStats ? trackStats.LocalTracks + trackStats.ExternalTracks : childCount)
? trackStats.LocalTracks + trackStats.ExternalTracks
: childCount;
playlists.Add(new
@@ -351,7 +349,6 @@ public class JellyfinAdminController : ControllerBase
isLinkedByAnotherUser,
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
allLinkedForPlaylist.FirstOrDefault()?.UserId,
statsPending = isConfigured && !includeStats,
localTracks = trackStats.LocalTracks,
externalTracks = trackStats.ExternalTracks,
externalAvailable = trackStats.ExternalAvailable
@@ -69,9 +69,8 @@ public partial class JellyfinController
return await ProxyJellyfinStream(fullPath, itemId);
}
// Handle external content with quality override from client transcoding params
var quality = StreamQualityHelper.ParseFromQueryString(Request.Query);
return await StreamExternalContent(provider!, externalId!, quality);
// Handle external content
return await StreamExternalContent(provider!, externalId!);
}
/// <summary>
@@ -151,9 +150,8 @@ public partial class JellyfinController
/// <summary>
/// Streams external content, using cache if available or downloading on-demand.
/// Supports quality override for client-requested "transcoding" of external tracks.
/// </summary>
private async Task<IActionResult> StreamExternalContent(string provider, string externalId, StreamQuality quality = StreamQuality.Original)
private async Task<IActionResult> StreamExternalContent(string provider, string externalId)
{
// Check for locally cached file
var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider, externalId);
@@ -180,32 +178,13 @@ public partial class JellyfinController
var downloadStream = await _downloadService.DownloadAndStreamAsync(
provider,
externalId,
quality != StreamQuality.Original ? quality : null,
HttpContext.RequestAborted);
var contentType = "audio/mpeg";
if (downloadStream is FileStream fs)
{
contentType = GetContentType(fs.Name);
}
return File(downloadStream, contentType, enableRangeProcessing: true);
return File(downloadStream, "audio/mpeg", enableRangeProcessing: true);
}
catch (Exception ex)
{
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
{
_logger.LogError("Failed to stream external song {Provider}:{ExternalId}: {StatusCode}: {ReasonPhrase}",
provider,
externalId,
(int)httpRequestException.StatusCode.Value,
httpRequestException.StatusCode.Value);
_logger.LogDebug(ex, "Detailed streaming failure for external song {Provider}:{ExternalId}", provider, externalId);
}
else
{
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
}
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
return StatusCode(500, new { error = "Streaming failed" });
}
}
@@ -237,9 +216,8 @@ public partial class JellyfinController
return await ProxyJellyfinStream(fullPath, itemId);
}
// For external content, parse quality override from client transcoding params
var quality = StreamQualityHelper.ParseFromQueryString(Request.Query);
return await StreamExternalContent(provider!, externalId!, quality);
// For external content, use simple streaming (no transcoding support yet)
return await StreamExternalContent(provider!, externalId!);
}
#endregion
@@ -1,6 +1,5 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using allstarr.Services.Common;
namespace allstarr.Controllers;
@@ -39,9 +38,66 @@ public partial class JellyfinController
{
var responseJson = result.RootElement.GetRawText();
// On successful auth, extract access token and post session capabilities in background
if (statusCode == 200)
{
_logger.LogInformation("Authentication successful");
// Extract access token from response for session capabilities
string? accessToken = null;
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
{
accessToken = tokenEl.GetString();
}
// Post session capabilities in background if we have a token
if (!string.IsNullOrEmpty(accessToken))
{
// Capture token in closure - don't use Request.Headers (will be disposed)
var token = accessToken;
_ = Task.Run(async () =>
{
try
{
_logger.LogDebug("🔧 Posting session capabilities after authentication");
// Build auth header with the new token
var authHeaders = new HeaderDictionary
{
["X-Emby-Token"] = token
};
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
var (capResult, capStatus) =
await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson,
authHeaders);
if (capStatus == 204 || capStatus == 200)
{
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})",
capStatus);
}
else
{
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth",
capStatus);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to post session capabilities after auth");
}
});
}
}
else
{
@@ -145,11 +145,12 @@ public partial class JellyfinController
return NotFound(new { error = "Song not found" });
}
// Strip external track labels from lyrics search terms.
var searchTitle = StripTrackDecorators(song.Title);
var searchArtist = StripTrackDecorators(song.Artist);
var searchAlbum = StripTrackDecorators(song.Album);
var searchArtists = song.Artists.Select(StripTrackDecorators).ToList();
// Strip [S] suffix from title, artist, and album for lyrics search
// The [S] tag is added to external tracks but shouldn't be used in lyrics queries
var searchTitle = song.Title.Replace(" [S]", "").Trim();
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
{
@@ -378,11 +379,11 @@ public partial class JellyfinController
return;
}
// Strip external track labels for lyrics search.
var searchTitle = StripTrackDecorators(song.Title);
var searchArtist = StripTrackDecorators(song.Artist);
var searchAlbum = StripTrackDecorators(song.Album);
var searchArtists = song.Artists.Select(StripTrackDecorators).ToList();
// Strip [S] suffix for lyrics search
var searchTitle = song.Title.Replace(" [S]", "").Trim();
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
{
@@ -466,18 +467,5 @@ public partial class JellyfinController
}
}
private static string StripTrackDecorators(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return value
.Replace(" [S]", "", StringComparison.Ordinal)
.Replace(" [E]", "", StringComparison.Ordinal)
.Trim();
}
#endregion
}
}
@@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Globalization;
using allstarr.Models.Scrobbling;
@@ -8,11 +7,6 @@ namespace allstarr.Controllers;
public partial class JellyfinController
{
private static readonly TimeSpan InferredStopDedupeWindow = TimeSpan.FromSeconds(15);
private static readonly TimeSpan PlaybackSignalDedupeWindow = TimeSpan.FromSeconds(8);
private static readonly TimeSpan PlaybackSignalRetentionWindow = TimeSpan.FromMinutes(5);
private static readonly ConcurrentDictionary<string, DateTime> RecentPlaybackSignals = new();
#region Playback Session Reporting
#region Session Management
@@ -59,28 +53,22 @@ public partial class JellyfinController
_logger.LogDebug("Capabilities body length: {BodyLength} bytes", body.Length);
}
var (_, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers);
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
return NoContent();
}
if (statusCode == 401)
else if (statusCode == 401)
{
_logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)");
return Unauthorized();
}
if (statusCode == 403)
else
{
_logger.LogWarning("⚠ Jellyfin returned 403 for capabilities");
return Forbid();
_logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode);
}
_logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode);
return StatusCode(statusCode);
return NoContent();
}
catch (Exception ex)
{
@@ -116,7 +104,6 @@ public partial class JellyfinController
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
string? playSessionId = null;
itemId = ParsePlaybackItemId(doc.RootElement);
@@ -126,7 +113,6 @@ public partial class JellyfinController
}
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
playSessionId = ParsePlaybackSessionId(doc.RootElement);
// Track the playing item for scrobbling on session cleanup (local tracks only)
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
@@ -183,23 +169,6 @@ public partial class JellyfinController
}
}
if (ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId))
{
_logger.LogDebug(
"Skipping duplicate external playback start signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})",
itemId,
deviceId ?? "unknown",
playSessionId ?? "none");
if (sessionReady)
{
_sessionManager.UpdateActivity(deviceId!);
_sessionManager.UpdatePlayingItem(deviceId!, itemId, positionTicks);
}
return NoContent();
}
// Fetch metadata early so we can log the correct track name
var song = await _metadataService.GetSongAsync(provider!, externalId!);
var trackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown";
@@ -273,8 +242,7 @@ public partial class JellyfinController
artist: song.Artist,
album: song.Album,
albumArtist: song.AlbumArtist,
durationSeconds: song.Duration,
startPositionSeconds: ToPlaybackPositionSeconds(positionTicks)
durationSeconds: song.Duration
);
if (track != null)
@@ -316,24 +284,6 @@ public partial class JellyfinController
});
}
if (!string.IsNullOrEmpty(itemId) &&
ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId))
{
_logger.LogDebug(
"Skipping duplicate local playback start signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})",
itemId,
deviceId ?? "unknown",
playSessionId ?? "none");
if (!string.IsNullOrEmpty(deviceId))
{
_sessionManager.UpdateActivity(deviceId);
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
}
return NoContent();
}
// For local tracks, forward playback start to Jellyfin FIRST
_logger.LogDebug("Forwarding playback start to Jellyfin...");
@@ -489,11 +439,9 @@ public partial class JellyfinController
var doc = JsonDocument.Parse(body);
string? itemId = null;
long? positionTicks = null;
string? playSessionId = null;
itemId = ParsePlaybackItemId(doc.RootElement);
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
playSessionId = ParsePlaybackSessionId(doc.RootElement);
deviceId = ResolveDeviceId(deviceId, doc.RootElement);
@@ -534,8 +482,7 @@ public partial class JellyfinController
artist: song.Artist,
album: song.Album,
albumArtist: song.AlbumArtist,
durationSeconds: song.Duration,
startPositionSeconds: ToPlaybackPositionSeconds(positionTicks)
durationSeconds: song.Duration
);
}
else
@@ -594,11 +541,9 @@ public partial class JellyfinController
}
var (previousItemId, previousPositionTicks) = _sessionManager.GetLastPlayingState(deviceId);
var inferredStop = sessionReady &&
!string.IsNullOrWhiteSpace(previousItemId) &&
var inferredStop = !string.IsNullOrWhiteSpace(previousItemId) &&
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
var inferredStart = sessionReady &&
!string.IsNullOrWhiteSpace(itemId) &&
var inferredStart = !string.IsNullOrWhiteSpace(itemId) &&
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
if (sessionReady && inferredStop && !string.IsNullOrWhiteSpace(previousItemId))
@@ -612,8 +557,7 @@ public partial class JellyfinController
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
}
if (inferredStart &&
!ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId))
if (inferredStart)
{
var song = await _metadataService.GetSongAsync(provider!, externalId!);
var externalTrackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown";
@@ -657,8 +601,7 @@ public partial class JellyfinController
artist: song.Artist,
album: song.Album,
albumArtist: song.AlbumArtist,
durationSeconds: song.Duration,
startPositionSeconds: ToPlaybackPositionSeconds(positionTicks));
durationSeconds: song.Duration);
if (track != null)
{
@@ -672,20 +615,6 @@ public partial class JellyfinController
});
}
}
else if (inferredStart)
{
_logger.LogDebug(
"Skipping duplicate inferred external playback start signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})",
itemId,
deviceId,
playSessionId ?? "none");
}
else if (!sessionReady)
{
_logger.LogDebug(
"Skipping inferred external playback start/stop from progress for {DeviceId} because session is unavailable",
deviceId);
}
}
// For external tracks, report progress with ghost UUID to Jellyfin
@@ -748,11 +677,9 @@ public partial class JellyfinController
}
var (previousItemId, previousPositionTicks) = _sessionManager.GetLastPlayingState(deviceId);
var inferredStop = sessionReady &&
!string.IsNullOrWhiteSpace(previousItemId) &&
var inferredStop = !string.IsNullOrWhiteSpace(previousItemId) &&
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
var inferredStart = sessionReady &&
!string.IsNullOrWhiteSpace(itemId) &&
var inferredStart = !string.IsNullOrWhiteSpace(itemId) &&
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
if (sessionReady && inferredStop && !string.IsNullOrWhiteSpace(previousItemId))
@@ -766,8 +693,7 @@ public partial class JellyfinController
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
}
if (inferredStart &&
!ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId))
if (inferredStart)
{
var trackName = await TryGetLocalTrackNameAsync(itemId);
_logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})",
@@ -812,20 +738,6 @@ public partial class JellyfinController
});
}
}
else if (inferredStart)
{
_logger.LogDebug(
"Skipping duplicate inferred local playback start signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})",
itemId,
deviceId,
playSessionId ?? "none");
}
else if (!sessionReady)
{
_logger.LogDebug(
"Skipping inferred local playback start/stop from progress for {DeviceId} because session is unavailable",
deviceId);
}
// When local scrobbling is disabled, still trigger Jellyfin's user-data path
// shortly after the normal scrobble threshold so downstream plugins that listen
@@ -889,25 +801,6 @@ public partial class JellyfinController
string previousItemId,
long? previousPositionTicks)
{
if (_sessionManager.WasRecentlyExplicitlyStopped(deviceId, previousItemId, InferredStopDedupeWindow))
{
_logger.LogDebug(
"Skipping inferred stop for {ItemId} on {DeviceId} (explicit stop already recorded within {Window}s)",
previousItemId,
deviceId,
InferredStopDedupeWindow.TotalSeconds);
return;
}
if (ShouldSuppressPlaybackSignal("stop", deviceId, previousItemId, playSessionId: null))
{
_logger.LogDebug(
"Skipping duplicate inferred playback stop signal for {ItemId} on {DeviceId}",
previousItemId,
deviceId);
return;
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(previousItemId);
if (isExternal)
@@ -1025,10 +918,7 @@ public partial class JellyfinController
string itemId,
long? positionTicks)
{
if (!_scrobblingSettings.Enabled ||
_scrobblingSettings.LocalTracksEnabled ||
!_scrobblingSettings.SyntheticLocalPlayedSignalEnabled ||
_scrobblingHelper == null)
if (_scrobblingSettings.LocalTracksEnabled || _scrobblingHelper == null)
{
return;
}
@@ -1119,22 +1009,6 @@ public partial class JellyfinController
return _settings.UserId;
}
private static int? ToPlaybackPositionSeconds(long? positionTicks)
{
if (!positionTicks.HasValue)
{
return null;
}
var seconds = positionTicks.Value / TimeSpan.TicksPerSecond;
if (seconds <= 0)
{
return 0;
}
return seconds > int.MaxValue ? int.MaxValue : (int)seconds;
}
private string? ResolveDeviceId(string? parsedDeviceId, JsonElement? payload = null)
{
if (!string.IsNullOrWhiteSpace(parsedDeviceId))
@@ -1197,7 +1071,6 @@ public partial class JellyfinController
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
string? playSessionId = null;
var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers);
itemId = ParsePlaybackItemId(doc.RootElement);
@@ -1213,7 +1086,6 @@ public partial class JellyfinController
}
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
playSessionId = ParsePlaybackSessionId(doc.RootElement);
deviceId = ResolveDeviceId(deviceId, doc.RootElement);
@@ -1241,24 +1113,6 @@ public partial class JellyfinController
if (isExternal)
{
if (ShouldSuppressPlaybackSignal("stop", deviceId, itemId, playSessionId))
{
_logger.LogDebug(
"Skipping duplicate external playback stop signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})",
itemId,
deviceId ?? "unknown",
playSessionId ?? "none");
if (!string.IsNullOrWhiteSpace(deviceId))
{
_sessionManager.MarkExplicitStop(deviceId, itemId);
_sessionManager.UpdatePlayingItem(deviceId, null, null);
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30));
}
return NoContent();
}
var position = positionTicks.HasValue
? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss")
: "unknown";
@@ -1353,13 +1207,6 @@ public partial class JellyfinController
});
}
if ((stopStatusCode == 200 || stopStatusCode == 204) && !string.IsNullOrWhiteSpace(deviceId))
{
_sessionManager.MarkExplicitStop(deviceId, itemId);
_sessionManager.UpdatePlayingItem(deviceId, null, null);
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30));
}
return NoContent();
}
@@ -1389,24 +1236,6 @@ public partial class JellyfinController
_logger.LogInformation("🎵 Local track playback stopped: {Name} (ID: {ItemId})",
trackName ?? "Unknown", itemId);
if (ShouldSuppressPlaybackSignal("stop", deviceId, itemId, playSessionId))
{
_logger.LogDebug(
"Skipping duplicate local playback stop signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})",
itemId,
deviceId ?? "unknown",
playSessionId ?? "none");
if (!string.IsNullOrWhiteSpace(deviceId))
{
_sessionManager.MarkExplicitStop(deviceId, itemId);
_sessionManager.UpdatePlayingItem(deviceId, null, null);
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30));
}
return NoContent();
}
// Scrobble local track playback stop (only if enabled)
if (_scrobblingSettings.LocalTracksEnabled && _scrobblingOrchestrator != null &&
_scrobblingHelper != null && !string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId) &&
@@ -1480,11 +1309,6 @@ public partial class JellyfinController
_logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
if (!string.IsNullOrWhiteSpace(deviceId))
{
if (!string.IsNullOrWhiteSpace(itemId))
{
_sessionManager.MarkExplicitStop(deviceId, itemId);
}
_sessionManager.UpdatePlayingItem(deviceId, null, null);
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30));
}
}
@@ -1558,14 +1382,9 @@ public partial class JellyfinController
string.Join(", ", Request.Headers.Keys.Where(h =>
h.Contains("Auth", StringComparison.OrdinalIgnoreCase))));
// Read body if present. Preserve true empty-body requests because Jellyfin
// uses several POST session-control endpoints with query params only.
string? body = null;
var hasRequestBody = !HttpMethods.IsGet(method) &&
(Request.ContentLength.GetValueOrDefault() > 0 ||
Request.Headers.ContainsKey("Transfer-Encoding"));
if (hasRequestBody)
// Read body if present
string body = "{}";
if ((method == "POST" || method == "PUT") && Request.ContentLength > 0)
{
Request.EnableBuffering();
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8,
@@ -1582,9 +1401,9 @@ public partial class JellyfinController
var (result, statusCode) = method switch
{
"GET" => await _proxyService.GetJsonAsync(endpoint, null, Request.Headers),
"POST" => await _proxyService.SendAsync(HttpMethod.Post, endpoint, body, Request.Headers, Request.ContentType),
"PUT" => await _proxyService.SendAsync(HttpMethod.Put, endpoint, body, Request.Headers, Request.ContentType),
"DELETE" => await _proxyService.SendAsync(HttpMethod.Delete, endpoint, body, Request.Headers, Request.ContentType),
"POST" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers),
"PUT" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for PUT
"DELETE" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for DELETE
_ => (null, 405)
};
@@ -1669,78 +1488,6 @@ public partial class JellyfinController
return ParseOptionalString(value);
}
private static string? ParsePlaybackSessionId(JsonElement payload)
{
var direct = TryReadStringProperty(payload, "PlaySessionId");
if (!string.IsNullOrWhiteSpace(direct))
{
return direct;
}
if (payload.TryGetProperty("PlaySession", out var playSession))
{
var nested = TryReadStringProperty(playSession, "Id");
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
}
return null;
}
private static bool ShouldSuppressPlaybackSignal(
string signalType,
string? deviceId,
string itemId,
string? playSessionId)
{
if (string.IsNullOrWhiteSpace(itemId))
{
return false;
}
var normalizedDevice = string.IsNullOrWhiteSpace(deviceId) ? "unknown-device" : deviceId;
var baseKey = $"{signalType}:{normalizedDevice}:{itemId}";
var sessionKey = string.IsNullOrWhiteSpace(playSessionId)
? null
: $"{baseKey}:{playSessionId}";
var now = DateTime.UtcNow;
if (RecentPlaybackSignals.TryGetValue(baseKey, out var lastSeenAtUtc) &&
(now - lastSeenAtUtc) <= PlaybackSignalDedupeWindow)
{
return true;
}
if (!string.IsNullOrWhiteSpace(sessionKey) &&
RecentPlaybackSignals.TryGetValue(sessionKey, out var lastSeenForSessionAtUtc) &&
(now - lastSeenForSessionAtUtc) <= PlaybackSignalDedupeWindow)
{
return true;
}
RecentPlaybackSignals[baseKey] = now;
if (!string.IsNullOrWhiteSpace(sessionKey))
{
RecentPlaybackSignals[sessionKey] = now;
}
if (RecentPlaybackSignals.Count > 4096)
{
var cutoff = now - PlaybackSignalRetentionWindow;
foreach (var pair in RecentPlaybackSignals)
{
if (pair.Value < cutoff)
{
RecentPlaybackSignals.TryRemove(pair.Key, out _);
}
}
}
return false;
}
private static string? ParsePlaybackItemId(JsonElement payload)
{
var direct = TryReadStringProperty(payload, "ItemId");
+256 -262
View File
@@ -1,7 +1,5 @@
using System.Text.Json;
using System.Text;
using allstarr.Models.Domain;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
@@ -33,11 +31,9 @@ public partial class JellyfinController
{
var boundSearchTerm = searchTerm;
searchTerm = GetEffectiveSearchTerm(searchTerm, Request.QueryString.Value);
string? searchCacheKey = null;
// AlbumArtistIds takes precedence over ArtistIds if both are provided
var effectiveArtistIds = albumArtistIds ?? artistIds;
var favoritesOnlyRequest = IsFavoritesOnlyRequest();
_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);
@@ -67,12 +63,6 @@ public partial class JellyfinController
if (isExternal)
{
if (favoritesOnlyRequest)
{
_logger.LogDebug("Suppressing external artist results for favorites-only request: {ArtistId}", artistId);
return CreateEmptyItemsResponse(startIndex);
}
// Check if this is a curator ID (format: ext-{provider}-curator-{name})
if (artistId.Contains("-curator-", StringComparison.OrdinalIgnoreCase))
{
@@ -95,12 +85,6 @@ public partial class JellyfinController
if (isExternal)
{
if (favoritesOnlyRequest)
{
_logger.LogDebug("Suppressing external album results for favorites-only request: {AlbumId}", albumId);
return CreateEmptyItemsResponse(startIndex);
}
_logger.LogDebug("Fetching songs for external album: {Provider}/{ExternalId}", provider,
externalId);
@@ -136,12 +120,6 @@ public partial class JellyfinController
if (isExternal)
{
if (favoritesOnlyRequest)
{
_logger.LogDebug("Suppressing external parent results for favorites-only request: {ParentId}", parentId);
return CreateEmptyItemsResponse(startIndex);
}
// External parent - get external content
_logger.LogDebug("Fetching children for external parent: {Provider}/{Type}/{ExternalId}",
provider, type, externalId);
@@ -183,7 +161,7 @@ public partial class JellyfinController
// Check cache for search results (only cache pure searches, not filtered searches)
if (string.IsNullOrWhiteSpace(effectiveArtistIds) && string.IsNullOrWhiteSpace(albumIds))
{
searchCacheKey = CacheKeyBuilder.BuildSearchKey(
var cacheKey = CacheKeyBuilder.BuildSearchKey(
searchTerm,
includeItemTypes,
limit,
@@ -192,14 +170,13 @@ public partial class JellyfinController
sortBy,
Request.Query["SortOrder"].ToString(),
recursive,
userId,
Request.Query["IsFavorite"].ToString());
var cachedResult = await _cache.GetStringAsync(searchCacheKey);
userId);
var cachedResult = await _cache.GetAsync<object>(cacheKey);
if (!string.IsNullOrWhiteSpace(cachedResult))
if (cachedResult != null)
{
_logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", searchCacheKey);
return Content(cachedResult, "application/json");
_logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", cacheKey);
return new JsonResult(cachedResult);
}
}
@@ -212,14 +189,10 @@ public partial class JellyfinController
var endpoint = userId != null ? $"Users/{userId}/Items" : "Items";
// Include MediaSources only for audio-oriented browse requests (bitrate needs).
// Album/artist browse requests should stay as close to raw Jellyfin responses as possible.
// Ensure MediaSources is included in Fields parameter for bitrate info
var queryString = Request.QueryString.Value ?? "";
var requestedTypes = ParseItemTypes(includeItemTypes);
var shouldIncludeMediaSources = requestedTypes != null &&
requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
if (shouldIncludeMediaSources && !string.IsNullOrEmpty(queryString))
if (!string.IsNullOrEmpty(queryString))
{
// Parse query string to modify Fields parameter
var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
@@ -258,16 +231,13 @@ public partial class JellyfinController
queryString = $"{queryString}&Fields=MediaSources";
}
}
else if (shouldIncludeMediaSources)
else
{
// No query string at all
queryString = "?Fields=MediaSources";
}
if (!string.IsNullOrEmpty(queryString))
{
endpoint = $"{endpoint}{queryString}";
}
endpoint = $"{endpoint}{queryString}";
var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
@@ -279,7 +249,7 @@ public partial class JellyfinController
}
// Update Spotify playlist counts if enabled and response contains playlists
if (ShouldProcessSpotifyPlaylistCounts(browseResult, includeItemTypes))
if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _))
{
_logger.LogDebug("Browse result has Items, checking for Spotify playlists to update counts");
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
@@ -305,7 +275,6 @@ public partial class JellyfinController
// Run local and external searches in parallel
var itemTypes = ParseItemTypes(includeItemTypes);
var externalSearchLimits = GetExternalSearchLimits(itemTypes, limit, includePlaylistsAsAlbums: true);
var jellyfinTask = GetLocalSearchResultForCurrentRequest(
cleanQuery,
includeItemTypes,
@@ -314,33 +283,14 @@ public partial class JellyfinController
recursive,
userId);
_logger.LogInformation(
"SEARCH TRACE: external limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit);
// Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = favoritesOnlyRequest
? Task.FromResult(new SearchResult())
: _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted);
var externalTask = _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
var playlistTask = favoritesOnlyRequest || !_settings.EnableExternalPlaylists
? Task.FromResult(new List<ExternalPlaylist>())
: _metadataService.SearchPlaylistsAsync(cleanQuery, limit, HttpContext.RequestAborted);
var playlistTask = _settings.EnableExternalPlaylists
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit, HttpContext.RequestAborted)
: Task.FromResult(new List<ExternalPlaylist>());
_logger.LogDebug("Playlist search enabled: {Enabled}, searching for: '{Query}'",
_settings.EnableExternalPlaylists, cleanQuery);
@@ -404,11 +354,11 @@ public partial class JellyfinController
var externalAlbumItems = externalResult.Albums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
var externalArtistItems = externalResult.Artists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
// Keep Jellyfin/provider ordering intact.
// Scores only decide which source leads each interleaving round.
var allSongs = InterleaveByScore(jellyfinSongItems, externalSongItems, cleanQuery, primaryBoost: 5.0);
var allAlbums = InterleaveByScore(jellyfinAlbumItems, externalAlbumItems, cleanQuery, primaryBoost: 5.0);
var allArtists = InterleaveByScore(jellyfinArtistItems, externalArtistItems, cleanQuery, primaryBoost: 5.0);
// Score-sort each source, then interleave by highest remaining score.
// Keep only a small source preference for already-relevant primary results.
var allSongs = InterleaveByScore(jellyfinSongItems, externalSongItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 72);
var allAlbums = InterleaveByScore(jellyfinAlbumItems, externalAlbumItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 78);
var allArtists = InterleaveByScore(jellyfinArtistItems, externalArtistItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 75);
// Log top results for debugging
if (_logger.IsEnabled(LogLevel.Debug))
@@ -457,8 +407,8 @@ public partial class JellyfinController
_logger.LogDebug("No playlists found to merge with albums");
}
// Keep album/playlist source ordering intact and only let scores decide who leads each round.
var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 0.0);
// Merge albums and playlists using score-based interleaving (albums keep a light priority over playlists).
var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 2.0, boostMinScore: 70);
_logger.LogDebug(
"Merged results: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}",
@@ -553,16 +503,23 @@ public partial class JellyfinController
TotalRecordCount = items.Count,
StartIndex = startIndex
};
var json = SerializeSearchResponseJson(response);
// Cache search results in Redis using the configured search TTL.
if (!string.IsNullOrWhiteSpace(searchTerm) &&
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
!string.IsNullOrWhiteSpace(searchCacheKey))
// Cache search results in Redis (15 min TTL, no file persistence)
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds))
{
if (externalHasRequestedTypeResults)
{
await _cache.SetStringAsync(searchCacheKey, json, CacheExtensions.SearchResultsTTL);
var cacheKey = CacheKeyBuilder.BuildSearchKey(
searchTerm,
includeItemTypes,
limit,
startIndex,
parentId,
sortBy,
Request.Query["SortOrder"].ToString(),
recursive,
userId);
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
CacheExtensions.SearchResultsTTL.TotalMinutes);
}
@@ -577,6 +534,12 @@ public partial class JellyfinController
_logger.LogDebug("About to serialize response...");
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null
});
if (_logger.IsEnabled(LogLevel.Debug))
{
var preview = json.Length > 200 ? json[..200] : json;
@@ -592,15 +555,6 @@ public partial class JellyfinController
}
}
private static string SerializeSearchResponseJson<T>(T response) where T : class
{
return JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = null,
DictionaryKeyPolicy = null
});
}
/// <summary>
/// Gets child items of a parent (tracks in album, albums for artist).
/// </summary>
@@ -691,36 +645,11 @@ 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);
_logger.LogInformation(
"SEARCH TRACE: hint limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit);
// Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(
cleanQuery,
externalSearchLimits.SongLimit,
externalSearchLimits.AlbumLimit,
externalSearchLimits.ArtistLimit,
HttpContext.RequestAborted);
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
// Run searches in parallel (local Jellyfin hints + external providers)
var jellyfinTask = GetLocalSearchHintsResultForCurrentRequest(cleanQuery, userId);
@@ -733,15 +662,9 @@ public partial class JellyfinController
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseSearchHintsResponse(jellyfinResult);
// NO deduplication - merge all results and take top matches
var allSongs = includesSongs
? localSongs.Concat(externalResult.Songs).Take(limit).ToList()
: new List<Song>();
var allAlbums = includesAlbums
? localAlbums.Concat(externalResult.Albums).Take(limit).ToList()
: new List<Album>();
var allArtists = includesArtists
? localArtists.Concat(externalResult.Artists).Take(limit).ToList()
: new List<Artist>();
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList();
return _responseBuilder.CreateSearchHintsResponse(
allSongs.Take(limit).ToList(),
@@ -787,87 +710,81 @@ public partial class JellyfinController
return MaskSensitiveQueryString(query);
}
private bool IsFavoritesOnlyRequest()
{
return string.Equals(Request.Query["IsFavorite"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
}
private static (int SongLimit, int AlbumLimit, int ArtistLimit) GetExternalSearchLimits(
string[]? requestedTypes,
int limit,
bool includePlaylistsAsAlbums)
{
if (limit <= 0)
{
return (0, 0, 0);
}
if (requestedTypes == null || requestedTypes.Length == 0)
{
return (limit, limit, limit);
}
var includeSongs = requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) ||
(includePlaylistsAsAlbums &&
requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
return (
includeSongs ? limit : 0,
includeAlbums ? limit : 0,
includeArtists ? limit : 0);
}
private static IActionResult CreateEmptyItemsResponse(int startIndex)
{
return new JsonResult(new
{
Items = Array.Empty<object>(),
TotalRecordCount = 0,
StartIndex = startIndex
});
}
/// <summary>
/// Merges two source queues without reordering either queue.
/// At each step, compare only the current head from each source and dequeue the winner.
/// Score-sorts each source and then interleaves by highest remaining score.
/// This avoids weak head results in one source blocking stronger results later in that same source.
/// </summary>
private List<Dictionary<string, object?>> InterleaveByScore(
List<Dictionary<string, object?>> primaryItems,
List<Dictionary<string, object?>> secondaryItems,
string query,
double primaryBoost)
double primaryBoost,
double boostMinScore = 70)
{
var primaryScored = primaryItems.Select(item =>
var primaryScored = primaryItems.Select((item, index) =>
{
var baseScore = CalculateItemRelevanceScore(query, item);
var finalScore = baseScore >= boostMinScore
? Math.Min(100.0, baseScore + primaryBoost)
: baseScore;
return new
{
Item = item,
Score = Math.Min(100.0, CalculateItemRelevanceScore(query, item) + primaryBoost)
BaseScore = baseScore,
Score = finalScore,
SourceIndex = index
};
})
.OrderByDescending(x => x.Score)
.ThenByDescending(x => x.BaseScore)
.ThenBy(x => x.SourceIndex)
.ToList();
var secondaryScored = secondaryItems.Select(item =>
var secondaryScored = secondaryItems.Select((item, index) =>
{
var baseScore = CalculateItemRelevanceScore(query, item);
return new
{
Item = item,
Score = CalculateItemRelevanceScore(query, item)
BaseScore = baseScore,
Score = baseScore,
SourceIndex = index
};
})
.OrderByDescending(x => x.Score)
.ThenByDescending(x => x.BaseScore)
.ThenBy(x => x.SourceIndex)
.ToList();
var result = new List<Dictionary<string, object?>>(primaryScored.Count + secondaryScored.Count);
int primaryIdx = 0, secondaryIdx = 0;
while (primaryIdx < primaryScored.Count && secondaryIdx < secondaryScored.Count)
while (primaryIdx < primaryScored.Count || secondaryIdx < secondaryScored.Count)
{
if (primaryIdx >= primaryScored.Count)
{
result.Add(secondaryScored[secondaryIdx++].Item);
continue;
}
if (secondaryIdx >= secondaryScored.Count)
{
result.Add(primaryScored[primaryIdx++].Item);
continue;
}
var primaryCandidate = primaryScored[primaryIdx];
var secondaryCandidate = secondaryScored[secondaryIdx];
if (primaryCandidate.Score >= secondaryCandidate.Score)
if (primaryCandidate.Score > secondaryCandidate.Score)
{
result.Add(primaryScored[primaryIdx++].Item);
}
else if (secondaryCandidate.Score > primaryCandidate.Score)
{
result.Add(secondaryScored[secondaryIdx++].Item);
}
else if (primaryCandidate.BaseScore >= secondaryCandidate.BaseScore)
{
result.Add(primaryScored[primaryIdx++].Item);
}
@@ -877,31 +794,146 @@ public partial class JellyfinController
}
}
while (primaryIdx < primaryScored.Count)
{
result.Add(primaryScored[primaryIdx++].Item);
}
while (secondaryIdx < secondaryScored.Count)
{
result.Add(secondaryScored[secondaryIdx++].Item);
}
return result;
}
/// <summary>
/// Calculates query relevance using the product's per-type rules.
/// Calculates query relevance for a search item.
/// Title is primary; metadata context is secondary and down-weighted.
/// </summary>
private double CalculateItemRelevanceScore(string query, Dictionary<string, object?> item)
{
return GetItemType(item) switch
var title = GetItemName(item);
if (string.IsNullOrWhiteSpace(title))
{
"Audio" => CalculateSongRelevanceScore(query, item),
"MusicAlbum" => CalculateAlbumRelevanceScore(query, item),
"MusicArtist" => CalculateArtistRelevanceScore(query, item),
_ => CalculateArtistRelevanceScore(query, item)
};
return 0;
}
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(query, title);
var searchText = BuildItemSearchText(item, title);
if (string.Equals(searchText, title, StringComparison.OrdinalIgnoreCase))
{
return titleScore;
}
var metadataScore = FuzzyMatcher.CalculateSimilarityAggressive(query, searchText);
var weightedMetadataScore = metadataScore * 0.85;
var baseScore = Math.Max(titleScore, weightedMetadataScore);
return ApplyQueryCoverageAdjustment(query, title, searchText, baseScore);
}
private static double ApplyQueryCoverageAdjustment(string query, string title, string searchText, double baseScore)
{
var queryTokens = TokenizeForCoverage(query);
if (queryTokens.Count < 2)
{
return baseScore;
}
var titleCoverage = CalculateTokenCoverage(queryTokens, title);
var searchCoverage = string.Equals(searchText, title, StringComparison.OrdinalIgnoreCase)
? titleCoverage
: CalculateTokenCoverage(queryTokens, searchText);
var coverage = Math.Max(titleCoverage, searchCoverage);
if (coverage >= 0.999)
{
return Math.Min(100.0, baseScore + 3.0);
}
if (coverage >= 0.8)
{
return baseScore * 0.9;
}
if (coverage >= 0.6)
{
return baseScore * 0.72;
}
return baseScore * 0.5;
}
private static double CalculateTokenCoverage(IReadOnlyList<string> queryTokens, string target)
{
var targetTokens = TokenizeForCoverage(target);
if (queryTokens.Count == 0 || targetTokens.Count == 0)
{
return 0;
}
var matched = 0;
foreach (var queryToken in queryTokens)
{
if (targetTokens.Any(targetToken => IsTokenMatch(queryToken, targetToken)))
{
matched++;
}
}
return (double)matched / queryTokens.Count;
}
private static bool IsTokenMatch(string queryToken, string targetToken)
{
return queryToken.Equals(targetToken, StringComparison.Ordinal) ||
queryToken.StartsWith(targetToken, StringComparison.Ordinal) ||
targetToken.StartsWith(queryToken, StringComparison.Ordinal);
}
private static IReadOnlyList<string> TokenizeForCoverage(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return Array.Empty<string>();
}
var normalized = NormalizeForCoverage(text);
var allTokens = normalized
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Distinct(StringComparer.Ordinal)
.ToList();
if (allTokens.Count == 0)
{
return Array.Empty<string>();
}
var significant = allTokens
.Where(token => token.Length >= 2 && !SearchStopWords.Contains(token))
.ToList();
return significant.Count > 0
? significant
: allTokens.Where(token => token.Length >= 2).ToList();
}
private static string NormalizeForCoverage(string text)
{
var normalized = RemoveDiacritics(text).ToLowerInvariant();
normalized = normalized.Replace('&', ' ');
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", " ");
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
return normalized;
}
private static string RemoveDiacritics(string text)
{
var normalized = text.Normalize(NormalizationForm.FormD);
var chars = new List<char>(normalized.Length);
foreach (var c in normalized)
{
if (System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c) != System.Globalization.UnicodeCategory.NonSpacingMark)
{
chars.Add(c);
}
}
return new string(chars.ToArray()).Normalize(NormalizationForm.FormC);
}
/// <summary>
@@ -912,90 +944,52 @@ public partial class JellyfinController
return GetItemStringValue(item, "Name");
}
private double CalculateSongRelevanceScore(string query, Dictionary<string, object?> item)
private string BuildItemSearchText(Dictionary<string, object?> item, string title)
{
var title = GetItemName(item);
var artistText = GetSongArtistText(item);
return CalculateBestFuzzyScore(query, title, CombineSearchFields(title, artistText));
}
var parts = new List<string>();
private double CalculateAlbumRelevanceScore(string query, Dictionary<string, object?> item)
{
var albumName = GetItemName(item);
var artistText = GetAlbumArtistText(item);
return CalculateBestFuzzyScore(query, albumName, CombineSearchFields(albumName, artistText));
}
AddDistinct(parts, title);
AddDistinct(parts, GetItemStringValue(item, "SortName"));
AddDistinct(parts, GetItemStringValue(item, "AlbumArtist"));
AddDistinct(parts, GetItemStringValue(item, "Artist"));
AddDistinct(parts, GetItemStringValue(item, "Album"));
private double CalculateArtistRelevanceScore(string query, Dictionary<string, object?> item)
{
var artistName = GetItemName(item);
if (string.IsNullOrWhiteSpace(artistName))
foreach (var artist in GetItemStringList(item, "Artists").Take(3))
{
return 0;
AddDistinct(parts, artist);
}
return FuzzyMatcher.CalculateSimilarityAggressive(query, artistName);
return string.Join(" ", parts);
}
private double CalculateBestFuzzyScore(string query, params string?[] candidates)
private static readonly HashSet<string> SearchStopWords = new(StringComparer.Ordinal)
{
var best = 0;
"a",
"an",
"and",
"at",
"for",
"in",
"of",
"on",
"the",
"to",
"with",
"feat",
"ft"
};
foreach (var candidate in candidates)
private static void AddDistinct(List<string> values, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}
best = Math.Max(best, FuzzyMatcher.CalculateSimilarityAggressive(query, candidate));
return;
}
return best;
}
private static string CombineSearchFields(params string?[] fields)
{
return string.Join(" ", fields.Where(field => !string.IsNullOrWhiteSpace(field)));
}
private string GetItemType(Dictionary<string, object?> item)
{
return GetItemStringValue(item, "Type");
}
private string GetSongArtistText(Dictionary<string, object?> item)
{
var artists = GetItemStringList(item, "Artists").Take(3).ToList();
if (artists.Count > 0)
if (!values.Contains(value, StringComparer.OrdinalIgnoreCase))
{
return string.Join(" ", artists);
values.Add(value);
}
var albumArtist = GetItemStringValue(item, "AlbumArtist");
if (!string.IsNullOrWhiteSpace(albumArtist))
{
return albumArtist;
}
return GetItemStringValue(item, "Artist");
}
private string GetAlbumArtistText(Dictionary<string, object?> item)
{
var albumArtist = GetItemStringValue(item, "AlbumArtist");
if (!string.IsNullOrWhiteSpace(albumArtist))
{
return albumArtist;
}
var artists = GetItemStringList(item, "Artists").Take(3).ToList();
if (artists.Count > 0)
{
return string.Join(" ", artists);
}
return GetItemStringValue(item, "Artist");
}
private string GetItemStringValue(Dictionary<string, object?> item, string key)
@@ -9,8 +9,6 @@ namespace allstarr.Controllers;
public partial class JellyfinController
{
private static readonly string[] KeptAudioExtensions = [".flac", ".mp3", ".m4a", ".opus"];
#region Spotify Playlist Injection
/// <summary>
@@ -65,33 +63,11 @@ public partial class JellyfinController
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
var jellyfinPlaylistChanged = cachedJellyfinSignature != currentJellyfinSignature;
var requestNeedsGenreMetadata = RequestIncludesField("Genres");
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
if (cachedItems != null && cachedItems.Count > 0 &&
InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(cachedItems))
{
_logger.LogWarning(
"Ignoring Redis playlist cache for {Playlist}: found synthesized local items that should have remained raw Jellyfin objects",
spotifyPlaylistName);
await _cache.DeleteAsync(cacheKey);
cachedItems = null;
}
if (cachedItems != null && cachedItems.Count > 0 &&
requestNeedsGenreMetadata &&
InjectedPlaylistItemHelper.ContainsLocalItemsMissingGenreMetadata(cachedItems))
{
_logger.LogWarning(
"Ignoring Redis playlist cache for {Playlist}: local items are missing genre metadata required by this request",
spotifyPlaylistName);
await _cache.DeleteAsync(cacheKey);
cachedItems = null;
}
if (cachedItems != null && cachedItems.Count > 0 && !jellyfinPlaylistChanged)
{
_logger.LogDebug("✅ Loaded {Count} playlist items from Redis cache for {Playlist} (Jellyfin unchanged)",
@@ -113,26 +89,7 @@ public partial class JellyfinController
// Check file cache as fallback
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
if (fileItems != null && fileItems.Count > 0 &&
InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(fileItems))
{
_logger.LogWarning(
"Ignoring file playlist cache for {Playlist}: found synthesized local items that should have remained raw Jellyfin objects",
spotifyPlaylistName);
fileItems = null;
}
if (fileItems != null && fileItems.Count > 0 &&
requestNeedsGenreMetadata &&
InjectedPlaylistItemHelper.ContainsLocalItemsMissingGenreMetadata(fileItems))
{
_logger.LogWarning(
"Ignoring file playlist cache for {Playlist}: local items are missing genre metadata required by this request",
spotifyPlaylistName);
fileItems = null;
}
if (fileItems != null && fileItems.Count > 0 && !jellyfinPlaylistChanged)
if (fileItems != null && fileItems.Count > 0)
{
_logger.LogDebug("✅ Loaded {Count} playlist items from file cache for {Playlist}",
fileItems.Count, spotifyPlaylistName);
@@ -251,7 +208,6 @@ public partial class JellyfinController
var usedJellyfinItems = new HashSet<string>();
var localUsedCount = 0;
var externalUsedCount = 0;
var unresolvedLocalCount = 0;
_logger.LogDebug("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
@@ -327,26 +283,9 @@ public partial class JellyfinController
}
else
{
if (JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(
matched.MatchedSong,
out var cachedLocalItem))
{
ProviderIdsEnricher.EnsureSpotifyProviderIds(cachedLocalItem, spotifyTrack.SpotifyId,
spotifyTrack.AlbumId);
ApplySpotifyAddedAtDateCreated(cachedLocalItem, spotifyTrack.AddedAt);
finalItems.Add(cachedLocalItem);
localUsedCount++;
_logger.LogDebug(
"✅ Position #{Pos}: '{Title}' → LOCAL from cached raw snapshot (ID: {Id})",
spotifyTrack.Position, spotifyTrack.Title, matched.MatchedSong.Id);
continue;
}
_logger.LogWarning(
"⚠️ Position #{Pos}: '{Title}' marked as LOCAL but not found in Jellyfin items (ID: {Id}); refusing to synthesize a replacement local object",
"⚠️ Position #{Pos}: '{Title}' marked as LOCAL but not found in Jellyfin items (ID: {Id})",
spotifyTrack.Position, spotifyTrack.Title, matched.MatchedSong.Id);
unresolvedLocalCount++;
continue;
}
}
@@ -377,24 +316,6 @@ public partial class JellyfinController
_logger.LogDebug("🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
if (unresolvedLocalCount > 0)
{
_logger.LogWarning(
"Aborting ordered injection for {Playlist}: {Count} local tracks could not be preserved from Jellyfin and would have been rewritten",
spotifyPlaylistName, unresolvedLocalCount);
await _cache.DeleteAsync(cacheKey);
return null;
}
if (InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(finalItems))
{
_logger.LogWarning(
"Aborting ordered injection for {Playlist}: built playlist still contains synthesized local items",
spotifyPlaylistName);
await _cache.DeleteAsync(cacheKey);
return null;
}
// Save to file cache for persistence across restarts
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
@@ -426,30 +347,6 @@ public partial class JellyfinController
item["DateCreated"] = addedAt.Value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ");
}
private bool RequestIncludesField(string fieldName)
{
if (!Request.Query.TryGetValue("Fields", out var rawValues) || rawValues.Count == 0)
{
return false;
}
foreach (var rawValue in rawValues)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
continue;
}
var fields = rawValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (fields.Any(field => string.Equals(field, fieldName, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}
return false;
}
/// <summary>
/// <summary>
/// Copies an external track to the kept folder when favorited.
@@ -482,13 +379,10 @@ public partial class JellyfinController
if (Directory.Exists(keptAlbumPath))
{
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
var existingAudioFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*")
.Where(IsKeptAudioFile)
.ToArray();
if (existingAudioFiles.Length > 0)
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
if (existingFiles.Length > 0)
{
_logger.LogInformation("Track already exists in kept folder: {Path}", existingAudioFiles[0]);
await EnsureLyricsSidecarForKeptTrackAsync(existingAudioFiles[0], song, provider, externalId);
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
// Mark as favorited even if we didn't download it
await MarkTrackAsFavoritedAsync(itemId, song);
return;
@@ -577,7 +471,6 @@ 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;
}
@@ -595,7 +488,6 @@ 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;
}
@@ -657,8 +549,6 @@ public partial class JellyfinController
}
}
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
// Mark as favorited in persistent storage
await MarkTrackAsFavoritedAsync(itemId, song);
}
@@ -733,18 +623,8 @@ public partial class JellyfinController
}
#region Persistent Favorites Tracking
/// <summary>
/// Information about a favorited track for persistent storage.
/// </summary>
private class FavoriteTrackInfo
{
public string ItemId { get; set; } = "";
public string Title { get; set; } = "";
public string Artist { get; set; } = "";
public string Album { get; set; } = "";
public DateTime FavoritedAt { get; set; }
}
private readonly string _favoritesFilePath = "/app/cache/favorites.json";
/// <summary>
/// Checks if a track is already favorited (persistent across restarts).
@@ -753,7 +633,13 @@ public partial class JellyfinController
{
try
{
return await _cache.ExistsAsync($"favorites:{itemId}");
if (!System.IO.File.Exists(_favoritesFilePath))
return false;
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
return favorites.ContainsKey(itemId);
}
catch (Exception ex)
{
@@ -769,16 +655,29 @@ public partial class JellyfinController
{
try
{
var info = new FavoriteTrackInfo
var favorites = new Dictionary<string, FavoriteTrackInfo>();
if (System.IO.File.Exists(_favoritesFilePath))
{
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
}
favorites[itemId] = new FavoriteTrackInfo
{
ItemId = itemId,
Title = song.Title ?? "Unknown Title",
Artist = song.Artist ?? "Unknown Artist",
Album = song.Album ?? "Unknown Album",
Title = song.Title,
Artist = song.Artist,
Album = song.Album,
FavoritedAt = DateTime.UtcNow
};
await _cache.SetAsync($"favorites:{itemId}", info);
// Ensure cache directory exists
Directory.CreateDirectory(Path.GetDirectoryName(_favoritesFilePath)!);
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
_logger.LogDebug("Marked track as favorited: {ItemId}", itemId);
}
catch (Exception ex)
@@ -794,9 +693,17 @@ public partial class JellyfinController
{
try
{
if (await _cache.ExistsAsync($"favorites:{itemId}"))
if (!System.IO.File.Exists(_favoritesFilePath))
return;
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
if (favorites.Remove(itemId))
{
await _cache.DeleteAsync($"favorites:{itemId}");
var updatedJson =
JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
_logger.LogDebug("Removed track from favorites: {ItemId}", itemId);
}
}
@@ -813,8 +720,24 @@ public partial class JellyfinController
{
try
{
var deletionTime = DateTime.UtcNow.AddHours(24);
await _cache.SetStringAsync($"pending_deletion:{itemId}", deletionTime.ToString("O"));
var deletionFilePath = "/app/cache/pending_deletions.json";
var pendingDeletions = new Dictionary<string, DateTime>();
if (System.IO.File.Exists(deletionFilePath))
{
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
}
// Mark for deletion 24 hours from now
pendingDeletions[itemId] = DateTime.UtcNow.AddHours(24);
// Ensure cache directory exists
Directory.CreateDirectory(Path.GetDirectoryName(deletionFilePath)!);
var updatedJson =
JsonSerializer.Serialize(pendingDeletions, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
// Also remove from favorites immediately
await UnmarkTrackAsFavoritedAsync(itemId);
@@ -827,6 +750,18 @@ public partial class JellyfinController
}
}
/// <summary>
/// Information about a favorited track for persistent storage.
/// </summary>
private class FavoriteTrackInfo
{
public string ItemId { get; set; } = "";
public string Title { get; set; } = "";
public string Artist { get; set; } = "";
public string Album { get; set; } = "";
public DateTime FavoritedAt { get; set; }
}
/// <summary>
/// Processes pending deletions (called by cleanup service).
/// </summary>
@@ -834,29 +769,31 @@ public partial class JellyfinController
{
try
{
var deletionKeys = _cache.GetKeysByPattern("pending_deletion:*").ToList();
if (deletionKeys.Count == 0) return;
var deletionFilePath = "/app/cache/pending_deletions.json";
if (!System.IO.File.Exists(deletionFilePath))
return;
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
var pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
var now = DateTime.UtcNow;
int deletedCount = 0;
var toDelete = pendingDeletions.Where(kvp => kvp.Value <= now).ToList();
var remaining = pendingDeletions.Where(kvp => kvp.Value > now)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
foreach (var key in deletionKeys)
foreach (var (itemId, _) in toDelete)
{
var timeStr = await _cache.GetStringAsync(key);
if (string.IsNullOrEmpty(timeStr)) continue;
if (DateTime.TryParse(timeStr, out var scheduleTime) && scheduleTime <= now)
{
var itemId = key.Substring("pending_deletion:".Length);
await ActuallyDeleteTrackAsync(itemId);
await _cache.DeleteAsync(key);
deletedCount++;
}
await ActuallyDeleteTrackAsync(itemId);
}
if (deletedCount > 0)
if (toDelete.Count > 0)
{
_logger.LogDebug("Processed {Count} pending deletions", deletedCount);
// Update pending deletions file
var updatedJson =
JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
_logger.LogDebug("Processed {Count} pending deletions", toDelete.Count);
}
}
catch (Exception ex)
@@ -912,33 +849,6 @@ 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>
+38 -212
View File
@@ -47,7 +47,6 @@ public partial class JellyfinController : ControllerBase
private readonly LyricsPlusService? _lyricsPlusService;
private readonly LrclibService? _lrclibService;
private readonly LyricsOrchestrator? _lyricsOrchestrator;
private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService;
private readonly ScrobblingOrchestrator? _scrobblingOrchestrator;
private readonly ScrobblingHelper? _scrobblingHelper;
private readonly OdesliService _odesliService;
@@ -78,7 +77,6 @@ public partial class JellyfinController : ControllerBase
LyricsPlusService? lyricsPlusService = null,
LrclibService? lrclibService = null,
LyricsOrchestrator? lyricsOrchestrator = null,
IKeptLyricsSidecarService? keptLyricsSidecarService = null,
ScrobblingOrchestrator? scrobblingOrchestrator = null,
ScrobblingHelper? scrobblingHelper = null)
{
@@ -100,7 +98,6 @@ public partial class JellyfinController : ControllerBase
_lyricsPlusService = lyricsPlusService;
_lrclibService = lrclibService;
_lyricsOrchestrator = lyricsOrchestrator;
_keptLyricsSidecarService = keptLyricsSidecarService;
_scrobblingOrchestrator = scrobblingOrchestrator;
_scrobblingHelper = scrobblingHelper;
_odesliService = odesliService;
@@ -204,48 +201,26 @@ public partial class JellyfinController : ControllerBase
/// </summary>
private async Task<IActionResult> GetExternalChildItems(string provider, string type, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
{
if (IsFavoritesOnlyRequest())
{
_logger.LogDebug(
"Suppressing external child items for favorites-only request: provider={Provider}, type={Type}, externalId={ExternalId}",
provider,
type,
externalId);
return CreateEmptyItemsResponse(GetRequestedStartIndex());
}
var itemTypes = ParseItemTypes(includeItemTypes);
var itemTypesUnspecified = itemTypes == null || itemTypes.Length == 0;
_logger.LogDebug("GetExternalChildItems: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}",
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
// Albums are track containers in Jellyfin clients; when ParentId points to an album,
// return tracks even if IncludeItemTypes is omitted.
if (type == "album" && (itemTypesUnspecified || itemTypes!.Contains("Audio", StringComparer.OrdinalIgnoreCase)))
// Check if asking for audio (album tracks or artist songs)
if (itemTypes?.Contains("Audio") == true)
{
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
var album = await _metadataService.GetAlbumAsync(provider, externalId, cancellationToken);
if (album == null)
if (type == "album")
{
return _responseBuilder.CreateError(404, "Album not found");
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
var album = await _metadataService.GetAlbumAsync(provider, externalId, cancellationToken);
if (album == null)
{
return _responseBuilder.CreateError(404, "Album not found");
}
return _responseBuilder.CreateItemsResponse(album.Songs);
}
var sortedAndPagedSongs = ApplySongSortAndPagingForCurrentRequest(album.Songs, out var totalRecordCount, out var startIndex);
var items = sortedAndPagedSongs.Select(_responseBuilder.ConvertSongToJellyfinItem).ToList();
return _responseBuilder.CreateJsonResponse(new
{
Items = items,
TotalRecordCount = totalRecordCount,
StartIndex = startIndex
});
}
// Check if asking for audio (artist songs)
if (itemTypes?.Contains("Audio", StringComparer.OrdinalIgnoreCase) == true)
{
if (type == "artist")
else if (type == "artist")
{
// For artist + Audio, fetch top tracks from the artist endpoint
_logger.LogDebug("Fetching artist tracks for {Provider}/{ExternalId}", provider, externalId);
@@ -263,7 +238,7 @@ public partial class JellyfinController : ControllerBase
}
// Check if asking for albums (artist albums)
if (itemTypes?.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) == true || itemTypesUnspecified)
if (itemTypes?.Contains("MusicAlbum") == true || itemTypes == null)
{
if (type == "artist")
{
@@ -292,85 +267,6 @@ public partial class JellyfinController : ControllerBase
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
return _responseBuilder.CreateItemsResponse(new List<Song>());
}
private int GetRequestedStartIndex()
{
return int.TryParse(Request.Query["StartIndex"], out var startIndex) && startIndex > 0
? startIndex
: 0;
}
private List<Song> ApplySongSortAndPagingForCurrentRequest(IReadOnlyCollection<Song> songs, out int totalRecordCount, out int startIndex)
{
var sortBy = Request.Query["SortBy"].ToString();
var sortOrder = Request.Query["SortOrder"].ToString();
var descending = sortOrder.Equals("Descending", StringComparison.OrdinalIgnoreCase);
var sortFields = ParseSortFields(sortBy);
var sortedSongs = songs.ToList();
sortedSongs.Sort((left, right) => CompareSongs(left, right, sortFields, descending));
totalRecordCount = sortedSongs.Count;
startIndex = 0;
if (int.TryParse(Request.Query["StartIndex"], out var parsedStartIndex) && parsedStartIndex > 0)
{
startIndex = parsedStartIndex;
}
if (int.TryParse(Request.Query["Limit"], out var parsedLimit) && parsedLimit > 0)
{
return sortedSongs.Skip(startIndex).Take(parsedLimit).ToList();
}
return sortedSongs.Skip(startIndex).ToList();
}
private static int CompareSongs(Song left, Song right, IReadOnlyList<string> sortFields, bool descending)
{
var effectiveSortFields = sortFields.Count > 0
? sortFields
: new[] { "ParentIndexNumber", "IndexNumber", "SortName" };
foreach (var field in effectiveSortFields)
{
var comparison = CompareSongsByField(left, right, field);
if (comparison == 0)
{
continue;
}
return descending ? -comparison : comparison;
}
return string.Compare(left.Title, right.Title, StringComparison.OrdinalIgnoreCase);
}
private static int CompareSongsByField(Song left, Song right, string field)
{
return field.ToLowerInvariant() switch
{
"parentindexnumber" => Nullable.Compare(left.DiscNumber, right.DiscNumber),
"indexnumber" => Nullable.Compare(left.Track, right.Track),
"sortname" => string.Compare(left.Title, right.Title, StringComparison.OrdinalIgnoreCase),
"name" => string.Compare(left.Title, right.Title, StringComparison.OrdinalIgnoreCase),
"datecreated" => Nullable.Compare(left.Year, right.Year),
"productionyear" => Nullable.Compare(left.Year, right.Year),
_ => 0
};
}
private static List<string> ParseSortFields(string sortBy)
{
if (string.IsNullOrWhiteSpace(sortBy))
{
return new List<string>();
}
return sortBy
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(field => !string.IsNullOrWhiteSpace(field))
.ToList();
}
private async Task<IActionResult> GetCuratorPlaylists(string provider, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
{
var itemTypes = ParseItemTypes(includeItemTypes);
@@ -613,8 +509,7 @@ public partial class JellyfinController : ControllerBase
string imageType,
int imageIndex = 0,
[FromQuery] int? maxWidth = null,
[FromQuery] int? maxHeight = null,
[FromQuery(Name = "tag")] string? tag = null)
[FromQuery] int? maxHeight = null)
{
if (string.IsNullOrWhiteSpace(itemId))
{
@@ -631,20 +526,12 @@ public partial class JellyfinController : ControllerBase
if (!isExternal)
{
var effectiveImageTag = tag;
if (string.IsNullOrWhiteSpace(effectiveImageTag) &&
_spotifySettings.IsSpotifyPlaylist(itemId))
{
effectiveImageTag = await ResolveCurrentSpotifyPlaylistImageTagAsync(itemId, imageType);
}
// Proxy image from Jellyfin for local content
var (imageBytes, contentType) = await _proxyService.GetImageAsync(
itemId,
imageType,
maxWidth,
maxHeight,
effectiveImageTag);
maxHeight);
if (imageBytes == null || contentType == null)
{
@@ -681,7 +568,7 @@ public partial class JellyfinController : ControllerBase
if (fallbackBytes != null && fallbackContentType != null)
{
return CreateConditionalImageResponse(fallbackBytes, fallbackContentType);
return File(fallbackBytes, fallbackContentType);
}
}
}
@@ -690,16 +577,7 @@ public partial class JellyfinController : ControllerBase
return await GetPlaceholderImageAsync();
}
return CreateConditionalImageResponse(imageBytes, contentType);
}
// Check Redis cache for previously fetched external image
var imageCacheKey = CacheKeyBuilder.BuildExternalImageKey(provider!, type!, externalId!);
var cachedImageBytes = await _cache.GetAsync<byte[]>(imageCacheKey);
if (cachedImageBytes != null)
{
_logger.LogDebug("Cache hit for external {Type} image: {Provider}/{ExternalId}", type, provider, externalId);
return CreateConditionalImageResponse(cachedImageBytes, "image/jpeg");
return File(imageBytes, contentType);
}
// Get external cover art URL
@@ -765,12 +643,9 @@ public partial class JellyfinController : ControllerBase
return await GetPlaceholderImageAsync();
}
// Cache the fetched image bytes in Redis for future requests
await _cache.SetAsync(imageCacheKey, imageBytes, CacheExtensions.ProxyImagesTTL);
_logger.LogDebug("Successfully fetched and cached external image from host {Host}, size: {Size} bytes",
_logger.LogDebug("Successfully fetched external image from host {Host}, size: {Size} bytes",
safeCoverUri.Host, imageBytes.Length);
return CreateConditionalImageResponse(imageBytes, "image/jpeg");
return File(imageBytes, "image/jpeg");
}
catch (Exception ex)
{
@@ -792,7 +667,7 @@ public partial class JellyfinController : ControllerBase
if (System.IO.File.Exists(placeholderPath))
{
var imageBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath);
return CreateConditionalImageResponse(imageBytes, "image/png");
return File(imageBytes, "image/png");
}
// Fallback: Return a 1x1 transparent PNG as minimal placeholder
@@ -800,54 +675,7 @@ public partial class JellyfinController : ControllerBase
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
);
return CreateConditionalImageResponse(transparentPng, "image/png");
}
private IActionResult CreateConditionalImageResponse(byte[] imageBytes, string contentType)
{
var etag = ImageConditionalRequestHelper.ComputeStrongETag(imageBytes);
Response.Headers["ETag"] = etag;
if (ImageConditionalRequestHelper.MatchesIfNoneMatch(Request.Headers, etag))
{
return StatusCode(StatusCodes.Status304NotModified);
}
return File(imageBytes, contentType);
}
private async Task<string?> ResolveCurrentSpotifyPlaylistImageTagAsync(string itemId, string imageType)
{
try
{
var (itemResult, statusCode) = await _proxyService.GetJsonAsyncInternal($"Items/{itemId}");
if (itemResult == null || statusCode != 200)
{
return null;
}
using var itemDocument = itemResult;
var imageTag = ExtractImageTag(itemDocument.RootElement, imageType);
if (!string.IsNullOrWhiteSpace(imageTag))
{
_logger.LogDebug(
"Resolved current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}: {ImageTag}",
imageType,
itemId,
imageTag);
}
return imageTag;
}
catch (Exception ex)
{
_logger.LogDebug(ex,
"Failed to resolve current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}",
imageType,
itemId);
return null;
}
return File(transparentPng, "image/png");
}
#endregion
@@ -1349,37 +1177,33 @@ public partial class JellyfinController : ControllerBase
});
}
var playlistItemsRequestId = GetExactPlaylistItemsRequestId(path);
if (!string.IsNullOrEmpty(playlistItemsRequestId))
// Intercept Spotify playlist requests by ID
if (_spotifySettings.Enabled &&
path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) &&
path.Contains("/items", StringComparison.OrdinalIgnoreCase))
{
if (_spotifySettings.Enabled)
// Extract playlist ID from path: playlists/{id}/items
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2 && parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase))
{
var playlistId = parts[1];
_logger.LogDebug("=== PLAYLIST REQUEST ===");
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId));
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistId));
// Check if this playlist ID is configured for Spotify injection
if (_spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId))
if (_spotifySettings.IsSpotifyPlaylist(playlistId))
{
_logger.LogInformation("========================================");
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
_logger.LogInformation("========================================");
return await GetPlaylistTracks(playlistItemsRequestId);
return await GetPlaylistTracks(playlistId);
}
}
var playlistItemsPath = path;
if (Request.QueryString.HasValue)
{
playlistItemsPath = $"{playlistItemsPath}{Request.QueryString.Value}";
}
_logger.LogDebug("Using transparent Jellyfin passthrough for non-injected playlist {PlaylistId}",
playlistItemsRequestId);
return await ProxyJsonPassthroughAsync(playlistItemsPath);
}
// Handle non-JSON responses (images, robots.txt, etc.)
@@ -1550,7 +1374,9 @@ public partial class JellyfinController : ControllerBase
// Modify response if it contains Spotify playlists to update ChildCount
// Only check for Items if the response is an object (not a string or array)
if (ShouldProcessSpotifyPlaylistCounts(result, Request.Query["IncludeItemTypes"].ToString()))
if (_spotifySettings.Enabled &&
result.RootElement.ValueKind == JsonValueKind.Object &&
result.RootElement.TryGetProperty("Items", out var items))
{
_logger.LogDebug("Response has Items property, checking for Spotify playlists to update counts");
result = await UpdateSpotifyPlaylistCounts(result);
+6 -6
View File
@@ -1155,7 +1155,7 @@ public class PlaylistController : ControllerBase
public async Task<IActionResult> ClearPlaylistCache(string name)
{
var decodedName = Uri.UnescapeDataString(name);
_logger.LogInformation("Rebuild from scratch triggered for playlist: {Name}", decodedName);
_logger.LogInformation("Rebuild from scratch triggered for playlist: {Name} (same as cron job)", decodedName);
if (_matchingService == null)
{
@@ -1164,7 +1164,7 @@ public class PlaylistController : ControllerBase
try
{
// Use the unified per-playlist rebuild method (same workflow as per-playlist cron rebuilds)
// Use the unified rebuild method (same as cron job and "Rebuild All Remote")
await _matchingService.TriggerRebuildForPlaylistAsync(decodedName);
// Invalidate playlist summary cache
@@ -1172,7 +1172,7 @@ public class PlaylistController : ControllerBase
return Ok(new
{
message = $"Rebuilding {decodedName} from scratch",
message = $"Rebuilding {decodedName} from scratch (same as cron job)",
timestamp = DateTime.UtcNow
});
}
@@ -1768,12 +1768,12 @@ public class PlaylistController : ControllerBase
/// <summary>
/// Rebuild all playlists from scratch (clear cache, fetch fresh data, re-match).
/// This is a manual bulk action across all playlists - used by "Rebuild All Remote" button.
/// This is the same process as the scheduled cron job - used by "Rebuild All Remote" button.
/// </summary>
[HttpPost("playlists/rebuild-all")]
public async Task<IActionResult> RebuildAllPlaylists()
{
_logger.LogInformation("Manual full rebuild triggered for all playlists");
_logger.LogInformation("Manual full rebuild triggered for all playlists (same as cron job)");
if (_matchingService == null)
{
@@ -1783,7 +1783,7 @@ public class PlaylistController : ControllerBase
try
{
await _matchingService.TriggerRebuildAllAsync();
return Ok(new { message = "Full rebuild triggered for all playlists", timestamp = DateTime.UtcNow });
return Ok(new { message = "Full rebuild triggered for all playlists (same as cron job)", timestamp = DateTime.UtcNow });
}
catch (Exception ex)
{
@@ -51,7 +51,6 @@ public class ScrobblingAdminController : ControllerBase
{
Enabled = _settings.Enabled,
LocalTracksEnabled = _settings.LocalTracksEnabled,
SyntheticLocalPlayedSignalEnabled = _settings.SyntheticLocalPlayedSignalEnabled,
LastFm = new
{
Enabled = _settings.LastFm.Enabled,
+2 -9
View File
@@ -161,15 +161,8 @@ public class SubsonicController : ControllerBase
try
{
var downloadStream = await _downloadService.DownloadAndStreamAsync(provider!, externalId!, cancellationToken: HttpContext.RequestAborted);
var contentType = "audio/mpeg";
if (downloadStream is FileStream fs)
{
contentType = GetContentType(fs.Name);
}
return File(downloadStream, contentType, enableRangeProcessing: true);
var downloadStream = await _downloadService.DownloadAndStreamAsync(provider!, externalId!, HttpContext.RequestAborted);
return File(downloadStream, "audio/mpeg", enableRangeProcessing: true);
}
catch (Exception ex)
{
@@ -1,37 +0,0 @@
using allstarr.Services.Common;
namespace allstarr.Middleware;
/// <summary>
/// Short-circuits common internet scanner paths before they reach the Jellyfin proxy.
/// </summary>
public class BotProbeBlockMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<BotProbeBlockMiddleware> _logger;
public BotProbeBlockMiddleware(
RequestDelegate next,
ILogger<BotProbeBlockMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var requestPath = context.Request.Path.Value;
if (!BotProbeDetector.IsHighConfidenceProbePath(requestPath))
{
await _next(context);
return;
}
_logger.LogDebug("Short-circuited likely bot probe from {RemoteIp}: {Method} {Path}",
context.Connection.RemoteIpAddress?.ToString() ?? "(null)",
context.Request.Method,
requestPath);
context.Response.StatusCode = StatusCodes.Status404NotFound;
}
}
@@ -24,14 +24,11 @@ public class RequestLoggingMiddleware
// Log initialization status
var initialValue = _configuration.GetValue<bool>("Debug:LogAllRequests");
var initialRedactionValue = _configuration.GetValue<bool>("Debug:RedactSensitiveRequestValues", false);
_logger.LogWarning("🔍 RequestLoggingMiddleware initialized - LogAllRequests={LogAllRequests}", initialValue);
if (initialValue)
{
_logger.LogWarning(
"🔍 Request logging ENABLED - all HTTP requests will be logged (RedactSensitiveRequestValues={Redact})",
initialRedactionValue);
_logger.LogWarning("🔍 Request logging ENABLED - all HTTP requests will be logged");
}
else
{
@@ -43,7 +40,6 @@ public class RequestLoggingMiddleware
{
// Check configuration on every request to allow dynamic toggling
var logAllRequests = _configuration.GetValue<bool>("Debug:LogAllRequests");
var redactSensitiveValues = _configuration.GetValue<bool>("Debug:RedactSensitiveRequestValues", false);
if (!logAllRequests)
{
@@ -53,13 +49,11 @@ public class RequestLoggingMiddleware
var stopwatch = Stopwatch.StartNew();
var request = context.Request;
var queryStringForLog = redactSensitiveValues
? BuildMaskedQueryString(request.QueryString.Value)
: request.QueryString.Value ?? string.Empty;
var maskedQueryString = BuildMaskedQueryString(request.QueryString.Value);
// Log request details
var requestLog = new StringBuilder();
requestLog.AppendLine($"📥 HTTP {request.Method} {request.Path}{queryStringForLog}");
requestLog.AppendLine($"📥 HTTP {request.Method} {request.Path}{maskedQueryString}");
requestLog.AppendLine($" Host: {request.Host}");
requestLog.AppendLine($" Content-Type: {request.ContentType ?? "(none)"}");
requestLog.AppendLine($" Content-Length: {request.ContentLength?.ToString() ?? "(none)"}");
@@ -71,18 +65,15 @@ public class RequestLoggingMiddleware
}
if (request.Headers.ContainsKey("X-Emby-Authorization"))
{
var value = request.Headers["X-Emby-Authorization"].ToString();
requestLog.AppendLine($" X-Emby-Authorization: {(redactSensitiveValues ? MaskAuthHeader(value) : value)}");
requestLog.AppendLine($" X-Emby-Authorization: {MaskAuthHeader(request.Headers["X-Emby-Authorization"]!)}");
}
if (request.Headers.ContainsKey("Authorization"))
{
var value = request.Headers["Authorization"].ToString();
requestLog.AppendLine($" Authorization: {(redactSensitiveValues ? MaskAuthHeader(value) : value)}");
requestLog.AppendLine($" Authorization: {MaskAuthHeader(request.Headers["Authorization"]!)}");
}
if (request.Headers.ContainsKey("X-Emby-Token"))
{
var value = request.Headers["X-Emby-Token"].ToString();
requestLog.AppendLine($" X-Emby-Token: {(redactSensitiveValues ? "***" : value)}");
requestLog.AppendLine($" X-Emby-Token: ***");
}
if (request.Headers.ContainsKey("X-Emby-Device-Id"))
{
@@ -94,6 +94,10 @@ public class WebSocketProxyMiddleware
_logger.LogDebug("🔍 WEBSOCKET: Client WebSocket for device {DeviceId}", deviceId);
}
// Accept the WebSocket connection from the client
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
_logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted");
// Build Jellyfin WebSocket URL
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://";
@@ -120,11 +124,6 @@ public class WebSocketProxyMiddleware
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString());
_logger.LogDebug("🔑 WEBSOCKET: Forwarded X-Emby-Authorization header");
}
else if (context.Request.Headers.TryGetValue("X-Emby-Token", out var tokenHeader))
{
serverWebSocket.Options.SetRequestHeader("X-Emby-Token", tokenHeader.ToString());
_logger.LogDebug("🔑 WEBSOCKET: Forwarded X-Emby-Token header");
}
else if (context.Request.Headers.TryGetValue("Authorization", out var authHeader2))
{
var authValue = authHeader2.ToString();
@@ -147,16 +146,6 @@ public class WebSocketProxyMiddleware
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
// Only accept the client socket after upstream auth/handshake succeeds.
// This ensures auth failures surface as HTTP status (401/403) instead of misleading 101 upgrades.
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
_logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted");
if (!string.IsNullOrEmpty(deviceId))
{
await _sessionManager.RegisterProxiedWebSocketAsync(deviceId);
}
// Start bidirectional proxying
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
@@ -168,25 +157,10 @@ public class WebSocketProxyMiddleware
}
catch (WebSocketException wsEx)
{
var isAuthFailure =
wsEx.Message.Contains("403", StringComparison.OrdinalIgnoreCase) ||
wsEx.Message.Contains("401", StringComparison.OrdinalIgnoreCase) ||
wsEx.Message.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase) ||
wsEx.Message.Contains("Forbidden", StringComparison.OrdinalIgnoreCase);
if (isAuthFailure)
// 403 is expected when tokens expire or session ends - don't spam logs
if (wsEx.Message.Contains("403"))
{
_logger.LogWarning("WEBSOCKET: Connection rejected by Jellyfin auth (token expired or session ended)");
if (!context.Response.HasStarted)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsJsonAsync(new
{
type = "https://tools.ietf.org/html/rfc9110#section-15.5.4",
title = "Forbidden",
status = StatusCodes.Status403Forbidden
});
}
_logger.LogWarning("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
}
else
{
@@ -199,11 +173,6 @@ public class WebSocketProxyMiddleware
}
finally
{
if (!string.IsNullOrEmpty(deviceId))
{
_sessionManager.UnregisterProxiedWebSocket(deviceId);
}
// Clean up connections
if (clientWebSocket?.State == WebSocketState.Open)
{
@@ -232,8 +201,8 @@ public class WebSocketProxyMiddleware
clientWebSocket?.Dispose();
serverWebSocket?.Dispose();
// CRITICAL: Notify session manager only when a client socket was accepted.
if (clientWebSocket != null && !string.IsNullOrEmpty(deviceId))
// CRITICAL: Notify session manager that client disconnected
if (!string.IsNullOrEmpty(deviceId))
{
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
await _sessionManager.RemoveSessionAsync(deviceId);
+2 -2
View File
@@ -112,8 +112,8 @@ public class Song
public int? ExplicitContentLyrics { get; set; }
/// <summary>
/// Raw Jellyfin metadata for local tracks, including MediaSources and cached item snapshots
/// Preserved to maintain full Jellyfin object fidelity across cache round-trips
/// Raw Jellyfin metadata (MediaSources, etc.) for local tracks
/// Preserved to maintain bitrate and other technical details
/// </summary>
public Dictionary<string, object?>? JellyfinMetadata { get; set; }
}
-4
View File
@@ -8,12 +8,8 @@ public class DownloadInfo
public string SongId { get; set; } = string.Empty;
public string ExternalId { get; set; } = string.Empty;
public string ExternalProvider { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty;
public DownloadStatus Status { get; set; }
public double Progress { get; set; } // 0.0 to 1.0
public bool RequestedForStreaming { get; set; }
public int? DurationSeconds { get; set; }
public string? LocalPath { get; set; }
public string? ErrorMessage { get; set; }
public DateTime StartedAt { get; set; }
+3 -10
View File
@@ -5,8 +5,6 @@ namespace allstarr.Models.Scrobbling;
/// </summary>
public class PlaybackSession
{
private const int ExternalStartToleranceSeconds = 5;
/// <summary>
/// Unique identifier for this playback session.
/// </summary>
@@ -56,18 +54,13 @@ public class PlaybackSession
{
if (Scrobbled)
return false; // Already scrobbled
if (Track.DurationSeconds == null || Track.DurationSeconds <= 30)
return false; // Track too short or duration unknown
// External scrobbles should only count if playback started near the beginning.
// This avoids duplicate/resume scrobbles when users jump into a track mid-way.
if (Track.IsExternal && (Track.StartPositionSeconds ?? 0) > ExternalStartToleranceSeconds)
return false;
var halfDuration = Track.DurationSeconds.Value / 2;
var scrobbleThreshold = Math.Min(halfDuration, 240); // 4 minutes = 240 seconds
return LastPositionSeconds >= scrobbleThreshold;
}
}
@@ -52,10 +52,4 @@ public record ScrobbleTrack
/// ListenBrainz only scrobbles external tracks.
/// </summary>
public bool IsExternal { get; init; } = false;
/// <summary>
/// Playback position in seconds when this listen started.
/// Used to prevent scrobbling resumed external tracks that did not start near the beginning.
/// </summary>
public int? StartPositionSeconds { get; init; }
}
+2 -11
View File
@@ -8,9 +8,9 @@ public class CacheSettings
{
/// <summary>
/// Search results cache duration in minutes.
/// Default: 1 minute (60 seconds)
/// Default: 120 minutes (2 hours)
/// </summary>
public int SearchResultsMinutes { get; set; } = 1;
public int SearchResultsMinutes { get; set; } = 120;
/// <summary>
/// Playlist cover images cache duration in hours.
@@ -61,14 +61,6 @@ public class CacheSettings
/// </summary>
public int ProxyImagesDays { get; set; } = 14;
/// <summary>
/// Transcoded audio cache duration in minutes.
/// Quality-override files (downloaded at lower quality for cellular streaming)
/// are cached in {downloads}/transcoded/ and cleaned up after this duration.
/// Default: 60 minutes (1 hour)
/// </summary>
public int TranscodeCacheMinutes { get; set; } = 60;
// Helper methods to get TimeSpan values
public TimeSpan SearchResultsTTL => TimeSpan.FromMinutes(SearchResultsMinutes);
public TimeSpan PlaylistImagesTTL => TimeSpan.FromHours(PlaylistImagesHours);
@@ -79,5 +71,4 @@ public class CacheSettings
public TimeSpan MetadataTTL => TimeSpan.FromDays(MetadataDays);
public TimeSpan OdesliLookupTTL => TimeSpan.FromDays(OdesliLookupDays);
public TimeSpan ProxyImagesTTL => TimeSpan.FromDays(ProxyImagesDays);
public TimeSpan TranscodeCacheTTL => TimeSpan.FromMinutes(TranscodeCacheMinutes);
}
@@ -22,10 +22,4 @@ public class DeezerSettings
/// If not specified or unavailable, the highest available quality will be used.
/// </summary>
public string? Quality { get; set; }
/// <summary>
/// Minimum interval between requests in milliseconds.
/// Default: 200ms
/// </summary>
public int MinRequestIntervalMs { get; set; } = 200;
}
@@ -22,10 +22,4 @@ public class QobuzSettings
/// If not specified or unavailable, the highest available quality will be used.
/// </summary>
public string? Quality { get; set; }
/// <summary>
/// Minimum interval between requests in milliseconds.
/// Default: 200ms
/// </summary>
public int MinRequestIntervalMs { get; set; } = 200;
}
@@ -18,12 +18,6 @@ public class ScrobblingSettings
/// </summary>
public bool LocalTracksEnabled { get; set; }
/// <summary>
/// Emits a synthetic local "played" signal from progress events when local scrobbling is disabled.
/// Default is false to avoid duplicate local scrobbles with Jellyfin plugins.
/// </summary>
public bool SyntheticLocalPlayedSignalEnabled { get; set; }
/// <summary>
/// Last.fm settings.
/// </summary>
@@ -14,10 +14,4 @@ public class SquidWTFSettings
/// If not specified or unavailable, LOSSLESS will be used.
/// </summary>
public string? Quality { get; set; }
/// <summary>
/// Minimum interval between requests in milliseconds.
/// Default: 200ms
/// </summary>
public int MinRequestIntervalMs { get; set; } = 200;
}
+1 -40
View File
@@ -16,7 +16,6 @@ using Microsoft.Extensions.Http;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
// Discover SquidWTF API and streaming endpoints from uptime feeds.
var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync();
@@ -176,25 +175,6 @@ builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
// but we want to reduce noise in production logs
options.SuppressHandlerScope = true;
});
// Register a dedicated named HttpClient for Jellyfin backend with connection pooling.
// SocketsHttpHandler reuses TCP connections across the scoped JellyfinProxyService
// instances, eliminating per-request TCP/TLS handshake overhead.
builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName)
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
// Keep up to 20 idle connections to Jellyfin alive at any time
MaxConnectionsPerServer = 20,
// Recycle pooled connections every 5 minutes to pick up DNS changes
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
// Close idle connections after 90 seconds to avoid stale sockets
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
// Allow HTTP/2 multiplexing when Jellyfin supports it
EnableMultipleHttp2Connections = true,
// Follow redirects within Jellyfin
AllowAutoRedirect = true,
MaxAutomaticRedirections = 5
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddHttpContextAccessor();
@@ -529,7 +509,6 @@ else
// Business services - shared across backends
builder.Services.AddSingleton(squidWtfEndpointCatalog);
builder.Services.AddSingleton<RedisCacheService>();
builder.Services.AddSingleton<FavoritesMigrationService>();
builder.Services.AddSingleton<OdesliService>();
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
builder.Services.AddSingleton<LrclibService>();
@@ -712,7 +691,6 @@ 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>();
@@ -753,8 +731,6 @@ builder.Services.Configure<allstarr.Models.Settings.ScrobblingSettings>(options
options.Enabled = builder.Configuration.GetValue<bool>("Scrobbling:Enabled");
options.LocalTracksEnabled = builder.Configuration.GetValue<bool>("Scrobbling:LocalTracksEnabled");
options.SyntheticLocalPlayedSignalEnabled =
builder.Configuration.GetValue<bool>("Scrobbling:SyntheticLocalPlayedSignalEnabled");
options.LastFm.Enabled = lastFmEnabled;
// Only override hardcoded API credentials if explicitly set in config
@@ -779,7 +755,6 @@ builder.Services.Configure<allstarr.Models.Settings.ScrobblingSettings>(options
Console.WriteLine($"Scrobbling Configuration:");
Console.WriteLine($" Enabled: {options.Enabled}");
Console.WriteLine($" Local Tracks Enabled: {options.LocalTracksEnabled}");
Console.WriteLine($" Synthetic Local Played Signal Enabled: {options.SyntheticLocalPlayedSignalEnabled}");
Console.WriteLine($" Last.fm Enabled: {options.LastFm.Enabled}");
Console.WriteLine($" Last.fm Username: {options.LastFm.Username ?? "(not set)"}");
Console.WriteLine($" Last.fm Session Key: {(string.IsNullOrEmpty(options.LastFm.SessionKey) ? "(not set)" : "***" + options.LastFm.SessionKey[^8..])}");
@@ -913,13 +888,6 @@ builder.Services.AddCors(options =>
var app = builder.Build();
// Run one-time favorites/deletions migration if using Redis
using (var scope = app.Services.CreateScope())
{
var migrationService = scope.ServiceProvider.GetRequiredService<FavoritesMigrationService>();
await migrationService.MigrateAsync();
}
// Initialize cache settings for static access
CacheExtensions.InitializeCacheSettings(app.Services);
@@ -940,9 +908,6 @@ catch (Exception ex)
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
app.UseForwardedHeaders();
// Drop high-confidence scanner paths before they hit the proxy or request logging.
app.UseMiddleware<BotProbeBlockMiddleware>();
// Request logging middleware (when DEBUG_LOG_ALL_REQUESTS=true)
app.UseMiddleware<RequestLoggingMiddleware>();
@@ -966,11 +931,7 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
// 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());
app.UseHttpsRedirection();
// Serve static files only on admin port (5275)
app.UseMiddleware<allstarr.Middleware.AdminNetworkAllowlistMiddleware>();
@@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
namespace allstarr.Services.Admin;
@@ -21,7 +20,9 @@ public class AdminHelperService
{
_logger = logger;
_jellyfinSettings = jellyfinSettings.Value;
_envFilePath = RuntimeEnvConfiguration.ResolveEnvFilePath(environment);
_envFilePath = environment.IsDevelopment()
? Path.Combine(environment.ContentRootPath, "..", ".env")
: "/app/.env";
}
public string GetJellyfinAuthHeader()
+1 -12
View File
@@ -28,18 +28,7 @@ public static class AuthHeaderHelper
return true;
}
}
// Some Jellyfin clients send the raw token separately instead of a MediaBrowser auth header.
foreach (var header in sourceHeaders)
{
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
targetRequest.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
return true;
}
}
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
foreach (var header in sourceHeaders)
{
+56 -259
View File
@@ -29,40 +29,13 @@ public abstract class BaseDownloadService : IDownloadService
protected readonly string CachePath;
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
// Concurrency and state locking
protected readonly SemaphoreSlim _stateSemaphore = new(1, 1);
protected readonly SemaphoreSlim _concurrencySemaphore;
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
// Rate limiting fields
private readonly SemaphoreSlim _requestLock = new(1, 1);
private DateTime _lastRequestTime = DateTime.MinValue;
protected int _minRequestIntervalMs = 200;
private readonly int _minRequestIntervalMs = 200;
protected StorageMode CurrentStorageMode
{
get
{
var backendType = Configuration["Backend:Type"] ?? "Subsonic";
var modeStr = backendType.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase)
? Configuration["Jellyfin:StorageMode"] ?? Configuration["Subsonic:StorageMode"] ?? "Permanent"
: Configuration["Subsonic:StorageMode"] ?? "Permanent";
return Enum.TryParse<StorageMode>(modeStr, true, out var result) ? result : StorageMode.Permanent;
}
}
protected DownloadMode CurrentDownloadMode
{
get
{
var backendType = Configuration["Backend:Type"] ?? "Subsonic";
var modeStr = backendType.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase)
? Configuration["Jellyfin:DownloadMode"] ?? Configuration["Subsonic:DownloadMode"] ?? "Track"
: Configuration["Subsonic:DownloadMode"] ?? "Track";
return Enum.TryParse<DownloadMode>(modeStr, true, out var result) ? result : DownloadMode.Track;
}
}
/// <summary>
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
/// </summary>
@@ -111,13 +84,6 @@ public abstract class BaseDownloadService : IDownloadService
{
Directory.CreateDirectory(CachePath);
}
var maxDownloadsStr = configuration["MAX_CONCURRENT_DOWNLOADS"];
if (!int.TryParse(maxDownloadsStr, out var maxDownloads) || maxDownloads <= 0)
{
maxDownloads = 3;
}
_concurrencySemaphore = new SemaphoreSlim(maxDownloads, maxDownloads);
}
#region IDownloadService Implementation
@@ -129,25 +95,12 @@ public abstract class BaseDownloadService : IDownloadService
/// </summary>
public async Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
return await DownloadSongInternalAsync(
externalProvider,
externalId,
triggerAlbumDownload: true,
requestedForStreaming: false,
cancellationToken);
return await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken);
}
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, StreamQuality? qualityOverride = null, CancellationToken cancellationToken = default)
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
// If a quality override is requested (not Original), use the quality override path
// This downloads to a temp file at the requested quality and streams it without caching
if (qualityOverride.HasValue && qualityOverride.Value != StreamQuality.Original)
{
return await DownloadAndStreamWithQualityOverrideAsync(externalProvider, externalId, qualityOverride.Value, cancellationToken);
}
// Standard path: use .env quality, cache the result
var startTime = DateTime.UtcNow;
// Check if already downloaded locally
@@ -158,7 +111,7 @@ public abstract class BaseDownloadService : IDownloadService
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
// Update write time for cache cleanup (extends cache lifetime)
if (CurrentStorageMode == StorageMode.Cache)
if (SubsonicSettings.StorageMode == StorageMode.Cache)
{
IOFile.SetLastWriteTime(localPath, DateTime.UtcNow);
}
@@ -181,12 +134,7 @@ public abstract class BaseDownloadService : IDownloadService
// IMPORTANT: Use CancellationToken.None for the actual download
// This ensures downloads complete server-side even if the client cancels the request
// The client can request the file again later once it's ready
localPath = await DownloadSongInternalAsync(
externalProvider,
externalId,
triggerAlbumDownload: true,
requestedForStreaming: true,
CancellationToken.None);
localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, CancellationToken.None);
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath);
@@ -209,65 +157,6 @@ public abstract class BaseDownloadService : IDownloadService
}
}
/// <summary>
/// Downloads and streams with a quality override.
/// When the client requests lower quality (e.g., cellular mode), this downloads to a temp file
/// at the requested quality tier and streams it. The temp file is auto-deleted after streaming.
/// This does NOT pollute the cache — the cached file at .env quality remains the canonical copy.
/// </summary>
private async Task<Stream> DownloadAndStreamWithQualityOverrideAsync(
string externalProvider, string externalId, StreamQuality quality, CancellationToken cancellationToken)
{
var startTime = DateTime.UtcNow;
Logger.LogInformation(
"Streaming with quality override {Quality} for {Provider}:{ExternalId}",
quality, externalProvider, externalId);
try
{
// Get metadata for the track
var song = await MetadataService.GetSongAsync(externalProvider, externalId);
if (song == null)
{
throw new Exception("Song not found");
}
// Download to a temp file at the overridden quality
// IMPORTANT: Use CancellationToken.None to ensure download completes server-side
var tempPath = await DownloadTrackWithQualityAsync(externalId, song, quality, CancellationToken.None);
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation(
"Quality-override download completed ({Quality}, {ElapsedMs}ms): {Path}",
quality, elapsed, tempPath);
// Touch the file to extend its cache lifetime for TTL-based cleanup
IOFile.SetLastWriteTime(tempPath, DateTime.UtcNow);
// Start background Odesli conversion for lyrics (doesn't block streaming)
StartBackgroundOdesliConversion(externalProvider, externalId);
// Return a regular stream — the file stays in the transcoded cache
// and is cleaned up by CacheCleanupService based on CACHE_TRANSCODE_MINUTES TTL
return IOFile.OpenRead(tempPath);
}
catch (OperationCanceledException)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogWarning(
"Quality-override download cancelled after {ElapsedMs}ms for {Provider}:{ExternalId}",
elapsed, externalProvider, externalId);
throw;
}
catch (Exception ex)
{
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogError(ex,
"Quality-override download failed after {ElapsedMs}ms for {Provider}:{ExternalId}",
elapsed, externalProvider, externalId);
throw;
}
}
/// <summary>
/// Starts background Odesli conversion for lyrics support.
@@ -305,11 +194,6 @@ public abstract class BaseDownloadService : IDownloadService
ActiveDownloads.TryGetValue(songId, out var info);
return info;
}
public IReadOnlyList<DownloadInfo> GetActiveDownloads()
{
return ActiveDownloads.Values.ToList().AsReadOnly();
}
public async Task<string?> GetLocalPathIfExistsAsync(string externalProvider, string externalId)
{
@@ -329,24 +213,6 @@ public abstract class BaseDownloadService : IDownloadService
}
public abstract Task<bool> IsAvailableAsync();
protected string BuildTrackedSongId(string externalId)
{
return BuildTrackedSongId(ProviderName, externalId);
}
protected static string BuildTrackedSongId(string externalProvider, string externalId)
{
return $"ext-{externalProvider}-song-{externalId}";
}
protected void SetDownloadProgress(string songId, double progress)
{
if (ActiveDownloads.TryGetValue(songId, out var info))
{
info.Progress = Math.Clamp(progress, 0d, 1d);
}
}
public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId)
{
@@ -383,23 +249,6 @@ public abstract class BaseDownloadService : IDownloadService
/// <returns>Local file path where the track was saved</returns>
protected abstract Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken);
/// <summary>
/// Downloads a track at a specific quality tier to a temp file.
/// Subclasses override this to map StreamQuality to provider-specific quality settings.
/// The .env quality is used as a ceiling — the override can only go equal or lower.
/// Default implementation falls back to DownloadTrackAsync (uses .env quality).
/// </summary>
/// <param name="trackId">External track ID</param>
/// <param name="song">Song metadata</param>
/// <param name="quality">Requested quality tier</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Local temp file path where the track was saved</returns>
protected virtual Task<string> DownloadTrackWithQualityAsync(string trackId, Song song, StreamQuality quality, CancellationToken cancellationToken)
{
// Default: ignore quality override and use configured quality
return DownloadTrackAsync(trackId, song, cancellationToken);
}
/// <summary>
/// Extracts the external album ID from the internal album ID format.
/// Example: "ext-deezer-album-123456" -> "123456"
@@ -423,25 +272,20 @@ public abstract class BaseDownloadService : IDownloadService
/// <summary>
/// Internal method for downloading a song with control over album download triggering
/// </summary>
protected async Task<string> DownloadSongInternalAsync(
string externalProvider,
string externalId,
bool triggerAlbumDownload,
bool requestedForStreaming = false,
CancellationToken cancellationToken = default)
protected async Task<string> DownloadSongInternalAsync(string externalProvider, string externalId, bool triggerAlbumDownload, CancellationToken cancellationToken = default)
{
if (externalProvider != ProviderName)
{
throw new NotSupportedException($"Provider '{externalProvider}' is not supported");
}
var songId = BuildTrackedSongId(externalProvider, externalId);
var isCache = CurrentStorageMode == StorageMode.Cache;
var songId = $"ext-{externalProvider}-{externalId}";
var isCache = SubsonicSettings.StorageMode == StorageMode.Cache;
bool isInitiator = false;
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
await DownloadLock.WaitAsync(cancellationToken);
var lockHeld = true;
// 1. Synchronous state check to prevent race conditions on checking existence or ActiveDownloads
await _stateSemaphore.WaitAsync(cancellationToken);
try
{
// Check if already downloaded (works for both cache and permanent modes)
@@ -462,73 +306,40 @@ public abstract class BaseDownloadService : IDownloadService
// Check if download in progress
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{
if (requestedForStreaming)
{
activeDownload.RequestedForStreaming = true;
}
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
// We are not the initiator; we will wait outside the lock.
}
else
{
// We must initiate the download
isInitiator = true;
ActiveDownloads[songId] = new DownloadInfo
// Release lock while waiting
DownloadLock.Release();
lockHeld = false;
// Wait for download to complete, checking every 100ms
// Note: We check cancellation but don't cancel the actual download
// The download continues server-side even if this client gives up waiting
while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{
SongId = songId,
ExternalId = externalId,
ExternalProvider = externalProvider,
Title = "Unknown Title", // Will be updated after fetching
Artist = "Unknown Artist",
Status = DownloadStatus.InProgress,
Progress = 0,
RequestedForStreaming = requestedForStreaming,
StartedAt = DateTime.UtcNow
};
}
}
finally
{
_stateSemaphore.Release();
}
// If another thread is already downloading this track, wait for it.
if (!isInitiator)
{
DownloadInfo? activeDownload;
while (ActiveDownloads.TryGetValue(songId, out activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{
// If client cancels, throw but let the download continue in background
if (cancellationToken.IsCancellationRequested)
{
Logger.LogInformation("Client cancelled while waiting for download {SongId}, but download continues server-side", songId);
throw new OperationCanceledException("Client cancelled request, but download continues server-side");
// If client cancels, throw but let the download continue in background
if (cancellationToken.IsCancellationRequested)
{
Logger.LogInformation("Client cancelled while waiting for download {SongId}, but download continues server-side", songId);
throw new OperationCanceledException("Client cancelled request, but download continues server-side");
}
await Task.Delay(100, CancellationToken.None);
}
await Task.Delay(100, CancellationToken.None);
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
{
Logger.LogDebug("Download completed while waiting, returning path: {Path}", activeDownload.LocalPath);
return activeDownload.LocalPath;
}
// Download failed or was cancelled
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed while waiting");
}
if (activeDownload?.Status == DownloadStatus.Completed && activeDownload.LocalPath != null)
{
Logger.LogDebug("Download completed while waiting, returning path: {Path}", activeDownload.LocalPath);
return activeDownload.LocalPath;
}
// Download failed or was cancelled
throw new Exception(activeDownload?.ErrorMessage ?? "Download failed while waiting");
}
// --- Execute the Download (we are the initiator) ---
// Wait for a concurrency permit before doing the heavy lifting
await _concurrencySemaphore.WaitAsync(cancellationToken);
try
{
// Get metadata
// In Album mode, fetch the full album first to ensure AlbumArtist is correctly set
Song? song = null;
if (CurrentDownloadMode == DownloadMode.Album)
if (SubsonicSettings.DownloadMode == DownloadMode.Album)
{
// First try to get the song to extract album ID
var tempSong = await MetadataService.GetSongAsync(externalProvider, externalId);
@@ -559,23 +370,21 @@ public abstract class BaseDownloadService : IDownloadService
throw new Exception("Song not found");
}
// Update ActiveDownloads with the real title/artist information
if (ActiveDownloads.TryGetValue(songId, out var info))
var downloadInfo = new DownloadInfo
{
info.Title = song.Title ?? "Unknown Title";
info.Artist = song.Artist ?? "Unknown Artist";
info.DurationSeconds = song.Duration;
}
SongId = songId,
ExternalId = externalId,
ExternalProvider = externalProvider,
Status = DownloadStatus.InProgress,
StartedAt = DateTime.UtcNow
};
ActiveDownloads[songId] = downloadInfo;
var localPath = await DownloadTrackAsync(externalId, song, cancellationToken);
if (ActiveDownloads.TryGetValue(songId, out var successInfo))
{
successInfo.Status = DownloadStatus.Completed;
successInfo.Progress = 1.0;
successInfo.LocalPath = localPath;
successInfo.CompletedAt = DateTime.UtcNow;
}
downloadInfo.Status = DownloadStatus.Completed;
downloadInfo.LocalPath = localPath;
downloadInfo.CompletedAt = DateTime.UtcNow;
song.LocalPath = localPath;
@@ -625,7 +434,7 @@ public abstract class BaseDownloadService : IDownloadService
});
// If download mode is Album and triggering is enabled, start background download of remaining tracks
if (triggerAlbumDownload && CurrentDownloadMode == DownloadMode.Album && !string.IsNullOrEmpty(song.AlbumId))
if (triggerAlbumDownload && SubsonicSettings.DownloadMode == DownloadMode.Album && !string.IsNullOrEmpty(song.AlbumId))
{
var albumExternalId = ExtractExternalIdFromAlbumId(song.AlbumId);
if (!string.IsNullOrEmpty(albumExternalId))
@@ -658,22 +467,15 @@ public abstract class BaseDownloadService : IDownloadService
Logger.LogDebug("Cleaned up failed download tracking for {SongId}", songId);
});
}
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
{
Logger.LogError("Download failed for {SongId}: {StatusCode}: {ReasonPhrase}",
songId, (int)httpRequestException.StatusCode.Value, httpRequestException.StatusCode.Value);
Logger.LogDebug(ex, "Detailed download failure for {SongId}", songId);
}
else
{
Logger.LogError(ex, "Download failed for {SongId}", songId);
}
Logger.LogError(ex, "Download failed for {SongId}", songId);
throw;
}
finally
{
_concurrencySemaphore.Release();
if (lockHeld)
{
DownloadLock.Release();
}
}
}
@@ -708,7 +510,7 @@ public abstract class BaseDownloadService : IDownloadService
}
// Check if download is already in progress or recently completed
var songId = BuildTrackedSongId(track.ExternalId!);
var songId = $"ext-{ProviderName}-{track.ExternalId}";
if (ActiveDownloads.TryGetValue(songId, out var activeDownload))
{
if (activeDownload.Status == DownloadStatus.InProgress)
@@ -725,12 +527,7 @@ public abstract class BaseDownloadService : IDownloadService
}
Logger.LogInformation("Downloading track '{Title}' from album '{Album}'", track.Title, album.Title);
await DownloadSongInternalAsync(
ProviderName,
track.ExternalId!,
triggerAlbumDownload: false,
requestedForStreaming: false,
CancellationToken.None);
await DownloadSongInternalAsync(ProviderName, track.ExternalId!, triggerAlbumDownload: false, CancellationToken.None);
}
catch (Exception ex)
{
@@ -1,107 +0,0 @@
using System.Globalization;
namespace allstarr.Services.Common;
/// <summary>
/// Identifies high-confidence internet scanner paths that should never hit Jellyfin.
/// </summary>
public static class BotProbeDetector
{
private static readonly string[] PrefixMatches =
{
".env",
".git",
".hg",
".svn",
"_ignition/",
"debug/default",
"vendor/",
"public/vendor/"
};
private static readonly string[] FragmentMatches =
{
"/.env",
"/.git/",
"/vendor/",
"phpunit",
"laravel-filemanager",
"eval-stdin.php"
};
private static readonly string[] SuffixMatches =
{
".php"
};
public static bool IsHighConfidenceProbePath(string? rawPath)
{
var path = NormalizePath(rawPath);
if (string.IsNullOrEmpty(path))
{
return false;
}
if (path.Equals("wp", StringComparison.Ordinal) ||
path.StartsWith("wp-", StringComparison.Ordinal) ||
path.StartsWith("wp/", StringComparison.Ordinal) ||
path.Equals("wordpress", StringComparison.Ordinal) ||
path.StartsWith("wordpress/", StringComparison.Ordinal))
{
return true;
}
if (PrefixMatches.Any(prefix => path.StartsWith(prefix, StringComparison.Ordinal)))
{
return true;
}
if (FragmentMatches.Any(fragment => path.Contains(fragment, StringComparison.Ordinal)))
{
return true;
}
return SuffixMatches.Any(suffix => path.EndsWith(suffix, StringComparison.Ordinal));
}
public static bool IsHighConfidenceProbeUrl(string? rawUrlOrPath)
{
if (string.IsNullOrWhiteSpace(rawUrlOrPath))
{
return false;
}
if (Uri.TryCreate(rawUrlOrPath, UriKind.Absolute, out var uri))
{
return IsHighConfidenceProbePath(uri.AbsolutePath);
}
return IsHighConfidenceProbePath(rawUrlOrPath);
}
private static string NormalizePath(string? rawPath)
{
if (string.IsNullOrWhiteSpace(rawPath))
{
return string.Empty;
}
var path = rawPath.Trim();
if (Uri.TryCreate(path, UriKind.Absolute, out var uri))
{
path = uri.AbsolutePath;
}
path = Uri.UnescapeDataString(path)
.Replace('\\', '/')
.TrimStart('/');
while (path.Contains("//", StringComparison.Ordinal))
{
path = path.Replace("//", "/", StringComparison.Ordinal);
}
return path.ToLower(CultureInfo.InvariantCulture);
}
}
@@ -45,7 +45,6 @@ public class CacheCleanupService : BackgroundService
try
{
await CleanupOldCachedFilesAsync(stoppingToken);
await CleanupTranscodedCacheAsync(stoppingToken);
await ProcessPendingDeletionsAsync(stoppingToken);
await Task.Delay(_cleanupInterval, stoppingToken);
}
@@ -135,71 +134,6 @@ public class CacheCleanupService : BackgroundService
}
}
/// <summary>
/// Cleans up transcoded quality-override files based on CACHE_TRANSCODE_MINUTES TTL.
/// This always runs regardless of StorageMode, since transcoded files are a separate concern.
/// </summary>
private async Task CleanupTranscodedCacheAsync(CancellationToken cancellationToken)
{
var downloadPath = _configuration["Library:DownloadPath"] ?? "downloads";
var transcodedPath = Path.Combine(downloadPath, "transcoded");
if (!Directory.Exists(transcodedPath))
{
return;
}
var ttl = CacheExtensions.TranscodeCacheTTL;
var cutoffTime = DateTime.UtcNow - ttl;
var deletedCount = 0;
var totalSize = 0L;
try
{
var files = Directory.GetFiles(transcodedPath, "*.*", SearchOption.AllDirectories);
foreach (var filePath in files)
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
var fileInfo = new FileInfo(filePath);
// Use last write time (updated on cache hit) to determine if file should be deleted
if (fileInfo.LastWriteTimeUtc < cutoffTime)
{
var size = fileInfo.Length;
File.Delete(filePath);
deletedCount++;
totalSize += size;
_logger.LogDebug("Deleted transcoded cache file: {Path} (age: {Age:F1} minutes)",
filePath, (DateTime.UtcNow - fileInfo.LastWriteTimeUtc).TotalMinutes);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete transcoded cache file: {Path}", filePath);
}
}
// Clean up empty directories in the transcoded folder
await CleanupEmptyDirectoriesAsync(transcodedPath, cancellationToken);
if (deletedCount > 0)
{
var sizeMB = totalSize / (1024.0 * 1024.0);
_logger.LogInformation("Transcoded cache cleanup: deleted {Count} files, freed {Size:F2} MB (TTL: {TTL} minutes)",
deletedCount, sizeMB, ttl.TotalMinutes);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during transcoded cache cleanup");
}
}
private async Task CleanupEmptyDirectoriesAsync(string rootPath, CancellationToken cancellationToken)
{
try
@@ -49,5 +49,4 @@ public static class CacheExtensions
public static TimeSpan MetadataTTL => GetCacheSettings().MetadataTTL;
public static TimeSpan OdesliLookupTTL => GetCacheSettings().OdesliLookupTTL;
public static TimeSpan ProxyImagesTTL => GetCacheSettings().ProxyImagesTTL;
public static TimeSpan TranscodeCacheTTL => GetCacheSettings().TranscodeCacheTTL;
}
+3 -14
View File
@@ -22,8 +22,7 @@ public static class CacheKeyBuilder
string? sortBy,
string? sortOrder,
bool? recursive,
string? userId,
string? isFavorite = null)
string? userId)
{
var normalizedTerm = Normalize(searchTerm);
var normalizedItemTypes = Normalize(itemTypes);
@@ -31,10 +30,9 @@ public static class CacheKeyBuilder
var normalizedSortBy = Normalize(sortBy);
var normalizedSortOrder = Normalize(sortOrder);
var normalizedUserId = Normalize(userId);
var normalizedIsFavorite = Normalize(isFavorite);
var normalizedRecursive = recursive.HasValue ? (recursive.Value ? "true" : "false") : string.Empty;
return $"search:{normalizedTerm}:{normalizedItemTypes}:{limit}:{startIndex}:{normalizedParentId}:{normalizedSortBy}:{normalizedSortOrder}:{normalizedRecursive}:{normalizedUserId}:{normalizedIsFavorite}";
return $"search:{normalizedTerm}:{normalizedItemTypes}:{limit}:{startIndex}:{normalizedParentId}:{normalizedSortBy}:{normalizedSortOrder}:{normalizedRecursive}:{normalizedUserId}";
}
private static string Normalize(string? value)
@@ -153,22 +151,13 @@ public static class CacheKeyBuilder
#endregion
#region Image Keys
#region Playlist Keys
public static string BuildPlaylistImageKey(string playlistId)
{
return $"playlist:image:{playlistId}";
}
/// <summary>
/// Builds a cache key for external album/song/artist cover art images.
/// Images are cached as byte[] in Redis with ProxyImagesTTL (default 14 days).
/// </summary>
public static string BuildExternalImageKey(string provider, string type, string externalId)
{
return $"image:{provider}:{type}:{externalId}";
}
#endregion
#region Genre Keys
+5 -102
View File
@@ -35,13 +35,13 @@ public class EnvMigrationService
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
continue;
// Migrate Library__DownloadPath to DOWNLOAD_PATH (inverse migration)
if (line.StartsWith("Library__DownloadPath="))
// Migrate DOWNLOAD_PATH to Library__DownloadPath
if (line.StartsWith("DOWNLOAD_PATH="))
{
var value = line.Substring("Library__DownloadPath=".Length);
lines[i] = $"DOWNLOAD_PATH={value}";
var value = line.Substring("DOWNLOAD_PATH=".Length);
lines[i] = $"Library__DownloadPath={value}";
modified = true;
_logger.LogInformation("Migrated Library__DownloadPath to DOWNLOAD_PATH in .env file");
_logger.LogDebug("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file");
}
// Migrate old SquidWTF quality values to new format
@@ -104,107 +104,10 @@ public class EnvMigrationService
File.WriteAllLines(_envFilePath, lines);
_logger.LogInformation("✅ .env file migration completed successfully");
}
ReformatEnvFileIfSquashed();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to migrate .env file");
}
}
private void ReformatEnvFileIfSquashed()
{
try
{
if (!File.Exists(_envFilePath)) return;
var currentLines = File.ReadAllLines(_envFilePath);
var commentCount = currentLines.Count(l => l.TrimStart().StartsWith("#"));
// If the file has fewer than 5 comments, it's likely a flattened/squashed file
// from an older version or raw docker output. Let's rehydrate it.
if (commentCount < 5)
{
var examplePath = Path.Combine(Directory.GetCurrentDirectory(), ".env.example");
if (!File.Exists(examplePath))
{
examplePath = Path.Combine(Directory.GetParent(Directory.GetCurrentDirectory())?.FullName ?? "", ".env.example");
}
if (!File.Exists(examplePath)) return;
_logger.LogInformation("Flattened/raw .env file detected (only {Count} comments). Rehydrating formatting from .env.example...", commentCount);
var currentValues = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in currentLines)
{
var trimmed = line.Trim();
if (string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith("#")) continue;
var eqIndex = trimmed.IndexOf('=');
if (eqIndex > 0)
{
var key = trimmed[..eqIndex].Trim();
var value = trimmed[(eqIndex + 1)..].Trim();
currentValues[key] = value;
}
}
var exampleLines = File.ReadAllLines(examplePath).ToList();
var usedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < exampleLines.Count; i++)
{
var line = exampleLines[i].TrimStart();
if (string.IsNullOrWhiteSpace(line)) continue;
if (!line.StartsWith("#"))
{
var eqIndex = line.IndexOf('=');
if (eqIndex > 0)
{
var key = line[..eqIndex].Trim();
if (currentValues.TryGetValue(key, out var val))
{
exampleLines[i] = $"{key}={val}";
usedKeys.Add(key);
}
}
}
else
{
var eqIndex = line.IndexOf('=');
if (eqIndex > 0)
{
var keyPart = line[..eqIndex].TrimStart('#').Trim();
if (!keyPart.Contains(" ") && keyPart.Length > 0 && currentValues.TryGetValue(keyPart, out var val))
{
exampleLines[i] = $"{keyPart}={val}";
usedKeys.Add(keyPart);
}
}
}
}
var leftoverKeys = currentValues.Keys.Except(usedKeys).ToList();
if (leftoverKeys.Any())
{
exampleLines.Add("");
exampleLines.Add("# ===== CUSTOM / UNKNOWN VARIABLES =====");
foreach (var key in leftoverKeys)
{
exampleLines.Add($"{key}={currentValues[key]}");
}
}
File.WriteAllLines(_envFilePath, exampleLines);
_logger.LogInformation("✅ .env file successfully rehydrated with comments and formatting");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to rehydrate .env file formatting");
}
}
}
@@ -1,168 +0,0 @@
using System.Text.Json;
using System.Globalization;
using allstarr.Models.Domain;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Configuration;
namespace allstarr.Services.Common;
/// <summary>
/// Handles one-time migration of favorites and pending deletions from old JSON files to Redis.
/// </summary>
public class FavoritesMigrationService
{
private readonly RedisCacheService _cache;
private readonly ILogger<FavoritesMigrationService> _logger;
private readonly string _cacheDir;
public FavoritesMigrationService(
RedisCacheService cache,
IConfiguration configuration,
ILogger<FavoritesMigrationService> logger)
{
_cache = cache;
_logger = logger;
_cacheDir = "/app/cache"; // This matches the path in JellyfinController
}
public async Task MigrateAsync()
{
if (!_cache.IsEnabled) return;
await MigrateFavoritesAsync();
await MigratePendingDeletionsAsync();
}
private async Task MigrateFavoritesAsync()
{
var filePath = Path.Combine(_cacheDir, "favorites.json");
var migrationMark = Path.Combine(_cacheDir, "favorites.json.migrated");
if (!File.Exists(filePath) || File.Exists(migrationMark)) return;
try
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
_logger.LogInformation("🚀 Starting one-time migration of favorites from {Path} to Redis...", filePath);
var json = await File.ReadAllTextAsync(filePath);
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json, options);
if (favorites == null || favorites.Count == 0)
{
File.Move(filePath, migrationMark);
return;
}
int count = 0;
foreach (var fav in favorites.Values)
{
await _cache.SetAsync($"favorites:{fav.ItemId}", fav);
count++;
}
File.Move(filePath, migrationMark);
_logger.LogInformation("✅ Successfully migrated {Count} favorites to Redis cached storage.", count);
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to migrate favorites from JSON to Redis");
}
}
private async Task MigratePendingDeletionsAsync()
{
var filePath = Path.Combine(_cacheDir, "pending_deletions.json");
var migrationMark = Path.Combine(_cacheDir, "pending_deletions.json.migrated");
if (!File.Exists(filePath) || File.Exists(migrationMark)) return;
try
{
_logger.LogInformation("🚀 Starting one-time migration of pending deletions from {Path} to Redis...", filePath);
var json = await File.ReadAllTextAsync(filePath);
var deletions = ParsePendingDeletions(json, DateTime.UtcNow);
if (deletions == null || deletions.Count == 0)
{
File.Move(filePath, migrationMark);
return;
}
int count = 0;
foreach (var (itemId, deleteAt) in deletions)
{
await _cache.SetStringAsync($"pending_deletion:{itemId}", deleteAt.ToUniversalTime().ToString("O"));
count++;
}
File.Move(filePath, migrationMark);
_logger.LogInformation("✅ Successfully migrated {Count} pending deletions to Redis cached storage.", count);
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ Failed to migrate pending deletions from JSON to Redis");
}
}
private static Dictionary<string, DateTime> ParsePendingDeletions(string json, DateTime fallbackDeleteAtUtc)
{
var legacySchedule = TryDeserialize<Dictionary<string, DateTime>>(json);
if (legacySchedule != null)
{
return legacySchedule.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.Kind == DateTimeKind.Utc ? kvp.Value : kvp.Value.ToUniversalTime());
}
var legacyScheduleStrings = TryDeserialize<Dictionary<string, string>>(json);
if (legacyScheduleStrings != null)
{
var parsed = new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
foreach (var (itemId, deleteAtRaw) in legacyScheduleStrings)
{
if (DateTime.TryParse(
deleteAtRaw,
CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind | DateTimeStyles.AssumeUniversal,
out var deleteAt))
{
parsed[itemId] = deleteAt.Kind == DateTimeKind.Utc ? deleteAt : deleteAt.ToUniversalTime();
}
}
return parsed;
}
var deletionSet = TryDeserialize<HashSet<string>>(json) ?? TryDeserialize<List<string>>(json)?.ToHashSet();
if (deletionSet != null)
{
return deletionSet.ToDictionary(itemId => itemId, _ => fallbackDeleteAtUtc, StringComparer.OrdinalIgnoreCase);
}
throw new JsonException("Unsupported pending_deletions.json format");
}
private static T? TryDeserialize<T>(string json)
{
try
{
return JsonSerializer.Deserialize<T>(json);
}
catch (JsonException)
{
return default;
}
}
private class FavoriteTrackInfo
{
public string ItemId { get; set; } = "";
public string Title { get; set; } = "";
public string Artist { get; set; } = "";
public string Album { get; set; } = "";
public DateTime FavoritedAt { get; set; }
}
}
@@ -1,39 +0,0 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Http;
namespace allstarr.Services.Common;
public static class ImageConditionalRequestHelper
{
public static string ComputeStrongETag(byte[] payload)
{
var hash = SHA256.HashData(payload);
return $"\"{Convert.ToHexString(hash)}\"";
}
public static bool MatchesIfNoneMatch(IHeaderDictionary headers, string etag)
{
if (!headers.TryGetValue("If-None-Match", out var headerValues))
{
return false;
}
foreach (var headerValue in headerValues)
{
if (string.IsNullOrEmpty(headerValue))
{
continue;
}
foreach (var candidate in headerValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (candidate == "*" || string.Equals(candidate, etag, StringComparison.Ordinal))
{
return true;
}
}
}
return false;
}
}
@@ -1,79 +0,0 @@
using System.Text.Json;
namespace allstarr.Services.Common;
/// <summary>
/// Detects invalid injected playlist items so local Jellyfin tracks stay raw.
/// </summary>
public static class InjectedPlaylistItemHelper
{
private const string SyntheticServerId = "allstarr";
public static bool ContainsSyntheticLocalItems(IEnumerable<Dictionary<string, object?>> items)
{
return items.Any(LooksLikeSyntheticLocalItem);
}
public static bool ContainsLocalItemsMissingGenreMetadata(IEnumerable<Dictionary<string, object?>> items)
{
return items.Any(LooksLikeLocalItemMissingGenreMetadata);
}
public static bool LooksLikeSyntheticLocalItem(IReadOnlyDictionary<string, object?> item)
{
var id = GetString(item, "Id");
if (string.IsNullOrWhiteSpace(id) || IsExternalItemId(id))
{
return false;
}
var serverId = GetString(item, "ServerId");
return string.Equals(serverId, SyntheticServerId, StringComparison.OrdinalIgnoreCase);
}
public static bool LooksLikeLocalItemMissingGenreMetadata(IReadOnlyDictionary<string, object?> item)
{
var id = GetString(item, "Id");
if (string.IsNullOrWhiteSpace(id) || IsExternalItemId(id) || LooksLikeSyntheticLocalItem(item))
{
return false;
}
return !HasNonNullValue(item, "Genres") || !HasNonNullValue(item, "GenreItems");
}
private static bool IsExternalItemId(string itemId)
{
return itemId.StartsWith("ext-", StringComparison.OrdinalIgnoreCase);
}
private static bool HasNonNullValue(IReadOnlyDictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
{
return false;
}
return value switch
{
JsonElement { ValueKind: JsonValueKind.Null or JsonValueKind.Undefined } => false,
_ => true
};
}
private static string? GetString(IReadOnlyDictionary<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
{
return null;
}
return value switch
{
string s => s,
JsonElement { ValueKind: JsonValueKind.String } element => element.GetString(),
JsonElement { ValueKind: JsonValueKind.Number } element => element.ToString(),
_ => value.ToString()
};
}
}
@@ -1,61 +0,0 @@
using System.Text.Json;
using allstarr.Models.Domain;
namespace allstarr.Services.Common;
/// <summary>
/// Stores and restores raw Jellyfin item snapshots on local songs for cache safety.
/// </summary>
public static class JellyfinItemSnapshotHelper
{
private const string RawItemKey = "RawItem";
public static void StoreRawItemSnapshot(Song song, JsonElement item)
{
var rawItem = DeserializeDictionary(item.GetRawText());
if (rawItem == null)
{
return;
}
song.JellyfinMetadata ??= new Dictionary<string, object?>();
song.JellyfinMetadata[RawItemKey] = rawItem;
}
public static bool HasRawItemSnapshot(Song? song)
{
return song?.JellyfinMetadata?.ContainsKey(RawItemKey) == true;
}
public static bool TryGetClonedRawItemSnapshot(Song? song, out Dictionary<string, object?> rawItem)
{
rawItem = new Dictionary<string, object?>();
if (song?.JellyfinMetadata == null ||
!song.JellyfinMetadata.TryGetValue(RawItemKey, out var snapshot) ||
snapshot == null)
{
return false;
}
var normalized = snapshot switch
{
Dictionary<string, object?> dict => DeserializeDictionary(JsonSerializer.Serialize(dict)),
JsonElement { ValueKind: JsonValueKind.Object } json => DeserializeDictionary(json.GetRawText()),
_ => DeserializeDictionary(JsonSerializer.Serialize(snapshot))
};
if (normalized == null)
{
return false;
}
rawItem = normalized;
return true;
}
private static Dictionary<string, object?>? DeserializeDictionary(string json)
{
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
}
}
@@ -248,25 +248,6 @@ public class RedisCacheService
}
}
/// <summary>
/// Gets all keys matching a pattern.
/// </summary>
public IEnumerable<string> GetKeysByPattern(string pattern)
{
if (!IsEnabled) return Array.Empty<string>();
try
{
var server = _redis!.GetServer(_redis.GetEndPoints().First());
return server.Keys(pattern: pattern).Select(k => (string)k!);
}
catch (Exception ex)
{
_logger.LogError(ex, "Redis GET KEYS BY PATTERN failed for pattern: {Pattern}", pattern);
return Array.Empty<string>();
}
}
/// <summary>
/// Deletes all keys matching a pattern (e.g., "search:*").
/// WARNING: Use with caution as this scans all keys.
@@ -235,7 +235,8 @@ public class RoundRobinFallbackHelper
}
catch (Exception ex)
{
LogEndpointFailure(baseUrl, ex, willRetry: attempt < orderedEndpoints.Count - 1);
_logger.LogError(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl);
// Mark as unhealthy in cache
lock (_healthCacheLock)
@@ -350,7 +351,8 @@ public class RoundRobinFallbackHelper
}
catch (Exception ex)
{
LogEndpointFailure(baseUrl, ex, willRetry: attempt < orderedEndpoints.Count - 1);
_logger.LogError(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl);
// Mark as unhealthy in cache
lock (_healthCacheLock)
@@ -369,110 +371,10 @@ public class RoundRobinFallbackHelper
return defaultValue;
}
/// <summary>
/// Tries endpoints until one both succeeds and returns an acceptable result.
/// Unacceptable results continue to the next endpoint without poisoning health state.
/// </summary>
public async Task<T> TryWithFallbackAsync<T>(
Func<string, Task<T>> action,
Func<T, bool> isAcceptableResult,
T defaultValue)
{
if (isAcceptableResult == null)
{
throw new ArgumentNullException(nameof(isAcceptableResult));
}
// Get healthy endpoints first (with caching to avoid excessive checks)
var healthyEndpoints = await GetHealthyEndpointsAsync();
// Try healthy endpoints first, then fall back to all if needed
var endpointsToTry = healthyEndpoints.Count < _apiUrls.Count
? healthyEndpoints.Concat(_apiUrls.Except(healthyEndpoints)).ToList()
: healthyEndpoints;
var orderedEndpoints = BuildTryOrder(endpointsToTry);
for (int attempt = 0; attempt < orderedEndpoints.Count; attempt++)
{
var baseUrl = orderedEndpoints[attempt];
try
{
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
_serviceName, baseUrl, attempt + 1, orderedEndpoints.Count);
var result = await action(baseUrl);
if (isAcceptableResult(result))
{
return result;
}
_logger.LogDebug("{Service} endpoint {Endpoint} returned an unacceptable result, trying next...",
_serviceName, baseUrl);
if (attempt == orderedEndpoints.Count - 1)
{
_logger.LogWarning("All {Count} {Service} endpoints returned unacceptable results, returning default value",
orderedEndpoints.Count, _serviceName);
return defaultValue;
}
}
catch (Exception ex)
{
LogEndpointFailure(baseUrl, ex, willRetry: attempt < orderedEndpoints.Count - 1);
lock (_healthCacheLock)
{
_healthCache[baseUrl] = (false, DateTime.UtcNow);
}
if (attempt == orderedEndpoints.Count - 1)
{
_logger.LogError("All {Count} {Service} endpoints failed, returning default value",
orderedEndpoints.Count, _serviceName);
return defaultValue;
}
}
}
return defaultValue;
}
private void LogEndpointFailure(string baseUrl, Exception ex, bool willRetry)
{
var message = BuildFailureSummary(ex);
if (willRetry)
{
_logger.LogWarning("{Service} request failed at {Endpoint}: {Error}. Trying next...",
_serviceName, baseUrl, message);
}
else
{
_logger.LogError("{Service} request failed at {Endpoint}: {Error}",
_serviceName, baseUrl, message);
}
_logger.LogDebug(ex, "{Service} detailed failure for endpoint {Endpoint}",
_serviceName, baseUrl);
}
private static string BuildFailureSummary(Exception ex)
{
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
{
var statusCode = (int)httpRequestException.StatusCode.Value;
return $"{statusCode}: {httpRequestException.StatusCode.Value}";
}
return ex.Message;
}
/// <summary>
/// Processes multiple items in parallel across all available endpoints.
/// Each endpoint processes items sequentially. Failed endpoints are blacklisted.
/// </summary>
/// </summary>
public async Task<List<TResult>> ProcessInParallelAsync<TItem, TResult>(
List<TItem> items,
Func<string, TItem, CancellationToken, Task<TResult>> action,
@@ -1,235 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
namespace allstarr.Services.Common;
/// <summary>
/// Loads supported flat .env keys into ASP.NET configuration so Docker/admin UI
/// updates stored in /app/.env take effect on the next application startup.
/// </summary>
public static class RuntimeEnvConfiguration
{
private static readonly IReadOnlyDictionary<string, string[]> ExactKeyMappings =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["BACKEND_TYPE"] = ["Backend:Type"],
["ADMIN_BIND_ANY_IP"] = ["Admin:BindAnyIp"],
["ADMIN_TRUSTED_SUBNETS"] = ["Admin:TrustedSubnets"],
["ADMIN_ENABLE_ENV_EXPORT"] = ["Admin:EnableEnvExport"],
["CORS_ALLOWED_ORIGINS"] = ["Cors:AllowedOrigins"],
["CORS_ALLOWED_METHODS"] = ["Cors:AllowedMethods"],
["CORS_ALLOWED_HEADERS"] = ["Cors:AllowedHeaders"],
["CORS_ALLOW_CREDENTIALS"] = ["Cors:AllowCredentials"],
["SUBSONIC_URL"] = ["Subsonic:Url"],
["JELLYFIN_URL"] = ["Jellyfin:Url"],
["JELLYFIN_API_KEY"] = ["Jellyfin:ApiKey"],
["JELLYFIN_USER_ID"] = ["Jellyfin:UserId"],
["JELLYFIN_CLIENT_USERNAME"] = ["Jellyfin:ClientUsername"],
["JELLYFIN_LIBRARY_ID"] = ["Jellyfin:LibraryId"],
["LIBRARY_DOWNLOAD_PATH"] = ["Library:DownloadPath"],
["LIBRARY_KEPT_PATH"] = ["Library:KeptPath"],
["REDIS_ENABLED"] = ["Redis:Enabled"],
["REDIS_CONNECTION_STRING"] = ["Redis:ConnectionString"],
["SPOTIFY_IMPORT_ENABLED"] = ["SpotifyImport:Enabled"],
["SPOTIFY_IMPORT_SYNC_START_HOUR"] = ["SpotifyImport:SyncStartHour"],
["SPOTIFY_IMPORT_SYNC_START_MINUTE"] = ["SpotifyImport:SyncStartMinute"],
["SPOTIFY_IMPORT_SYNC_WINDOW_HOURS"] = ["SpotifyImport:SyncWindowHours"],
["SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS"] = ["SpotifyImport:MatchingIntervalHours"],
["SPOTIFY_IMPORT_PLAYLISTS"] = ["SpotifyImport:Playlists"],
["SPOTIFY_IMPORT_PLAYLIST_IDS"] = ["SpotifyImport:PlaylistIds"],
["SPOTIFY_IMPORT_PLAYLIST_NAMES"] = ["SpotifyImport:PlaylistNames"],
["SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS"] = ["SpotifyImport:PlaylistLocalTracksPositions"],
["SPOTIFY_API_ENABLED"] = ["SpotifyApi:Enabled"],
["SPOTIFY_API_SESSION_COOKIE"] = ["SpotifyApi:SessionCookie"],
["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = ["SpotifyApi:SessionCookieSetDate"],
["SPOTIFY_API_CACHE_DURATION_MINUTES"] = ["SpotifyApi:CacheDurationMinutes"],
["SPOTIFY_API_RATE_LIMIT_DELAY_MS"] = ["SpotifyApi:RateLimitDelayMs"],
["SPOTIFY_API_PREFER_ISRC_MATCHING"] = ["SpotifyApi:PreferIsrcMatching"],
["SPOTIFY_LYRICS_API_URL"] = ["SpotifyApi:LyricsApiUrl"],
["SCROBBLING_ENABLED"] = ["Scrobbling:Enabled"],
["SCROBBLING_LOCAL_TRACKS_ENABLED"] = ["Scrobbling:LocalTracksEnabled"],
["SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED"] = ["Scrobbling:SyntheticLocalPlayedSignalEnabled"],
["SCROBBLING_LASTFM_ENABLED"] = ["Scrobbling:LastFm:Enabled"],
["SCROBBLING_LASTFM_API_KEY"] = ["Scrobbling:LastFm:ApiKey"],
["SCROBBLING_LASTFM_SHARED_SECRET"] = ["Scrobbling:LastFm:SharedSecret"],
["SCROBBLING_LASTFM_SESSION_KEY"] = ["Scrobbling:LastFm:SessionKey"],
["SCROBBLING_LASTFM_USERNAME"] = ["Scrobbling:LastFm:Username"],
["SCROBBLING_LASTFM_PASSWORD"] = ["Scrobbling:LastFm:Password"],
["SCROBBLING_LISTENBRAINZ_ENABLED"] = ["Scrobbling:ListenBrainz:Enabled"],
["SCROBBLING_LISTENBRAINZ_USER_TOKEN"] = ["Scrobbling:ListenBrainz:UserToken"],
["DEBUG_LOG_ALL_REQUESTS"] = ["Debug:LogAllRequests"],
["DEBUG_REDACT_SENSITIVE_REQUEST_VALUES"] = ["Debug:RedactSensitiveRequestValues"],
["DEEZER_ARL"] = ["Deezer:Arl"],
["DEEZER_ARL_FALLBACK"] = ["Deezer:ArlFallback"],
["DEEZER_QUALITY"] = ["Deezer:Quality"],
["DEEZER_MIN_REQUEST_INTERVAL_MS"] = ["Deezer:MinRequestIntervalMs"],
["QOBUZ_USER_AUTH_TOKEN"] = ["Qobuz:UserAuthToken"],
["QOBUZ_USER_ID"] = ["Qobuz:UserId"],
["QOBUZ_QUALITY"] = ["Qobuz:Quality"],
["QOBUZ_MIN_REQUEST_INTERVAL_MS"] = ["Qobuz:MinRequestIntervalMs"],
["SQUIDWTF_QUALITY"] = ["SquidWTF:Quality"],
["SQUIDWTF_MIN_REQUEST_INTERVAL_MS"] = ["SquidWTF:MinRequestIntervalMs"],
["MUSICBRAINZ_ENABLED"] = ["MusicBrainz:Enabled"],
["MUSICBRAINZ_USERNAME"] = ["MusicBrainz:Username"],
["MUSICBRAINZ_PASSWORD"] = ["MusicBrainz:Password"],
["CACHE_SEARCH_RESULTS_MINUTES"] = ["Cache:SearchResultsMinutes"],
["CACHE_PLAYLIST_IMAGES_HOURS"] = ["Cache:PlaylistImagesHours"],
["CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS"] = ["Cache:SpotifyPlaylistItemsHours"],
["CACHE_SPOTIFY_MATCHED_TRACKS_DAYS"] = ["Cache:SpotifyMatchedTracksDays"],
["CACHE_LYRICS_DAYS"] = ["Cache:LyricsDays"],
["CACHE_GENRE_DAYS"] = ["Cache:GenreDays"],
["CACHE_METADATA_DAYS"] = ["Cache:MetadataDays"],
["CACHE_ODESLI_LOOKUP_DAYS"] = ["Cache:OdesliLookupDays"],
["CACHE_PROXY_IMAGES_DAYS"] = ["Cache:ProxyImagesDays"],
["CACHE_TRANSCODE_MINUTES"] = ["Cache:TranscodeCacheMinutes"]
};
private static readonly IReadOnlyDictionary<string, string[]> SharedBackendKeyMappings =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["MUSIC_SERVICE"] = ["Subsonic:MusicService", "Jellyfin:MusicService"],
["EXPLICIT_FILTER"] = ["Subsonic:ExplicitFilter", "Jellyfin:ExplicitFilter"],
["DOWNLOAD_MODE"] = ["Subsonic:DownloadMode", "Jellyfin:DownloadMode"],
["STORAGE_MODE"] = ["Subsonic:StorageMode", "Jellyfin:StorageMode"],
["CACHE_DURATION_HOURS"] = ["Subsonic:CacheDurationHours", "Jellyfin:CacheDurationHours"],
["ENABLE_EXTERNAL_PLAYLISTS"] = ["Subsonic:EnableExternalPlaylists", "Jellyfin:EnableExternalPlaylists"],
["PLAYLISTS_DIRECTORY"] = ["Subsonic:PlaylistsDirectory", "Jellyfin:PlaylistsDirectory"]
};
private static readonly HashSet<string> IgnoredComposeOnlyKeys = new(StringComparer.OrdinalIgnoreCase)
{
"DOWNLOAD_PATH",
"KEPT_PATH",
"CACHE_PATH",
"REDIS_DATA_PATH"
};
public static string ResolveEnvFilePath(IHostEnvironment environment)
{
return environment.IsDevelopment()
? Path.GetFullPath(Path.Combine(environment.ContentRootPath, "..", ".env"))
: "/app/.env";
}
public static void AddDotEnvOverrides(
ConfigurationManager configuration,
IHostEnvironment environment,
TextWriter? logWriter = null)
{
AddDotEnvOverrides(configuration, ResolveEnvFilePath(environment), logWriter);
}
public static void AddDotEnvOverrides(
ConfigurationManager configuration,
string envFilePath,
TextWriter? logWriter = null)
{
var overrides = LoadDotEnvOverrides(envFilePath);
if (overrides.Count == 0)
{
if (File.Exists(envFilePath))
{
logWriter?.WriteLine($"No supported runtime overrides found in {envFilePath}");
}
return;
}
configuration.AddInMemoryCollection(overrides);
logWriter?.WriteLine($"Loaded {overrides.Count} runtime override(s) from {envFilePath}");
}
public static Dictionary<string, string?> LoadDotEnvOverrides(string envFilePath)
{
var overrides = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(envFilePath))
{
return overrides;
}
foreach (var line in File.ReadLines(envFilePath))
{
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
{
continue;
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}
var envKey = line[..separatorIndex].Trim();
var envValue = StripQuotes(line[(separatorIndex + 1)..].Trim());
foreach (var mapping in MapEnvVarToConfiguration(envKey, envValue))
{
overrides[mapping.Key] = mapping.Value;
}
}
return overrides;
}
public static IEnumerable<KeyValuePair<string, string?>> MapEnvVarToConfiguration(string envKey, string? envValue)
{
if (string.IsNullOrWhiteSpace(envKey) || IgnoredComposeOnlyKeys.Contains(envKey))
{
yield break;
}
if (envKey.Contains("__", StringComparison.Ordinal))
{
yield return new KeyValuePair<string, string?>(envKey.Replace("__", ":"), envValue);
yield break;
}
if (SharedBackendKeyMappings.TryGetValue(envKey, out var sharedKeys))
{
foreach (var sharedKey in sharedKeys)
{
yield return new KeyValuePair<string, string?>(sharedKey, envValue);
}
yield break;
}
if (ExactKeyMappings.TryGetValue(envKey, out var configKeys))
{
foreach (var configKey in configKeys)
{
yield return new KeyValuePair<string, string?>(configKey, envValue);
}
}
}
private static string StripQuotes(string? value)
{
if (string.IsNullOrEmpty(value))
{
return value ?? string.Empty;
}
if (value.StartsWith('"') && value.EndsWith('"') && value.Length >= 2)
{
return value[1..^1];
}
return value;
}
}
@@ -1,100 +0,0 @@
using System.Text.Json;
using allstarr.Models.Spotify;
namespace allstarr.Services.Common;
/// <summary>
/// Computes displayed counts for injected Spotify playlists.
/// </summary>
public static class SpotifyPlaylistCountHelper
{
public static int CountExternalMatchedTracks(IEnumerable<MatchedTrack>? matchedTracks)
{
if (matchedTracks == null)
{
return 0;
}
return matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
}
public static int ComputeServedItemCount(
int? exactCachedPlaylistItemsCount,
int localTracksCount,
IEnumerable<MatchedTrack>? matchedTracks)
{
if (exactCachedPlaylistItemsCount.HasValue && exactCachedPlaylistItemsCount.Value > 0)
{
return exactCachedPlaylistItemsCount.Value;
}
return Math.Max(0, localTracksCount) + CountExternalMatchedTracks(matchedTracks);
}
public static long SumExternalMatchedRunTimeTicks(IEnumerable<MatchedTrack>? matchedTracks)
{
if (matchedTracks == null)
{
return 0;
}
return matchedTracks
.Where(t => t.MatchedSong != null && !t.MatchedSong.IsLocal)
.Sum(t => Math.Max(0, (long)(t.MatchedSong.Duration ?? 0) * TimeSpan.TicksPerSecond));
}
public static long SumCachedPlaylistRunTimeTicks(IEnumerable<Dictionary<string, object?>>? cachedPlaylistItems)
{
if (cachedPlaylistItems == null)
{
return 0;
}
long total = 0;
foreach (var item in cachedPlaylistItems)
{
item.TryGetValue("RunTimeTicks", out var runTimeTicks);
total += ExtractRunTimeTicks(runTimeTicks);
}
return total;
}
public static long ComputeServedRunTimeTicks(
long? exactCachedPlaylistRunTimeTicks,
long localPlaylistRunTimeTicks,
IEnumerable<MatchedTrack>? matchedTracks)
{
if (exactCachedPlaylistRunTimeTicks.HasValue)
{
return Math.Max(0, exactCachedPlaylistRunTimeTicks.Value);
}
return Math.Max(0, localPlaylistRunTimeTicks) + SumExternalMatchedRunTimeTicks(matchedTracks);
}
public static long ExtractRunTimeTicks(object? rawValue)
{
return rawValue switch
{
null => 0,
long longValue => Math.Max(0, longValue),
int intValue => Math.Max(0, intValue),
double doubleValue => Math.Max(0, (long)doubleValue),
decimal decimalValue => Math.Max(0, (long)decimalValue),
string stringValue when long.TryParse(stringValue, out var parsed) => Math.Max(0, parsed),
JsonElement jsonElement => ExtractJsonRunTimeTicks(jsonElement),
_ => 0
};
}
private static long ExtractJsonRunTimeTicks(JsonElement jsonElement)
{
return jsonElement.ValueKind switch
{
JsonValueKind.Number when jsonElement.TryGetInt64(out var longValue) => Math.Max(0, longValue),
JsonValueKind.String when long.TryParse(jsonElement.GetString(), out var parsed) => Math.Max(0, parsed),
_ => 0
};
}
}
@@ -1,110 +0,0 @@
namespace allstarr.Services.Common;
/// <summary>
/// Represents the quality tier requested by a client for streaming.
/// Used to map client transcoding parameters to provider-specific quality levels.
/// The .env quality setting acts as a ceiling — client requests can only go equal or lower.
/// </summary>
public enum StreamQuality
{
/// <summary>
/// Use the quality configured in .env / appsettings (default behavior).
/// This is the "Lossless" / "no transcoding" selection in a client.
/// </summary>
Original,
/// <summary>
/// High quality lossy (e.g., 320kbps AAC/MP3).
/// Covers client selections: 320K, 256K, 192K.
/// Maps to: SquidWTF HIGH, Deezer MP3_320, Qobuz MP3_320.
/// </summary>
High,
/// <summary>
/// Low quality lossy (e.g., 96-128kbps AAC/MP3).
/// Covers client selections: 128K, 64K.
/// Maps to: SquidWTF LOW, Deezer MP3_128, Qobuz MP3_320 (lowest available).
/// </summary>
Low
}
/// <summary>
/// Parses Jellyfin client transcoding query parameters to determine
/// the requested stream quality tier for external tracks.
///
/// Typical client quality options: Lossless, 320K, 256K, 192K, 128K, 64K
/// These are mapped to StreamQuality tiers which providers then translate
/// to their own quality levels, capped at the .env ceiling.
/// </summary>
public static class StreamQualityHelper
{
/// <summary>
/// Parses the request query string to determine what quality the client wants.
/// Jellyfin clients send parameters like AudioBitRate, MaxStreamingBitrate,
/// AudioCodec, TranscodingContainer when requesting transcoded streams.
/// </summary>
public static StreamQuality ParseFromQueryString(IQueryCollection query)
{
// Check for explicit audio bitrate (e.g., AudioBitRate=128000)
if (query.TryGetValue("AudioBitRate", out var audioBitRateVal) &&
int.TryParse(audioBitRateVal.FirstOrDefault(), out var audioBitRate))
{
return MapBitRateToQuality(audioBitRate);
}
// Check for MaxStreamingBitrate (e.g., MaxStreamingBitrate=140000000 for lossless)
if (query.TryGetValue("MaxStreamingBitrate", out var maxBitrateVal) &&
long.TryParse(maxBitrateVal.FirstOrDefault(), out var maxBitrate))
{
// Very high values (>= 10Mbps) indicate lossless / no transcoding
if (maxBitrate >= 10_000_000)
{
return StreamQuality.Original;
}
return MapBitRateToQuality((int)(maxBitrate / 1000));
}
// Check for audioBitRate (lowercase variant used by some clients)
if (query.TryGetValue("audioBitRate", out var audioBitRateLower) &&
int.TryParse(audioBitRateLower.FirstOrDefault(), out var audioBitRateLowerVal))
{
return MapBitRateToQuality(audioBitRateLowerVal);
}
// Check TranscodingContainer — if client requests mp3/aac, they want lossy
if (query.TryGetValue("TranscodingContainer", out var container))
{
var containerStr = container.FirstOrDefault()?.ToLowerInvariant();
if (containerStr is "mp3" or "aac" or "m4a")
{
// Container specified but no bitrate — default to High (320kbps)
return StreamQuality.High;
}
}
// No transcoding parameters — use original quality from .env
return StreamQuality.Original;
}
/// <summary>
/// Maps a bitrate value (in bps) to a StreamQuality tier.
/// Client options are typically: Lossless, 320K, 256K, 192K, 128K, 64K
///
/// >= 192kbps → High (covers 320K, 256K, 192K selections)
/// &lt; 192kbps → Low (covers 128K, 64K selections)
/// </summary>
private static StreamQuality MapBitRateToQuality(int bitRate)
{
// >= 192kbps → High (320kbps tier)
// Covers client selections: 320K, 256K, 192K
if (bitRate >= 192_000)
{
return StreamQuality.High;
}
// < 192kbps → Low (96-128kbps tier)
// Covers client selections: 128K, 64K
return StreamQuality.Low;
}
}
@@ -14,8 +14,6 @@ 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,
@@ -55,12 +53,15 @@ public class VersionUpgradeRebuildService : IHostedService
}
else
{
_logger.LogInformation(
"Scheduling full rebuild for all playlists in background after version upgrade");
_backgroundRebuildCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_backgroundRebuildTask = RunBackgroundRebuildAsync(currentVersion, _backgroundRebuildCts.Token);
return;
_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");
}
}
}
else
@@ -75,51 +76,7 @@ public class VersionUpgradeRebuildService : IHostedService
public Task StopAsync(CancellationToken cancellationToken)
{
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;
}
return Task.CompletedTask;
}
private async Task<string?> ReadPreviousVersionAsync(CancellationToken cancellationToken)
+10 -144
View File
@@ -57,7 +57,6 @@ public class DeezerDownloadService : BaseDownloadService
_arl = deezer.Arl;
_arlFallback = deezer.ArlFallback;
_preferredQuality = deezer.Quality;
_minRequestIntervalMs = deezer.MinRequestIntervalMs;
}
#region BaseDownloadService Implementation
@@ -99,9 +98,10 @@ public class DeezerDownloadService : BaseDownloadService
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist;
var basePath = CurrentStorageMode == StorageMode.Cache
? Path.Combine(DownloadPath, "cache")
: Path.Combine(DownloadPath, "permanent");
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine("downloads", "cache")
: Path.Combine("downloads", "permanent");
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "deezer", trackId);
// Create directories if they don't exist
@@ -118,11 +118,11 @@ public class DeezerDownloadService : BaseDownloadService
request.Headers.Add("User-Agent", "Mozilla/5.0");
request.Headers.Add("Accept", "*/*");
var res = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
return res;
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
}, Logger);
response.EnsureSuccessStatusCode();
// Download and decrypt
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
@@ -140,140 +140,6 @@ public class DeezerDownloadService : BaseDownloadService
#endregion
#region Quality Override Support
/// <summary>
/// Downloads a track at a specific quality tier, capped at the .env quality ceiling.
/// Deezer quality hierarchy: FLAC > MP3_320 > MP3_128
///
/// Examples:
/// env=FLAC: Original→FLAC, High→MP3_320, Low→MP3_128
/// env=MP3_320: Original→MP3_320, High→MP3_320, Low→MP3_128
/// env=MP3_128: Original→MP3_128, High→MP3_128, Low→MP3_128
/// </summary>
protected override async Task<string> DownloadTrackWithQualityAsync(
string trackId, Song song, StreamQuality quality, CancellationToken cancellationToken)
{
if (quality == StreamQuality.Original)
{
return await DownloadTrackAsync(trackId, song, cancellationToken);
}
// Map StreamQuality to Deezer quality, capped at .env ceiling
var envQuality = NormalizeDeezerQuality(_preferredQuality);
var deezerQuality = MapStreamQualityToDeezer(quality, envQuality);
Logger.LogInformation(
"Quality override: StreamQuality.{Quality} → Deezer quality '{DeezerQuality}' (env ceiling: {EnvQuality}) for track {TrackId}",
quality, deezerQuality, envQuality, trackId);
// Use the existing download logic with the overridden quality
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken, deezerQuality);
Logger.LogInformation(
"Quality override download info resolved (Format: {Format})",
downloadInfo.Format);
// Determine extension based on format
var extension = downloadInfo.Format?.ToUpper() switch
{
"FLAC" => ".flac",
_ => ".mp3"
};
// Write to transcoded cache directory: {downloads}/transcoded/Artist/Album/song.ext
// These files are cleaned up by CacheCleanupService based on CACHE_TRANSCODE_MINUTES TTL
var artistForPath = song.AlbumArtist ?? song.Artist;
var basePath = Path.Combine("downloads", "transcoded");
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "deezer", trackId);
// Create directories if they don't exist
var albumFolder = Path.GetDirectoryName(outputPath)!;
EnsureDirectoryExists(albumFolder);
// If the file already exists in transcoded cache, return it directly
if (IOFile.Exists(outputPath))
{
// Touch the file to extend its cache lifetime
IOFile.SetLastWriteTime(outputPath, DateTime.UtcNow);
Logger.LogInformation("Quality override cache hit: {Path}", outputPath);
return outputPath;
}
// Download the encrypted file
var response = await RetryHelper.RetryWithBackoffAsync(async () =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
request.Headers.Add("User-Agent", "Mozilla/5.0");
request.Headers.Add("Accept", "*/*");
var res = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
return res;
}, Logger);
// Download and decrypt (Deezer uses Blowfish CBC encryption)
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
await DecryptAndWriteStreamAsync(responseStream, outputFile, trackId, cancellationToken);
// Close file before writing metadata
await outputFile.DisposeAsync();
// Write metadata and cover art
await WriteMetadataAsync(outputPath, song, cancellationToken);
return outputPath;
}
/// <summary>
/// Normalizes the .env quality string to a standard Deezer quality level.
/// </summary>
private static string NormalizeDeezerQuality(string? quality)
{
if (string.IsNullOrEmpty(quality)) return "FLAC";
return quality.ToUpperInvariant() switch
{
"FLAC" => "FLAC",
"MP3_320" or "320" => "MP3_320",
"MP3_128" or "128" => "MP3_128",
_ => "FLAC"
};
}
/// <summary>
/// Maps a StreamQuality tier to a Deezer quality string, capped at the .env ceiling.
/// </summary>
private static string MapStreamQualityToDeezer(StreamQuality streamQuality, string envQuality)
{
// Quality ranking from highest to lowest
var ranking = new[] { "FLAC", "MP3_320", "MP3_128" };
var envIndex = Array.IndexOf(ranking, envQuality);
if (envIndex < 0) envIndex = 0; // Default to FLAC if unknown
var idealQuality = streamQuality switch
{
StreamQuality.Original => envQuality,
StreamQuality.High => "MP3_320",
StreamQuality.Low => "MP3_128",
_ => envQuality
};
// Cap at env ceiling (lower index = higher quality)
var idealIndex = Array.IndexOf(ranking, idealQuality);
if (idealIndex < 0) idealIndex = envIndex;
if (idealIndex < envIndex)
{
return envQuality;
}
return idealQuality;
}
#endregion
#region Deezer API Methods
private async Task InitializeAsync(string? arlOverride = null)
@@ -319,7 +185,7 @@ public class DeezerDownloadService : BaseDownloadService
}, Logger);
}
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken, string? qualityOverride = null)
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
{
var tryDownload = async (string arl) =>
{
@@ -347,8 +213,8 @@ public class DeezerDownloadService : BaseDownloadService
: "";
// Get download URL via media API
// Build format list based on preferred quality (or overridden quality for transcoding)
var formatsList = BuildFormatsList(qualityOverride ?? _preferredQuality);
// Build format list based on preferred quality
var formatsList = BuildFormatsList(_preferredQuality);
var mediaRequest = new
{
@@ -62,19 +62,6 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
}
}
public async Task<Song?> FindSongByIsrcAsync(string isrc, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isrc))
{
return null;
}
var results = await SearchSongsAsync(isrc, limit: 5, cancellationToken);
return results.FirstOrDefault(song =>
!string.IsNullOrWhiteSpace(song.Isrc) &&
song.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase));
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
try
@@ -135,15 +122,10 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
{
var songsTask = songLimit > 0
? SearchSongsAsync(query, songLimit, cancellationToken)
: Task.FromResult(new List<Song>());
var albumsTask = albumLimit > 0
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
: Task.FromResult(new List<Album>());
var artistsTask = artistLimit > 0
? SearchArtistsAsync(query, artistLimit, cancellationToken)
: Task.FromResult(new List<Artist>());
// Execute searches in parallel
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
await Task.WhenAll(songsTask, albumsTask, artistsTask);
+2 -11
View File
@@ -21,17 +21,13 @@ public interface IDownloadService
Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Downloads a song and streams the result progressively.
/// When qualityOverride is specified (not null and not Original), downloads at the requested
/// quality tier instead of the configured .env quality. Used for client-requested "transcoding".
/// The .env quality acts as a ceiling — client requests can only go equal or lower.
/// Downloads a song and streams the result progressively
/// </summary>
/// <param name="externalProvider">The provider (deezer, spotify)</param>
/// <param name="externalId">The ID on the external provider</param>
/// <param name="qualityOverride">Optional quality tier override for streaming (null = use .env quality)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>A stream of the audio file</returns>
Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, Common.StreamQuality? qualityOverride = null, CancellationToken cancellationToken = default);
Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Downloads remaining tracks from an album in background (excluding the specified track)
@@ -46,11 +42,6 @@ public interface IDownloadService
/// </summary>
DownloadInfo? GetDownloadStatus(string songId);
/// <summary>
/// Gets a snapshot of all active/recent downloads for the activity feed
/// </summary>
IReadOnlyList<DownloadInfo> GetActiveDownloads();
/// <summary>
/// Gets the local path for a song if it has been downloaded already
/// </summary>
@@ -40,11 +40,6 @@ public interface IMusicMetadataService
/// Gets details of an external song
/// </summary>
Task<Song?> GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to find a song by ISRC using the provider's most exact lookup path.
/// </summary>
Task<Song?> FindSongByIsrcAsync(string isrc, CancellationToken cancellationToken = default);
/// <summary>
/// Gets details of an external album with its songs
@@ -2,7 +2,6 @@ using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Common;
namespace allstarr.Services.Jellyfin;
@@ -187,13 +186,10 @@ public class JellyfinModelMapper
// Cover art URL construction
song.CoverArtUrl = $"/Items/{id}/Images/Primary";
// Preserve the full raw item so cached local matches can be replayed without losing fields.
JellyfinItemSnapshotHelper.StoreRawItemSnapshot(song, item);
// Preserve Jellyfin metadata (MediaSources, etc.) for local tracks.
// This ensures bitrate and other technical details are maintained.
song.JellyfinMetadata ??= new Dictionary<string, object?>();
// Preserve Jellyfin metadata (MediaSources, etc.) for local tracks
// This ensures bitrate and other technical details are maintained
song.JellyfinMetadata = new Dictionary<string, object?>();
if (item.TryGetProperty("MediaSources", out var mediaSources))
{
song.JellyfinMetadata["MediaSources"] = JsonSerializer.Deserialize<object>(mediaSources.GetRawText());
+167 -260
View File
@@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Common;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
@@ -10,17 +9,9 @@ namespace allstarr.Services.Jellyfin;
/// <summary>
/// Handles proxying requests to the Jellyfin server and authentication.
/// Uses a named HttpClient ("JellyfinBackend") with SocketsHttpHandler for
/// TCP connection pooling across scoped instances.
/// </summary>
public class JellyfinProxyService
{
/// <summary>
/// The IHttpClientFactory registration name for the Jellyfin backend client.
/// Configured with SocketsHttpHandler for connection pooling in Program.cs.
/// </summary>
public const string HttpClientName = "JellyfinBackend";
private readonly HttpClient _httpClient;
private readonly JellyfinSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor;
@@ -39,7 +30,7 @@ public class JellyfinProxyService
ILogger<JellyfinProxyService> logger,
RedisCacheService cache)
{
_httpClient = httpClientFactory.CreateClient(HttpClientName);
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
@@ -123,37 +114,27 @@ public class JellyfinProxyService
var baseEndpoint = parts[0];
var existingQuery = parts[1];
// Fast path: preserve the caller's raw query string exactly as provided.
// This is required for endpoints that legitimately repeat keys like Fields=...
if (queryParams == null || queryParams.Count == 0)
{
return await GetJsonAsyncInternal(BuildUrl(endpoint), clientHeaders);
}
var preservedParams = new List<string>();
foreach (var param in existingQuery.Split('&', StringSplitOptions.RemoveEmptyEntries))
// Parse existing query string
var mergedParams = new Dictionary<string, string>();
foreach (var param in existingQuery.Split('&'))
{
var kv = param.Split('=', 2);
var key = kv.Length > 0 ? Uri.UnescapeDataString(kv[0]) : string.Empty;
// Explicit query params override every existing value for the same key.
if (!string.IsNullOrEmpty(key) && queryParams.ContainsKey(key))
if (kv.Length == 2)
{
continue;
mergedParams[Uri.UnescapeDataString(kv[0])] = Uri.UnescapeDataString(kv[1]);
}
preservedParams.Add(param);
}
var explicitParams = queryParams.Select(kv =>
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}");
var mergedQuery = string.Join("&", preservedParams.Concat(explicitParams));
var url = string.IsNullOrEmpty(mergedQuery)
? BuildUrl(baseEndpoint)
: $"{BuildUrl(baseEndpoint)}?{mergedQuery}";
// Merge with provided queryParams (provided params take precedence)
if (queryParams != null)
{
foreach (var kv in queryParams)
{
mergedParams[kv.Key] = kv.Value;
}
}
var url = BuildUrl(baseEndpoint, mergedParams);
return await GetJsonAsyncInternal(url, clientHeaders);
}
@@ -161,78 +142,9 @@ public class JellyfinProxyService
return await GetJsonAsyncInternal(finalUrl, clientHeaders);
}
/// <summary>
/// Sends a proxied GET request to Jellyfin and returns the raw upstream response without buffering the body.
/// Intended for transparent passthrough of large JSON payloads that Allstarr does not modify.
/// </summary>
public async Task<HttpResponseMessage> GetPassthroughResponseAsync(
string endpoint,
IHeaderDictionary? clientHeaders = null,
CancellationToken cancellationToken = default)
{
var url = BuildUrl(endpoint);
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
ForwardPassthroughRequestHeaders(clientHeaders, request);
var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode && !isBrowserStaticRequest && !isPublicEndpoint)
{
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
}
return response;
}
private async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders)
{
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
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)
{
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
}
// Try to parse error response to pass through to client
if (!string.IsNullOrWhiteSpace(content))
{
try
{
var errorDoc = JsonDocument.Parse(content);
return (errorDoc, statusCode);
}
catch
{
// Not valid JSON, return null
}
}
return (null, statusCode);
}
return (JsonDocument.Parse(content), statusCode);
}
private HttpRequestMessage CreateClientGetRequest(
string url,
IHeaderDictionary? clientHeaders,
out bool isBrowserStaticRequest,
out bool isPublicEndpoint)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
@@ -245,21 +157,21 @@ public class JellyfinProxyService
}
}
bool authHeaderAdded = false;
// Check if this is a browser request for static assets (favicon, etc.)
isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/web/", StringComparison.OrdinalIgnoreCase) ||
(clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) &&
h.Value.ToString().Contains("Mozilla", StringComparison.OrdinalIgnoreCase)) == true &&
clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) &&
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
bool isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/web/", StringComparison.OrdinalIgnoreCase) ||
(clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) &&
h.Value.ToString().Contains("Mozilla", StringComparison.OrdinalIgnoreCase)) == true &&
clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) &&
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
// Check if this is a public endpoint that doesn't require authentication
isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
var authHeaderAdded = false;
bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
// Forward authentication headers from client if provided
if (clientHeaders != null && clientHeaders.Count > 0)
@@ -286,35 +198,45 @@ public class JellyfinProxyService
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
return request;
}
private static void ForwardPassthroughRequestHeaders(
IHeaderDictionary? clientHeaders,
HttpRequestMessage request)
{
if (clientHeaders == null || clientHeaders.Count == 0)
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)
{
return;
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
// 401 means token expired or invalid - client needs to re-authenticate
_logger.LogDebug("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
}
else if (!isBrowserStaticRequest && !isPublicEndpoint)
{
_logger.LogError("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
}
// Try to parse error response to pass through to client
if (!string.IsNullOrWhiteSpace(content))
{
try
{
var errorDoc = JsonDocument.Parse(content);
return (errorDoc, statusCode);
}
catch
{
// Not valid JSON, return null
}
}
return (null, statusCode);
}
if (clientHeaders.TryGetValue("Accept-Encoding", out var acceptEncoding) &&
acceptEncoding.Count > 0)
{
request.Headers.TryAddWithoutValidation("Accept-Encoding", acceptEncoding.ToArray());
}
if (clientHeaders.TryGetValue("User-Agent", out var userAgent) &&
userAgent.Count > 0)
{
request.Headers.TryAddWithoutValidation("User-Agent", userAgent.ToArray());
}
if (clientHeaders.TryGetValue("Accept-Language", out var acceptLanguage) &&
acceptLanguage.Count > 0)
{
request.Headers.TryAddWithoutValidation("Accept-Language", acceptLanguage.ToArray());
}
return (JsonDocument.Parse(content), statusCode);
}
/// <summary>
@@ -323,31 +245,10 @@ public class JellyfinProxyService
/// Returns the response body and HTTP status code.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders)
{
var bodyToSend = body;
if (string.IsNullOrWhiteSpace(bodyToSend))
{
bodyToSend = "{}";
_logger.LogWarning("POST body was empty for {Endpoint}, sending empty JSON object", endpoint);
}
return await SendAsync(HttpMethod.Post, endpoint, bodyToSend, clientHeaders, "application/json");
}
/// <summary>
/// Sends an arbitrary HTTP request to Jellyfin while preserving the caller's method and body semantics.
/// Intended for transparent proxy scenarios such as session control routes.
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> SendAsync(
HttpMethod method,
string endpoint,
string? body,
IHeaderDictionary clientHeaders,
string? contentType = null)
{
var url = BuildUrl(endpoint, null);
using var request = new HttpRequestMessage(method, url);
using var request = new HttpRequestMessage(HttpMethod.Post, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
@@ -360,62 +261,68 @@ public class JellyfinProxyService
}
}
if (body != null)
// Handle special case for playback endpoints
// NOTE: Jellyfin API expects PlaybackStartInfo/PlaybackProgressInfo/PlaybackStopInfo
// DIRECTLY as the body, NOT wrapped in a field. Do NOT wrap the body.
var bodyToSend = body;
if (string.IsNullOrWhiteSpace(body))
{
var requestContent = new StringContent(body, System.Text.Encoding.UTF8);
try
{
requestContent.Headers.ContentType = !string.IsNullOrWhiteSpace(contentType)
? MediaTypeHeaderValue.Parse(contentType)
: new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
}
catch (FormatException)
{
_logger.LogWarning("Invalid content type '{ContentType}' for {Method} {Endpoint}; falling back to application/json",
contentType,
method,
endpoint);
requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
}
request.Content = requestContent;
bodyToSend = "{}";
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
}
var authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
var isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
bool authHeaderAdded = false;
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
// Forward authentication headers from client
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
if (authHeaderAdded)
{
_logger.LogTrace("Forwarded authentication headers");
}
else if (!isAuthEndpoint)
// For authentication endpoints, credentials are in the body, not headers
// For other endpoints without auth, let Jellyfin reject the request
if (!authHeaderAdded && !isAuthEndpoint)
{
_logger.LogDebug("No client auth provided for {Method} {Url} - Jellyfin will handle authentication", method, url);
_logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// DO NOT log the body for auth endpoints - it contains passwords!
if (isAuthEndpoint)
{
_logger.LogDebug("{Method} to Jellyfin: {Url} (auth request - body not logged)", method, url);
}
else if (body == null)
{
_logger.LogTrace("{Method} to Jellyfin: {Url} (no request body)", method, url);
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
}
else
{
_logger.LogTrace("{Method} to Jellyfin: {Url}, body length: {Length} bytes", method, url, body.Length);
_logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
}
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
LogUpstreamFailure(method, response.StatusCode, url, errorContent);
// 401 is expected when tokens expire - don't spam logs
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_logger.LogDebug("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
}
else
{
_logger.LogError("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent);
}
// Try to parse error response as JSON to pass through to client
if (!string.IsNullOrWhiteSpace(errorContent))
{
try
@@ -432,17 +339,21 @@ public class JellyfinProxyService
return (null, statusCode);
}
// Log successful session-related responses
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
{
_logger.LogTrace("Jellyfin responded {StatusCode} for {Method} {Endpoint}", statusCode, method, endpoint);
_logger.LogTrace("Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
}
if (response.StatusCode == HttpStatusCode.NoContent)
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return (null, statusCode);
}
var responseContent = await response.Content.ReadAsStringAsync();
// Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent))
{
return (null, statusCode);
@@ -504,7 +415,66 @@ public class JellyfinProxyService
/// </summary>
public async Task<(JsonDocument? Body, int StatusCode)> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
{
return await SendAsync(HttpMethod.Delete, endpoint, null, clientHeaders);
var url = BuildUrl(endpoint, null);
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
{
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(clientIp))
{
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
}
}
bool authHeaderAdded = false;
// Forward authentication headers from client
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
if (!authHeaderAdded)
{
_logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url);
}
else
{
_logger.LogTrace("Forwarded authentication headers");
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_logger.LogDebug("DELETE to Jellyfin: {Url}", url);
var response = await _httpClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent);
return (null, statusCode);
}
// Handle 204 No Content responses
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return (null, statusCode);
}
var responseContent = await response.Content.ReadAsStringAsync();
// Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent))
{
return (null, statusCode);
}
return (JsonDocument.Parse(responseContent), statusCode);
}
/// <summary>
@@ -807,11 +777,10 @@ public class JellyfinProxyService
string itemId,
string imageType = "Primary",
int? maxWidth = null,
int? maxHeight = null,
string? imageTag = null)
int? maxHeight = null)
{
// Build cache key
var cacheKey = $"image:{itemId}:{imageType}:{maxWidth}:{maxHeight}:{imageTag}";
var cacheKey = $"image:{itemId}:{imageType}:{maxWidth}:{maxHeight}";
// Try cache first
var cached = await _cache.GetStringAsync(cacheKey);
@@ -838,12 +807,6 @@ public class JellyfinProxyService
queryParams["maxHeight"] = maxHeight.Value.ToString();
}
// Jellyfin uses `tag` for image cache busting when artwork changes.
if (!string.IsNullOrWhiteSpace(imageTag))
{
queryParams["tag"] = imageTag;
}
var result = await GetBytesSafeAsync($"Items/{itemId}/Images/{imageType}", queryParams);
// Cache for 7 days if successful
@@ -945,62 +908,6 @@ public class JellyfinProxyService
return url;
}
private void LogUpstreamFailure(HttpMethod method, HttpStatusCode statusCode, string url, string? responseBody = null)
{
if (statusCode == HttpStatusCode.Unauthorized)
{
_logger.LogDebug("Jellyfin {Method} returned 401 for {Url} - client should re-authenticate",
method.Method, url);
return;
}
var isLikelyBotProbe = BotProbeDetector.IsHighConfidenceProbeUrl(url);
if (statusCode == HttpStatusCode.NotFound)
{
if (isLikelyBotProbe)
{
_logger.LogDebug("Likely bot probe returned 404 for {Url}", url);
}
else
{
_logger.LogDebug("Jellyfin {Method} returned 404 for {Url}", method.Method, url);
}
return;
}
var responsePreview = string.IsNullOrWhiteSpace(responseBody)
? null
: responseBody.Length > 200 ? responseBody[..200] + "..." : responseBody;
if (isLikelyBotProbe)
{
if (responsePreview == null)
{
_logger.LogWarning("Likely bot probe returned {StatusCode} for {Url}", statusCode, url);
}
else
{
_logger.LogWarning("Likely bot probe returned {StatusCode} for {Url}. Response: {Response}",
statusCode, url, responsePreview);
}
return;
}
if (responsePreview == null)
{
_logger.LogError("Jellyfin {Method} request failed: {StatusCode} for {Url}",
method.Method, statusCode, url);
}
else
{
_logger.LogError("Jellyfin {Method} request failed: {StatusCode} for {Url}. Response: {Response}",
method.Method, statusCode, url, responsePreview);
}
}
/// <summary>
/// Sends a GET request to the Jellyfin server using the server's API key for internal operations.
/// This should only be used for server-side operations, not for proxying client requests.
@@ -294,7 +294,7 @@ public class JellyfinResponseBuilder
/// </summary>
public Dictionary<string, object?> ConvertSongToJellyfinItem(Song song)
{
// Add external/explicit labels to song titles for external tracks.
// Add " [S]" suffix to external song titles (S = streaming source)
var songTitle = song.Title;
var artistName = song.Artist;
var albumName = song.Album;
@@ -302,7 +302,7 @@ public class JellyfinResponseBuilder
if (!song.IsLocal)
{
songTitle = BuildExternalSongTitle(song);
songTitle = $"{song.Title} [S]";
// Also add [S] to artist and album names for consistency
if (!string.IsNullOrEmpty(artistName) && !artistName.EndsWith(" [S]"))
@@ -355,7 +355,6 @@ public class JellyfinResponseBuilder
["Tags"] = new string[0],
["People"] = new object[0],
["SortName"] = songTitle,
["AudioInfo"] = new Dictionary<string, object?>(),
["ParentLogoItemId"] = song.AlbumId,
["ParentBackdropItemId"] = song.AlbumId,
["ParentBackdropImageTags"] = new string[0],
@@ -406,7 +405,6 @@ public class JellyfinResponseBuilder
["MediaType"] = "Audio",
["NormalizationGain"] = 0.0,
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
["CanDelete"] = false,
["CanDownload"] = true,
["SupportsSync"] = true
};
@@ -504,18 +502,6 @@ public class JellyfinResponseBuilder
return item;
}
private static string BuildExternalSongTitle(Song song)
{
var title = $"{song.Title} [S]";
if (song.ExplicitContentLyrics == 1)
{
title = $"{title} [E]";
}
return title;
}
private static bool ShouldDisableTranscoding(string provider)
{
return provider.Equals("deezer", StringComparison.OrdinalIgnoreCase) ||
@@ -541,7 +527,6 @@ public class JellyfinResponseBuilder
["ServerId"] = "allstarr",
["Id"] = album.Id,
["PremiereDate"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : null,
["DateCreated"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : "1970-01-01T00:00:00.0000000Z",
["ChannelId"] = (object?)null,
["Genres"] = !string.IsNullOrEmpty(album.Genre)
? new[] { album.Genre }
@@ -550,8 +535,6 @@ public class JellyfinResponseBuilder
["ProductionYear"] = album.Year,
["IsFolder"] = true,
["Type"] = "MusicAlbum",
["SortName"] = albumName,
["BasicSyncInfo"] = new Dictionary<string, object?>(),
["GenreItems"] = !string.IsNullOrEmpty(album.Genre)
? new[]
{
@@ -638,9 +621,6 @@ public class JellyfinResponseBuilder
["RunTimeTicks"] = 0,
["IsFolder"] = true,
["Type"] = "MusicArtist",
["SortName"] = artistName,
["PrimaryImageAspectRatio"] = 1.0,
["BasicSyncInfo"] = new Dictionary<string, object?>(),
["GenreItems"] = new Dictionary<string, object?>[0],
["UserData"] = new Dictionary<string, object>
{
@@ -763,11 +743,6 @@ public class JellyfinResponseBuilder
["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond,
["IsFolder"] = true,
["Type"] = "MusicAlbum",
["SortName"] = $"{playlist.Name} [S/P]",
["DateCreated"] = playlist.CreatedDate.HasValue
? playlist.CreatedDate.Value.ToString("o")
: "1970-01-01T00:00:00.0000000Z",
["BasicSyncInfo"] = new Dictionary<string, object?>(),
["GenreItems"] = new Dictionary<string, object?>[0],
["UserData"] = new Dictionary<string, object>
{
@@ -20,7 +20,6 @@ public class JellyfinSessionManager : IDisposable
private readonly ILogger<JellyfinSessionManager> _logger;
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
private readonly ConcurrentDictionary<string, SemaphoreSlim> _sessionInitLocks = new();
private readonly ConcurrentDictionary<string, byte> _proxiedWebSocketConnections = new();
private readonly Timer _keepAliveTimer;
public JellyfinSessionManager(
@@ -54,28 +53,21 @@ public class JellyfinSessionManager : IDisposable
await initLock.WaitAsync();
try
{
var hasProxiedWebSocket = HasProxiedWebSocket(deviceId);
// Check if we already have this session tracked
if (_sessions.TryGetValue(deviceId, out var existingSession))
{
existingSession.LastActivity = DateTime.UtcNow;
existingSession.HasProxiedWebSocket = hasProxiedWebSocket;
_logger.LogInformation("Session already exists for device {DeviceId}", deviceId);
if (!hasProxiedWebSocket)
// Refresh capabilities to keep session alive
// If this returns false (401), the token expired and client needs to re-auth
var refreshOk = await PostCapabilitiesAsync(headers);
if (!refreshOk)
{
// Refresh capabilities to keep session alive only for sessions that Allstarr
// is synthesizing itself. Native proxied websocket sessions should be left
// entirely under Jellyfin's control.
var refreshOk = await PostCapabilitiesAsync(headers);
if (!refreshOk)
{
// Token expired - remove the stale session
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
}
// Token expired - remove the stale session
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
}
return true;
@@ -83,26 +75,16 @@ public class JellyfinSessionManager : IDisposable
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
if (!hasProxiedWebSocket)
// Post session capabilities to Jellyfin - this creates the session
var createOk = await PostCapabilitiesAsync(headers);
if (!createOk)
{
// Post session capabilities to Jellyfin only when Allstarr is creating a
// synthetic session. If the real client already has a proxied websocket,
// re-posting capabilities can overwrite its remote-control state.
var createOk = await PostCapabilitiesAsync(headers);
if (!createOk)
{
// Token expired or invalid - client needs to re-authenticate
_logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId);
return false;
}
// Token expired or invalid - client needs to re-authenticate
_logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId);
return false;
}
_logger.LogInformation("Session created for {DeviceId}", deviceId);
}
else
{
_logger.LogDebug("Skipping synthetic Jellyfin session bootstrap for proxied websocket device {DeviceId}",
deviceId);
}
_logger.LogInformation("Session created for {DeviceId}", deviceId);
// Track this session
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
@@ -117,16 +99,11 @@ public class JellyfinSessionManager : IDisposable
Version = version,
LastActivity = DateTime.UtcNow,
Headers = CloneHeaders(headers),
ClientIp = clientIp,
HasProxiedWebSocket = hasProxiedWebSocket
ClientIp = clientIp
};
// Start a synthetic WebSocket connection only when the client itself does not
// already have a proxied Jellyfin socket through Allstarr.
if (!hasProxiedWebSocket)
{
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
}
// Start a WebSocket connection to Jellyfin on behalf of this client
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
return true;
}
@@ -141,44 +118,6 @@ public class JellyfinSessionManager : IDisposable
}
}
public async Task RegisterProxiedWebSocketAsync(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
{
return;
}
_proxiedWebSocketConnections[deviceId] = 0;
if (_sessions.TryGetValue(deviceId, out var session))
{
session.HasProxiedWebSocket = true;
session.LastActivity = DateTime.UtcNow;
await CloseSyntheticWebSocketAsync(deviceId, session);
}
}
public void UnregisterProxiedWebSocket(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
{
return;
}
_proxiedWebSocketConnections.TryRemove(deviceId, out _);
if (_sessions.TryGetValue(deviceId, out var session))
{
session.HasProxiedWebSocket = false;
session.LastActivity = DateTime.UtcNow;
}
}
private bool HasProxiedWebSocket(string deviceId)
{
return !string.IsNullOrWhiteSpace(deviceId) && _proxiedWebSocketConnections.ContainsKey(deviceId);
}
/// <summary>
/// Posts session capabilities to Jellyfin.
/// Returns true if successful, false if token expired (401).
@@ -251,48 +190,6 @@ public class JellyfinSessionManager : IDisposable
}
}
/// <summary>
/// Marks that an explicit playback stop was received for this device+item.
/// Used to suppress duplicate inferred stop forwarding from progress transitions.
/// </summary>
public void MarkExplicitStop(string deviceId, string itemId)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
lock (session.SyncRoot)
{
session.LastExplicitStopItemId = itemId;
session.LastExplicitStopAtUtc = DateTime.UtcNow;
}
}
}
/// <summary>
/// Returns true when an explicit stop for this device+item was recorded within the given time window.
/// </summary>
public bool WasRecentlyExplicitlyStopped(string deviceId, string itemId, TimeSpan within)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
lock (session.SyncRoot)
{
if (!string.Equals(session.LastExplicitStopItemId, itemId, StringComparison.Ordinal))
{
return false;
}
if (!session.LastExplicitStopAtUtc.HasValue)
{
return false;
}
return (DateTime.UtcNow - session.LastExplicitStopAtUtc.Value) <= within;
}
}
return false;
}
/// <summary>
/// Returns true if a local played-signal was already sent for this device+item.
/// </summary>
@@ -357,37 +254,34 @@ public class JellyfinSessionManager : IDisposable
return (null, null);
}
/// <summary>
/// Returns current active playback states for tracked sessions.
/// </summary>
public IReadOnlyList<ActivePlaybackState> GetActivePlaybackStates(TimeSpan maxAge)
{
var cutoff = DateTime.UtcNow - maxAge;
return _sessions.Values
.Where(session =>
!string.IsNullOrWhiteSpace(session.LastPlayingItemId) &&
session.LastActivity >= cutoff)
.Select(session => new ActivePlaybackState(
session.DeviceId,
session.LastPlayingItemId!,
session.LastPlayingPositionTicks ?? 0,
session.LastActivity))
.ToList();
}
/// <summary>
/// Marks a session as potentially ended (e.g., after playback stops).
/// Jellyfin should decide when the upstream playback session expires.
/// The session will be cleaned up if no new activity occurs within the timeout.
/// </summary>
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
{
if (_sessions.TryGetValue(deviceId, out _))
if (_sessions.TryGetValue(deviceId, out var session))
{
_logger.LogDebug(
"⏰ SESSION: Playback stopped for {DeviceId}; leaving upstream session lifetime to Jellyfin (timeout hint {Seconds}s ignored)",
deviceId,
timeout.TotalSeconds);
_logger.LogDebug("⏰ SESSION: Marking session {DeviceId} as potentially ended, will cleanup in {Seconds}s if no activity",
deviceId, timeout.TotalSeconds);
_ = Task.Run(async () =>
{
var markedTime = DateTime.UtcNow;
await Task.Delay(timeout);
// Check if there's been activity since we marked it
if (_sessions.TryGetValue(deviceId, out var currentSession) &&
currentSession.LastActivity <= markedTime)
{
_logger.LogDebug("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
await RemoveSessionAsync(deviceId);
}
else
{
_logger.LogDebug("✓ SESSION: Session {DeviceId} had activity, keeping alive", deviceId);
}
});
}
}
@@ -406,10 +300,8 @@ public class JellyfinSessionManager : IDisposable
ClientIp = s.ClientIp,
LastActivity = s.LastActivity,
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
HasWebSocket = s.HasProxiedWebSocket || s.WebSocket != null,
HasProxiedWebSocket = s.HasProxiedWebSocket,
HasSyntheticWebSocket = s.WebSocket != null,
WebSocketState = s.HasProxiedWebSocket ? "Proxied" : s.WebSocket?.State.ToString() ?? "None"
HasWebSocket = s.WebSocket != null,
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
}).ToList();
return new
@@ -426,8 +318,6 @@ public class JellyfinSessionManager : IDisposable
/// </summary>
public async Task RemoveSessionAsync(string deviceId)
{
_proxiedWebSocketConnections.TryRemove(deviceId, out _);
if (_sessions.TryRemove(deviceId, out var session))
{
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
@@ -466,7 +356,8 @@ public class JellyfinSessionManager : IDisposable
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
}
// Let Jellyfin retire the session naturally; internal cleanup must not revoke the user's token.
// Notify Jellyfin that the session is ending
await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers);
}
catch (Exception ex)
{
@@ -487,12 +378,6 @@ public class JellyfinSessionManager : IDisposable
return;
}
if (session.HasProxiedWebSocket || HasProxiedWebSocket(deviceId))
{
_logger.LogDebug("Skipping synthetic Jellyfin websocket for proxied device {DeviceId}", deviceId);
return;
}
ClientWebSocket? webSocket = null;
try
@@ -525,12 +410,6 @@ public class JellyfinSessionManager : IDisposable
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}", deviceId);
authFound = true;
}
else if (sessionHeaders.TryGetValue("X-Emby-Token", out var token))
{
webSocket.Options.SetRequestHeader("X-Emby-Token", token.ToString());
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Token for {DeviceId}", deviceId);
authFound = true;
}
else if (sessionHeaders.TryGetValue("Authorization", out var auth))
{
var authValue = auth.ToString();
@@ -596,13 +475,6 @@ public class JellyfinSessionManager : IDisposable
{
try
{
if (HasProxiedWebSocket(deviceId))
{
_logger.LogDebug("Stopping synthetic Jellyfin websocket because proxied client websocket is active for {DeviceId}",
deviceId);
break;
}
// Use a timeout so we can send keep-alive messages periodically
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
@@ -713,12 +585,6 @@ public class JellyfinSessionManager : IDisposable
{
try
{
session.HasProxiedWebSocket = HasProxiedWebSocket(session.DeviceId);
if (session.HasProxiedWebSocket)
{
continue;
}
// Post capabilities again to keep session alive
// If this returns false (401), the token has expired
var success = await PostCapabilitiesAsync(session.Headers);
@@ -777,17 +643,8 @@ public class JellyfinSessionManager : IDisposable
public long? LastPlayingPositionTicks { get; set; }
public string? ClientIp { get; set; }
public string? LastLocalPlayedSignalItemId { get; set; }
public string? LastExplicitStopItemId { get; set; }
public DateTime? LastExplicitStopAtUtc { get; set; }
public bool HasProxiedWebSocket { get; set; }
}
public sealed record ActivePlaybackState(
string DeviceId,
string ItemId,
long PositionTicks,
DateTime LastActivity);
public void Dispose()
{
_keepAliveTimer?.Dispose();
@@ -814,31 +671,4 @@ public class JellyfinSessionManager : IDisposable
}
}
}
private async Task CloseSyntheticWebSocketAsync(string deviceId, SessionInfo session)
{
var syntheticSocket = session.WebSocket;
if (syntheticSocket == null)
{
return;
}
session.WebSocket = null;
try
{
if (syntheticSocket.State == WebSocketState.Open || syntheticSocket.State == WebSocketState.CloseReceived)
{
await syntheticSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Native client websocket active", CancellationToken.None);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to close synthetic Jellyfin websocket for proxied device {DeviceId}", deviceId);
}
finally
{
syntheticSocket.Dispose();
}
}
}
@@ -1,15 +0,0 @@
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);
}
@@ -1,319 +0,0 @@
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; }
}
}
+4 -145
View File
@@ -55,7 +55,6 @@ public class QobuzDownloadService : BaseDownloadService
_userAuthToken = qobuzConfig.UserAuthToken;
_userId = qobuzConfig.UserId;
_preferredQuality = qobuzConfig.Quality;
_minRequestIntervalMs = qobuzConfig.MinRequestIntervalMs;
}
#region BaseDownloadService Implementation
@@ -102,7 +101,8 @@ public class QobuzDownloadService : BaseDownloadService
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist;
var basePath = CurrentStorageMode == StorageMode.Cache
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine(DownloadPath, "cache")
: Path.Combine(DownloadPath, "permanent");
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "qobuz", trackId);
@@ -113,12 +113,8 @@ public class QobuzDownloadService : BaseDownloadService
outputPath = PathHelper.ResolveUniquePath(outputPath);
// Download the file (Qobuz files are NOT encrypted like Deezer)
var response = await RetryHelper.RetryWithBackoffAsync(async () =>
{
var res = await _httpClient.GetAsync(downloadInfo.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
return res;
}, Logger);
var response = await _httpClient.GetAsync(downloadInfo.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
@@ -134,143 +130,6 @@ public class QobuzDownloadService : BaseDownloadService
#endregion
#region Quality Override Support
/// <summary>
/// Downloads a track at a specific quality tier, capped at the .env quality ceiling.
/// Note: Qobuz's lowest available quality is MP3 320kbps, so both High and Low map to FormatMp3320.
///
/// Quality hierarchy: FormatFlac24High > FormatFlac24Low > FormatFlac16 > FormatMp3320
/// </summary>
protected override async Task<string> DownloadTrackWithQualityAsync(
string trackId, Song song, StreamQuality quality, CancellationToken cancellationToken)
{
if (quality == StreamQuality.Original)
{
return await DownloadTrackAsync(trackId, song, cancellationToken);
}
// Map StreamQuality to Qobuz format ID, capped at .env ceiling
// Both High and Low map to MP3_320 since Qobuz has no lower quality
var envFormatId = GetFormatId(_preferredQuality);
var formatId = MapStreamQualityToQobuz(quality, envFormatId);
Logger.LogInformation(
"Quality override: StreamQuality.{Quality} → Qobuz formatId {FormatId} (env ceiling: {EnvFormatId}) for track {TrackId}",
quality, formatId, envFormatId, trackId);
// Get download URL at the overridden quality — try all secrets
var secrets = await _bundleService.GetSecretsAsync();
if (secrets.Count == 0)
{
throw new Exception("No secrets available for signing");
}
QobuzDownloadResult? downloadInfo = null;
Exception? lastException = null;
foreach (var secret in secrets)
{
try
{
downloadInfo = await TryGetTrackDownloadUrlAsync(trackId, formatId, secret, cancellationToken);
break;
}
catch (Exception ex)
{
lastException = ex;
Logger.LogDebug("Failed with secret for quality override: {Error}", ex.Message);
}
}
if (downloadInfo == null)
{
throw new Exception("Failed to get download URL for quality override", lastException);
}
// Check if it's a demo/sample
if (downloadInfo.IsSample)
{
throw new Exception("Track is only available as a demo/sample");
}
// Determine extension based on MIME type
var extension = downloadInfo.MimeType?.Contains("flac") == true ? ".flac" : ".mp3";
// Write to transcoded cache directory: {downloads}/transcoded/Artist/Album/song.ext
// These files are cleaned up by CacheCleanupService based on CACHE_TRANSCODE_MINUTES TTL
var artistForPath = song.AlbumArtist ?? song.Artist;
var basePath = Path.Combine("downloads", "transcoded");
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "qobuz", trackId);
// Create directories if they don't exist
var albumFolder = Path.GetDirectoryName(outputPath)!;
EnsureDirectoryExists(albumFolder);
// If the file already exists in transcoded cache, return it directly
if (IOFile.Exists(outputPath))
{
// Touch the file to extend its cache lifetime
IOFile.SetLastWriteTime(outputPath, DateTime.UtcNow);
Logger.LogInformation("Quality override cache hit: {Path}", outputPath);
return outputPath;
}
// Download the file (Qobuz files are NOT encrypted like Deezer)
var response = await RetryHelper.RetryWithBackoffAsync(async () =>
{
var res = await _httpClient.GetAsync(downloadInfo.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
return res;
}, Logger);
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
await responseStream.CopyToAsync(outputFile, cancellationToken);
// Close file before writing metadata
await outputFile.DisposeAsync();
// Write metadata and cover art
await WriteMetadataAsync(outputPath, song, cancellationToken);
return outputPath;
}
/// <summary>
/// Maps a StreamQuality tier to a Qobuz format ID, capped at the .env ceiling.
/// Since Qobuz's lowest quality is MP3 320, both High and Low map to FormatMp3320.
/// </summary>
private int MapStreamQualityToQobuz(StreamQuality streamQuality, int envFormatId)
{
// Format ranking from highest to lowest quality
var ranking = new[] { FormatFlac24High, FormatFlac24Low, FormatFlac16, FormatMp3320 };
var envIndex = Array.IndexOf(ranking, envFormatId);
if (envIndex < 0) envIndex = 0; // Default to highest if unknown
var idealFormatId = streamQuality switch
{
StreamQuality.Original => envFormatId,
StreamQuality.High => FormatMp3320, // Both High and Low map to MP3 320 (Qobuz's lowest)
StreamQuality.Low => FormatMp3320,
_ => envFormatId
};
// Cap at env ceiling (lower index = higher quality)
var idealIndex = Array.IndexOf(ranking, idealFormatId);
if (idealIndex < 0) idealIndex = envIndex;
if (idealIndex < envIndex)
{
return envFormatId;
}
return idealFormatId;
}
#endregion
#region Qobuz Download Methods
/// <summary>
@@ -81,19 +81,6 @@ public class QobuzMetadataService : TrackParserBase, IMusicMetadataService
}
}
public async Task<Song?> FindSongByIsrcAsync(string isrc, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isrc))
{
return null;
}
var results = await SearchSongsAsync(isrc, limit: 5, cancellationToken);
return results.FirstOrDefault(song =>
!string.IsNullOrWhiteSpace(song.Isrc) &&
song.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase));
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
try
@@ -160,15 +147,9 @@ public class QobuzMetadataService : TrackParserBase, IMusicMetadataService
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
{
var songsTask = songLimit > 0
? SearchSongsAsync(query, songLimit, cancellationToken)
: Task.FromResult(new List<Song>());
var albumsTask = albumLimit > 0
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
: Task.FromResult(new List<Album>());
var artistsTask = artistLimit > 0
? SearchArtistsAsync(query, artistLimit, cancellationToken)
: Task.FromResult(new List<Artist>());
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
await Task.WhenAll(songsTask, albumsTask, artistsTask);
@@ -101,8 +101,7 @@ public class ScrobblingHelper
DurationSeconds = durationSeconds,
MusicBrainzId = musicBrainzId,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
IsExternal = isExternal,
StartPositionSeconds = 0
IsExternal = isExternal
};
}
catch (Exception ex)
@@ -120,8 +119,7 @@ public class ScrobblingHelper
string artist,
string? album = null,
string? albumArtist = null,
int? durationSeconds = null,
int? startPositionSeconds = null)
int? durationSeconds = null)
{
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist))
{
@@ -136,8 +134,7 @@ public class ScrobblingHelper
AlbumArtist = albumArtist,
DurationSeconds = durationSeconds,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
IsExternal = true, // Explicitly mark as external
StartPositionSeconds = startPositionSeconds
IsExternal = true // Explicitly mark as external
};
}
@@ -48,19 +48,6 @@ public class ScrobblingOrchestrator
{
if (!_settings.Enabled)
return;
var existingSession = FindSession(deviceId, track.Artist, track.Title);
if (existingSession != null)
{
existingSession.LastActivity = DateTime.UtcNow;
_logger.LogDebug(
"Ignoring duplicate playback start for active session: {Artist} - {Track} (device: {DeviceId}, session: {SessionId})",
track.Artist,
track.Title,
deviceId,
existingSession.SessionId);
return;
}
var sessionId = $"{deviceId}:{track.Artist}:{track.Title}:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
+20 -102
View File
@@ -1026,7 +1026,26 @@ public class SpotifyApiClient : IDisposable
continue;
}
var trackCount = TryGetSpotifyPlaylistItemCount(playlist);
// 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();
}
// Log if we couldn't find track count for debugging
if (trackCount == 0)
@@ -1038,9 +1057,7 @@ 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();
@@ -1049,14 +1066,11 @@ 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];
@@ -1151,68 +1165,6 @@ 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)
@@ -1286,40 +1238,6 @@ 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
@@ -247,7 +247,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
// Re-fetch
await GetPlaylistTracksAsync(playlistName);
await ClearPlaylistImageCacheAsync(playlistName);
}
/// <summary>
@@ -263,20 +262,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
}
private async Task ClearPlaylistImageCacheAsync(string playlistName)
{
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
if (playlistConfig == null || string.IsNullOrWhiteSpace(playlistConfig.JellyfinId))
{
return;
}
var deletedCount = await _cache.DeleteByPatternAsync($"image:{playlistConfig.JellyfinId}:*");
_logger.LogDebug("Cleared {Count} cached local image entries for playlist {Playlist}",
deletedCount,
playlistName);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
@@ -19,16 +19,11 @@ namespace allstarr.Services.Spotify;
///
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
///
/// CRON SCHEDULING: Each playlist has its own cron schedule.
/// When a playlist schedule is due, we run the same per-playlist rebuild workflow
/// used by the manual per-playlist "Rebuild" button.
/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers.
/// Manual refresh is always allowed. Cache persists until next cron run.
/// </summary>
public class SpotifyTrackMatchingService : BackgroundService
{
private const string CachedPlaylistItemFields =
"Genres,GenreItems,DateCreated,MediaSources,ParentId,People,Tags,SortName,UserData,ProviderIds";
private readonly SpotifyImportSettings _spotifySettings;
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly RedisCacheService _cache;
@@ -38,7 +33,6 @@ 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();
@@ -88,7 +82,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
? "ISRC-preferred" : "fuzzy";
_logger.LogInformation("Matching mode: {Mode}", matchMode);
_logger.LogInformation("Cron-based scheduling: each playlist runs independently");
_logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule");
// Log all playlist schedules
foreach (var playlist in _spotifySettings.Playlists)
@@ -118,10 +112,8 @@ public class SpotifyTrackMatchingService : BackgroundService
{
try
{
// Calculate next run time for each playlist.
// Use a small grace window so we don't miss exact-minute cron runs when waking slightly late.
// Calculate next run time for each playlist
var now = DateTime.UtcNow;
var schedulerReference = now.AddMinutes(-1);
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
foreach (var playlist in _spotifySettings.Playlists)
@@ -131,7 +123,7 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
var cron = CronExpression.Parse(schedule);
var nextRun = cron.GetNextOccurrence(schedulerReference, TimeZoneInfo.Utc);
var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
@@ -157,62 +149,44 @@ public class SpotifyTrackMatchingService : BackgroundService
continue;
}
// Run all playlists that are currently due.
var duePlaylists = nextRuns
.Where(x => x.NextRun <= now)
.OrderBy(x => x.NextRun)
.ToList();
// Find the next playlist that needs to run
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
var waitTime = nextPlaylist.NextRun - now;
if (duePlaylists.Count == 0)
if (waitTime.TotalSeconds > 0)
{
// No playlist due yet: wait until the next scheduled run (or max 1 hour to re-check schedules)
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
var waitTime = nextPlaylist.NextRun - now;
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
// Wait until next run (or max 1 hour to re-check schedules)
var maxWait = TimeSpan.FromHours(1);
var actualWait = waitTime > maxWait ? maxWait : waitTime;
await Task.Delay(actualWait, stoppingToken);
continue;
}
_logger.LogInformation(
"=== CRON TRIGGER: Running scheduled rebuild for {Count} due playlists ===",
duePlaylists.Count);
// Time to run this playlist
_logger.LogInformation("=== CRON TRIGGER: Running scheduled sync for {Playlist} ===", nextPlaylist.PlaylistName);
var anySkippedForCooldown = false;
foreach (var due in duePlaylists)
// Check cooldown to prevent duplicate runs
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
{
if (stoppingToken.IsCancellationRequested)
var timeSinceLastRun = now - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
{
break;
}
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.PlaylistName);
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
due.PlaylistName,
stoppingToken,
trigger: "cron");
if (!rebuilt)
{
anySkippedForCooldown = true;
_logger.LogWarning("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
continue;
}
_logger.LogInformation("✓ Finished scheduled rebuild for {Playlist} - Next run at {NextRun} UTC",
due.PlaylistName, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
}
// Avoid a tight loop if one or more due playlists were skipped by cooldown.
if (anySkippedForCooldown)
{
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
// Run full rebuild for this playlist (same as "Rebuild All Remote" button)
await RebuildSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
_lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
_logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
}
catch (Exception ex)
{
@@ -224,7 +198,7 @@ public class SpotifyTrackMatchingService : BackgroundService
/// <summary>
/// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches).
/// Used by individual per-playlist rebuild actions.
/// This is the unified method used by both cron scheduler and "Rebuild All Remote" button.
/// </summary>
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
{
@@ -296,7 +270,6 @@ public class SpotifyTrackMatchingService : BackgroundService
throw;
}
await ClearPlaylistImageCacheAsync(playlist);
_logger.LogInformation("✓ Rebuild complete for {Playlist}", playlistName);
}
@@ -339,8 +312,6 @@ public class SpotifyTrackMatchingService : BackgroundService
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
}
await ClearPlaylistImageCacheAsync(playlist);
}
catch (Exception ex)
{
@@ -349,79 +320,38 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
private async Task ClearPlaylistImageCacheAsync(SpotifyPlaylistConfig playlist)
{
if (string.IsNullOrWhiteSpace(playlist.JellyfinId))
{
return;
}
var deletedCount = await _cache.DeleteByPatternAsync($"image:{playlist.JellyfinId}:*");
_logger.LogDebug("Cleared {Count} cached local image entries for playlist {Playlist}",
deletedCount,
playlist.Name);
}
/// <summary>
/// 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.
/// This clears caches, fetches fresh data, and re-matches everything - same as cron job.
/// </summary>
public async Task TriggerRebuildAllAsync(CancellationToken cancellationToken = default)
public async Task TriggerRebuildAllAsync()
{
_logger.LogInformation("Full rebuild triggered for all playlists");
await RebuildAllPlaylistsAsync(cancellationToken);
_logger.LogInformation("Manual full rebuild triggered for all playlists (same as cron job)");
await RebuildAllPlaylistsAsync(CancellationToken.None);
}
/// <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.
/// This clears cache, fetches fresh data, and re-matches - same as cron job.
/// </summary>
public async Task TriggerRebuildForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist}", playlistName);
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
playlistName,
CancellationToken.None,
trigger: "manual");
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist} (same as cron job)", playlistName);
if (!rebuilt)
{
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
var remaining = _minimumRunInterval - timeSinceLastRun;
var remainingSeconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds));
throw new InvalidOperationException(
$"Please wait {remainingSeconds} more seconds before rebuilding again");
}
throw new InvalidOperationException("Playlist rebuild skipped due to cooldown");
}
}
private async Task<bool> TryRunSinglePlaylistRebuildWithCooldownAsync(
string playlistName,
CancellationToken cancellationToken,
string trigger)
{
// Check cooldown to prevent abuse
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
{
_logger.LogWarning(
"Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
trigger,
playlistName,
(int)timeSinceLastRun.TotalSeconds,
(int)_minimumRunInterval.TotalSeconds);
return false;
_logger.LogWarning("Skipping manual rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before rebuilding again");
}
}
await RebuildSinglePlaylistAsync(playlistName, cancellationToken);
await RebuildSinglePlaylistAsync(playlistName, CancellationToken.None);
_lastRunTimes[playlistName] = DateTime.UtcNow;
return true;
}
/// <summary>
@@ -614,16 +544,6 @@ public class SpotifyTrackMatchingService : BackgroundService
// Only re-match if cache is missing OR if we detect manual mappings that need to be applied
if (existingMatched != null && existingMatched.Count > 0)
{
var hasIncompleteLocalSnapshots = existingMatched.Any(m =>
m.MatchedSong?.IsLocal == true && !JellyfinItemSnapshotHelper.HasRawItemSnapshot(m.MatchedSong));
if (hasIncompleteLocalSnapshots)
{
_logger.LogInformation(
"Rebuilding matched track cache for {Playlist}: cached local matches are missing full Jellyfin item snapshots",
playlistName);
}
// Check if we have NEW manual mappings that aren't in the cache
var hasNewManualMappings = false;
foreach (var track in tracksToMatch)
@@ -646,16 +566,14 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
if (!hasNewManualMappings && !hasIncompleteLocalSnapshots)
if (!hasNewManualMappings)
{
_logger.LogWarning("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
playlistName, existingMatched.Count, tracksToMatch.Count);
return;
}
_logger.LogInformation(
"Rebuilding matched track cache for {Playlist} to apply updated mappings or snapshot completeness",
playlistName);
_logger.LogInformation("New manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
}
// PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin)
@@ -665,7 +583,6 @@ public class SpotifyTrackMatchingService : BackgroundService
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
var jellyfinModelMapper = scope.ServiceProvider.GetService<JellyfinModelMapper>();
if (proxyService != null && jellyfinSettings != null)
{
@@ -673,7 +590,7 @@ public class SpotifyTrackMatchingService : BackgroundService
{
var userId = jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields };
var queryParams = new Dictionary<string, string> { ["Fields"] = "ProviderIds" };
if (!string.IsNullOrEmpty(userId))
{
queryParams["UserId"] = userId;
@@ -685,7 +602,14 @@ public class SpotifyTrackMatchingService : BackgroundService
{
foreach (var item in items.EnumerateArray())
{
var song = jellyfinModelMapper?.ParseSong(item) ?? CreateLocalSongSnapshot(item);
var song = new Song
{
Id = item.GetProperty("Id").GetString() ?? "",
Title = item.GetProperty("Name").GetString() ?? "",
Artist = item.TryGetProperty("AlbumArtist", out var artist) ? artist.GetString() ?? "" : "",
Album = item.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
IsLocal = true
};
jellyfinTracks.Add(song);
}
_logger.LogInformation("📚 Loaded {Count} tracks from Jellyfin playlist {Playlist}",
@@ -774,28 +698,11 @@ 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
@@ -807,23 +714,12 @@ public class SpotifyTrackMatchingService : BackgroundService
if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) &&
!string.IsNullOrEmpty(globalMapping.ExternalId))
{
mappedSong = await metadataService.GetSongAsync(
globalMapping.ExternalProvider,
globalMapping.ExternalId,
trackCancellationToken);
mappedSong = await metadataService.GetSongAsync(globalMapping.ExternalProvider, globalMapping.ExternalId);
}
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);
}
}
@@ -831,31 +727,10 @@ public class SpotifyTrackMatchingService : BackgroundService
// Try ISRC match
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
{
try
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
if (isrcSong != null)
{
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);
candidates.Add((isrcSong, 100.0, "isrc"));
}
}
@@ -863,8 +738,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
spotifyTrack.Title,
spotifyTrack.Artists,
metadataService,
trackCancellationToken);
metadataService);
foreach (var (song, score) in fuzzySongs)
{
@@ -874,48 +748,16 @@ 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 for {Playlist} track #{Position}: {Title} by {Artist}",
playlistName,
spotifyTrack.Position,
spotifyTrack.Title,
primaryArtist);
_logger.LogError(ex, "Failed to match track: {Title}", spotifyTrack.Title);
return (spotifyTrack, new List<(Song, double, string)>());
}
}).ToList();
var batchResults = await Task.WhenAll(batchTasks);
batchStopwatch.Stop();
foreach (var result in batchResults)
{
@@ -925,16 +767,6 @@ 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);
@@ -1107,136 +939,140 @@ public class SpotifyTrackMatchingService : BackgroundService
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
string title,
List<string> artists,
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
IMusicMetadataService metadataService)
{
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
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)
{
// Search Jellyfin for local tracks
var searchParams = new Dictionary<string, string>
try
{
["searchTerm"] = query,
["includeItemTypes"] = "Audio",
["recursive"] = "true",
["limit"] = "10"
};
var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
{
var localResults = new List<Song>();
foreach (var item in items.EnumerateArray())
// Search Jellyfin for local tracks
var searchParams = new Dictionary<string, string>
{
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
["searchTerm"] = query,
["includeItemTypes"] = "Audio",
["recursive"] = "true",
["limit"] = "10"
};
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
{
var localResults = new List<Song>();
foreach (var item in items.EnumerateArray())
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
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
});
}
localResults.Add(new Song
if (localResults.Count > 0)
{
Id = id,
Title = songTitle,
Artist = artist,
IsLocal = true
});
}
// 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();
if (localResults.Count > 0)
{
// Score local results
var scoredLocal = localResults
.Select(song => new
allCandidates.AddRange(scoredLocal);
// If we found good local matches, return them (don't search external)
if (scoredLocal.Any(x => x.TotalScore >= 70))
{
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;
_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);
}
}
catch (Exception ex)
// STEP 2: Only search EXTERNAL if no good local match found
var externalResults = await metadataService.SearchSongsAsync(query, limit: 10);
if (externalResults.Count > 0)
{
_logger.LogWarning(ex, "Failed to search local library for '{Title}'", title);
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;
}
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)
catch
{
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 new List<(Song, double)>();
}
return allCandidates;
}
private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist)
@@ -1250,19 +1086,33 @@ 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,
CancellationToken cancellationToken)
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
{
// 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
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
cancellationToken.ThrowIfCancellationRequested();
// STEP 2: Search EXTERNAL by ISRC
var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1);
if (results.Count > 0 && results[0].Isrc == isrc)
{
return results[0];
}
// STEP 2: Search EXTERNAL by ISRC
return await metadataService.FindSongByIsrcAsync(isrc, cancellationToken);
// Some providers may not support isrc: prefix, try without
results = await metadataService.SearchSongsAsync(isrc, limit: 5);
var exactMatch = results.FirstOrDefault(r =>
!string.IsNullOrEmpty(r.Isrc) &&
r.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase));
return exactMatch;
}
catch
{
return null;
}
}
/// <summary>
@@ -1499,7 +1349,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// Request all fields that clients typically need (not just MediaSources)
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields={CachedPlaylistItemFields}";
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=Genres,DateCreated,MediaSources,ParentId,People,Tags,SortName,ProviderIds";
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
if (statusCode != 200 || existingTracksResponse == null)
@@ -2001,39 +1851,4 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
}
}
private static Song CreateLocalSongSnapshot(JsonElement item)
{
var runTimeTicks = item.TryGetProperty("RunTimeTicks", out var rtt) ? rtt.GetInt64() : 0;
var song = new Song
{
Id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "",
Title = item.TryGetProperty("Name", out var name) ? name.GetString() ?? "" : "",
Album = item.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
AlbumId = item.TryGetProperty("AlbumId", out var albumId) ? albumId.GetString() : null,
Duration = (int)(runTimeTicks / TimeSpan.TicksPerSecond),
Track = item.TryGetProperty("IndexNumber", out var track) ? track.GetInt32() : null,
DiscNumber = item.TryGetProperty("ParentIndexNumber", out var disc) ? disc.GetInt32() : null,
Year = item.TryGetProperty("ProductionYear", out var year) ? year.GetInt32() : null,
IsLocal = true
};
if (item.TryGetProperty("Artists", out var artists) && artists.GetArrayLength() > 0)
{
song.Artist = artists[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtist))
{
song.Artist = albumArtist.GetString() ?? "";
}
JellyfinItemSnapshotHelper.StoreRawItemSnapshot(song, item);
song.JellyfinMetadata ??= new Dictionary<string, object?>();
if (item.TryGetProperty("MediaSources", out var mediaSources))
{
song.JellyfinMetadata["MediaSources"] = JsonSerializer.Deserialize<object>(mediaSources.GetRawText());
}
return song;
}
}
@@ -81,7 +81,6 @@ public class SquidWTFDownloadService : BaseDownloadService
// Increase timeout for large downloads and slow endpoints
_httpClient.Timeout = TimeSpan.FromMinutes(5);
_minRequestIntervalMs = _squidwtfSettings.MinRequestIntervalMs;
}
@@ -97,310 +96,218 @@ public class SquidWTFDownloadService : BaseDownloadService
}
private async Task<string> RunDownloadWithFallbackAsync(string trackId, Song song, string quality, string basePath, CancellationToken cancellationToken)
{
return await _fallbackHelper.TryWithFallbackAsync(async baseUrl =>
{
var songId = BuildTrackedSongId(trackId);
var downloadInfo = await FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken);
Logger.LogInformation(
"Track download info resolved via {Endpoint} (Format: {Format}, Quality: {Quality})",
downloadInfo.Endpoint, downloadInfo.MimeType, downloadInfo.AudioQuality);
Logger.LogDebug("Resolved SquidWTF CDN download URL: {Url}", downloadInfo.DownloadUrl);
var extension = downloadInfo.MimeType?.ToLower() switch
{
"audio/flac" => ".flac", "audio/mpeg" => ".mp3", "audio/mp4" => ".m4a", _ => ".flac"
};
var artistForPath = song.AlbumArtist ?? song.Artist;
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId);
var albumFolder = Path.GetDirectoryName(outputPath)!;
EnsureDirectoryExists(albumFolder);
if (basePath.EndsWith("transcoded") && IOFile.Exists(outputPath))
{
IOFile.SetLastWriteTime(outputPath, DateTime.UtcNow);
Logger.LogInformation("Quality override cache hit: {Path}", outputPath);
return outputPath;
}
outputPath = PathHelper.ResolveUniquePath(outputPath);
using var req = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
req.Headers.Add("User-Agent", "Mozilla/5.0");
req.Headers.Add("Accept", "*/*");
var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
var totalBytes = res.Content.Headers.ContentLength;
var buffer = new byte[81920];
long totalBytesRead = 0;
while (true)
{
var bytesRead = await responseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (bytesRead <= 0)
{
break;
}
await outputFile.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
totalBytesRead += bytesRead;
if (totalBytes.HasValue && totalBytes.Value > 0)
{
SetDownloadProgress(songId, (double)totalBytesRead / totalBytes.Value);
}
}
await outputFile.DisposeAsync();
SetDownloadProgress(songId, 1.0);
_ = Task.Run(async () =>
{
try
{
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId))
{
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId);
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId);
}
});
await WriteMetadataAsync(outputPath, song, cancellationToken);
return outputPath;
});
}
protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
{
return await QueueRequestAsync(async () =>
{
Exception? lastException = null;
var qualityOrder = BuildQualityFallbackOrder(_squidwtfSettings.Quality);
var basePath = CurrentStorageMode == StorageMode.Cache
? Path.Combine(DownloadPath, "cache") : Path.Combine(DownloadPath, "permanent");
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl);
Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality);
foreach (var quality in qualityOrder)
// Determine extension from MIME type
var extension = downloadInfo.MimeType?.ToLower() switch
{
"audio/flac" => ".flac",
"audio/mpeg" => ".mp3",
"audio/mp4" => ".m4a",
_ => ".flac" // Default to FLAC
};
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist;
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine("downloads", "cache")
: Path.Combine("downloads", "permanent");
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId);
// Create directories if they don't exist
var albumFolder = Path.GetDirectoryName(outputPath)!;
EnsureDirectoryExists(albumFolder);
// Resolve unique path if file already exists
outputPath = PathHelper.ResolveUniquePath(outputPath);
// Use round-robin with fallback for downloads to reduce CPU usage
Logger.LogDebug("Using round-robin endpoint selection for download");
var response = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Map quality settings to Tidal's quality levels per hifi-api spec
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
{
try
{
return await RunDownloadWithFallbackAsync(trackId, song, quality, basePath, cancellationToken);
}
catch (Exception ex)
{
lastException = ex;
if (!string.Equals(quality, qualityOrder[^1], StringComparison.Ordinal))
{
Logger.LogWarning("Track {TrackId} unavailable at SquidWTF quality {Quality}: {Error}. Trying lower quality", trackId, quality, DescribeException(ex));
}
}
"FLAC" => "LOSSLESS",
"HI_RES" => "HI_RES_LOSSLESS",
"LOSSLESS" => "LOSSLESS",
"HIGH" => "HIGH",
"LOW" => "LOW",
_ => "LOSSLESS"
};
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
Logger.LogDebug("Requesting track download info: {Url}", url);
// Get download info from this endpoint
var infoResponse = await _httpClient.GetAsync(url, cancellationToken);
if (!infoResponse.IsSuccessStatusCode)
{
Logger.LogWarning("Track download request failed: {StatusCode} {Url}", infoResponse.StatusCode, url);
infoResponse.EnsureSuccessStatusCode();
}
throw lastException ?? new Exception($"Unable to download track {trackId}");
var json = await infoResponse.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var data))
{
throw new Exception("Invalid response from API");
}
var manifestBase64 = data.GetProperty("manifest").GetString()
?? throw new Exception("No manifest in response");
// Decode base64 manifest to get actual CDN URL
var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64));
var manifest = JsonDocument.Parse(manifestJson);
if (!manifest.RootElement.TryGetProperty("urls", out var urls) || urls.GetArrayLength() == 0)
{
throw new Exception("No download URLs in manifest");
}
var downloadUrl = urls[0].GetString()
?? throw new Exception("Download URL is null");
// Start the actual download from Tidal CDN (no encryption - squid.wtf handles everything)
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
request.Headers.Add("User-Agent", "Mozilla/5.0");
request.Headers.Add("Accept", "*/*");
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
});
response.EnsureSuccessStatusCode();
// Download directly (no decryption needed - squid.wtf handles everything)
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
await responseStream.CopyToAsync(outputFile, cancellationToken);
// Close file before writing metadata
await outputFile.DisposeAsync();
// Start Spotify ID conversion in background (for lyrics support)
// This doesn't block streaming - lyrics endpoint will fetch it on-demand if needed
_ = Task.Run(async () =>
{
try
{
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None);
if (!string.IsNullOrEmpty(spotifyId))
{
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId);
// Spotify ID is cached by Odesli service for future lyrics requests
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId);
}
});
// Write metadata and cover art (without Spotify ID - it's only needed for lyrics)
await WriteMetadataAsync(outputPath, song, cancellationToken);
return outputPath;
}
#endregion
#region Quality Override Support
/// <summary>
/// Downloads a track at a specific quality tier, capped at the .env quality ceiling.
/// The .env quality is the maximum — client requests can only go equal or lower.
///
/// Quality hierarchy (highest to lowest): HI_RES_LOSSLESS > LOSSLESS > HIGH > LOW
///
/// Examples:
/// env=HI_RES_LOSSLESS: Original→HI_RES_LOSSLESS, High→HIGH, Low→LOW
/// env=LOSSLESS: Original→LOSSLESS, High→HIGH, Low→LOW
/// env=HIGH: Original→HIGH, High→HIGH, Low→LOW
/// env=LOW: Original→LOW, High→LOW, Low→LOW
/// </summary>
protected override async Task<string> DownloadTrackWithQualityAsync(
string trackId, Song song, StreamQuality quality, CancellationToken cancellationToken)
{
if (quality == StreamQuality.Original)
{
return await DownloadTrackAsync(trackId, song, cancellationToken);
}
// Map StreamQuality to SquidWTF quality string, capped at .env ceiling
var envQuality = NormalizeSquidWTFQuality(_squidwtfSettings.Quality);
var squidQuality = MapStreamQualityToSquidWTF(quality, envQuality);
Logger.LogInformation(
"Quality override: StreamQuality.{Quality} → SquidWTF quality '{SquidQuality}' (env ceiling: {EnvQuality}) for track {TrackId}",
quality, squidQuality, envQuality, trackId);
var basePath = Path.Combine("downloads", "transcoded");
return await QueueRequestAsync(async () =>
{
return await RunDownloadWithFallbackAsync(trackId, song, squidQuality, basePath, cancellationToken);
});
}
/// <summary>
/// Normalizes the .env quality string to a standard SquidWTF quality level.
/// Maps various aliases (HI_RES, FLAC, etc.) to canonical names.
/// </summary>
private static string NormalizeSquidWTFQuality(string? quality)
{
if (string.IsNullOrEmpty(quality)) return "LOSSLESS";
return quality.ToUpperInvariant() switch
{
"HI_RES" or "HI_RES_LOSSLESS" => "HI_RES_LOSSLESS",
"FLAC" or "LOSSLESS" => "LOSSLESS",
"HIGH" => "HIGH",
"LOW" => "LOW",
_ => "LOSSLESS"
};
}
/// <summary>
/// Maps a StreamQuality tier to a SquidWTF quality string, capped at the .env ceiling.
/// The .env quality is the maximum — client requests can only go equal or lower.
/// </summary>
private static string MapStreamQualityToSquidWTF(StreamQuality streamQuality, string envQuality)
{
// Quality ranking from highest to lowest
var ranking = new[] { "HI_RES_LOSSLESS", "LOSSLESS", "HIGH", "LOW" };
var envIndex = Array.IndexOf(ranking, envQuality);
if (envIndex < 0) envIndex = 1; // Default to LOSSLESS if unknown
// Map StreamQuality to the "ideal" SquidWTF quality
var idealQuality = streamQuality switch
{
StreamQuality.Original => envQuality, // Lossless client selection → use .env setting
StreamQuality.High => "HIGH", // 320/256/192K → HIGH (320kbps AAC)
StreamQuality.Low => "LOW", // 128/64K → LOW (96kbps AAC)
_ => envQuality
};
// Cap: if the ideal quality is higher than env, clamp down to env
// Lower array index = higher quality
var idealIndex = Array.IndexOf(ranking, idealQuality);
if (idealIndex < 0) idealIndex = envIndex;
if (idealIndex < envIndex)
{
return envQuality;
}
return idealQuality;
}
#endregion
#region SquidWTF API Methods
// Removed GetTrackDownloadInfoAsync as it's now integrated inside RunDownloadWithFallbackAsync
private async Task<DownloadResult> FetchTrackDownloadInfoAsync(
string baseUrl,
string trackId,
string quality,
CancellationToken cancellationToken)
/// <summary>
/// Gets track download information from hifi-api /track/ endpoint.
/// Per hifi-api spec: GET /track/?id={trackId}&quality={quality}
/// Returns: { "version": "2.0", "data": { trackId, assetPresentation, audioMode, audioQuality,
/// manifestMimeType, manifestHash, manifest (base64), albumReplayGain, trackReplayGain, bitDepth, sampleRate } }
/// The manifest is base64-encoded JSON containing: { mimeType, codecs, encryptionType, urls: [downloadUrl] }
/// Quality options: HI_RES_LOSSLESS (24-bit/192kHz FLAC), LOSSLESS (16-bit/44.1kHz FLAC), HIGH (320kbps AAC), LOW (96kbps AAC)
/// </summary>
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
{
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
return await QueueRequestAsync(async () =>
{
// Use round-robin with fallback instead of racing to reduce CPU usage
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Map quality settings to Tidal's quality levels per hifi-api spec
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
{
"FLAC" => "LOSSLESS",
"HI_RES" => "HI_RES_LOSSLESS",
"LOSSLESS" => "LOSSLESS",
"HIGH" => "HIGH",
"LOW" => "LOW",
_ => "LOSSLESS" // Default to lossless
};
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
Logger.LogDebug("Fetching track download info from: {Url}", url);
Logger.LogDebug("Fetching track download info from: {Url}", url);
using var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
response.EnsureSuccessStatusCode();
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var data))
{
throw new Exception("Invalid response from API");
}
// Get the manifest (base64 encoded JSON containing the actual CDN URL)
var manifestBase64 = data.GetProperty("manifest").GetString()
?? throw new Exception("No manifest in response");
// Decode the manifest
var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64));
using var manifest = JsonDocument.Parse(manifestJson);
// Extract the download URL from the manifest
if (!manifest.RootElement.TryGetProperty("urls", out var urls) || urls.GetArrayLength() == 0)
{
throw new Exception("No download URLs in manifest");
}
var downloadUrl = urls[0].GetString()
?? throw new Exception("Download URL is null");
var mimeType = manifest.RootElement.TryGetProperty("mimeType", out var mimeTypeEl)
? mimeTypeEl.GetString()
: "audio/flac";
var audioQuality = data.TryGetProperty("audioQuality", out var audioQualityEl)
? audioQualityEl.GetString()
: quality;
return new DownloadResult
{
Endpoint = baseUrl,
DownloadUrl = downloadUrl,
MimeType = mimeType ?? "audio/flac",
AudioQuality = audioQuality ?? quality
};
}
private static IReadOnlyList<string> BuildQualityFallbackOrder(string? configuredQuality)
{
return NormalizeQuality(configuredQuality) switch
{
"HI_RES_LOSSLESS" => ["HI_RES_LOSSLESS", "LOSSLESS", "HIGH", "LOW"],
"LOSSLESS" => ["LOSSLESS", "HIGH", "LOW"],
"HIGH" => ["HIGH", "LOW"],
"LOW" => ["LOW"],
_ => ["LOSSLESS", "HIGH", "LOW"]
};
}
private static string NormalizeQuality(string? configuredQuality)
{
return configuredQuality?.ToUpperInvariant() switch
{
"FLAC" => "LOSSLESS",
"HI_RES" => "HI_RES_LOSSLESS",
"HI_RES_LOSSLESS" => "HI_RES_LOSSLESS",
"LOSSLESS" => "LOSSLESS",
"HIGH" => "HIGH",
"LOW" => "LOW",
_ => "LOSSLESS"
};
}
private static string DescribeException(Exception ex)
{
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
{
var statusCode = (int)httpRequestException.StatusCode.Value;
return $"{statusCode}: {httpRequestException.StatusCode.Value}";
}
return ex.Message;
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
Logger.LogWarning("Track download info request failed: {StatusCode} {Url}", response.StatusCode, url);
response.EnsureSuccessStatusCode();
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var data))
{
throw new Exception("Invalid response from API");
}
// Get the manifest (base64 encoded JSON containing the actual CDN URL)
var manifestBase64 = data.GetProperty("manifest").GetString()
?? throw new Exception("No manifest in response");
// Decode the manifest
var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64));
var manifest = JsonDocument.Parse(manifestJson);
// Extract the download URL from the manifest
if (!manifest.RootElement.TryGetProperty("urls", out var urls) || urls.GetArrayLength() == 0)
{
throw new Exception("No download URLs in manifest");
}
var downloadUrl = urls[0].GetString()
?? throw new Exception("Download URL is null");
var mimeType = manifest.RootElement.TryGetProperty("mimeType", out var mimeTypeEl)
? mimeTypeEl.GetString()
: "audio/flac";
var audioQuality = data.TryGetProperty("audioQuality", out var audioQualityEl)
? audioQualityEl.GetString()
: "LOSSLESS";
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadUrl);
return new DownloadResult
{
DownloadUrl = downloadUrl,
MimeType = mimeType ?? "audio/flac",
AudioQuality = audioQuality ?? "LOSSLESS"
};
});
});
}
@@ -460,9 +367,8 @@ public class SquidWTFDownloadService : BaseDownloadService
private class DownloadResult
{
public string Endpoint { get; set; } = string.Empty;
public string DownloadUrl { get; set; } = string.Empty;
public string MimeType { get; set; } = string.Empty;
public string AudioQuality { get; set; } = string.Empty;
}
}
}
@@ -7,6 +7,7 @@ using allstarr.Services.Common;
using System.Text.Json;
using System.Text;
using Microsoft.Extensions.Options;
using System.Text.Json.Nodes;
namespace allstarr.Services.SquidWTF;
@@ -16,19 +17,17 @@ namespace allstarr.Services.SquidWTF;
/// SquidWTF is a proxy to Tidal's API that provides free access to Tidal's music catalog.
/// This implementation follows the hifi-api specification documented at the forked repository.
///
/// API Endpoints (per hifi-api README):
/// - GET /search/?s={query}&limit={limit}&offset={offset} - Search tracks (returns data.items array)
/// - GET /search/?i={isrc}&limit=1&offset=0 - Exact track lookup by ISRC (returns data.items array)
/// - GET /search/?a={query}&limit={limit}&offset={offset} - Search artists (returns data.artists.items array)
/// - GET /search/?al={query}&limit={limit}&offset={offset} - Search albums (returns data.albums.items array)
/// - GET /search/?p={query}&limit={limit}&offset={offset} - Search playlists (returns data.playlists.items array)
/// - GET /info/?id={trackId} - Get track metadata (returns data object with full track info)
/// - GET /track/?id={trackId}&quality={quality} - Get track download info (returns manifest)
/// - GET /recommendations/?id={trackId} - Get recommended next/similar tracks
/// - GET /album/?id={albumId}&limit={limit}&offset={offset} - Get album with paginated tracks
/// - GET /artist/?id={artistId} - Get lightweight artist metadata + cover
/// - GET /artist/?f={artistId} - Get artist releases and aggregate tracks
/// - GET /playlist/?id={playlistId}&limit={limit}&offset={offset} - Get playlist with paginated tracks
/// API Endpoints (per hifi-api spec):
/// - GET /search/?s={query} - Search tracks (returns data.items array)
/// - GET /search/?a={query} - Search artists (returns data.artists.items array)
/// - GET /search/?al={query} - Search albums (returns data.albums.items array, undocumented)
/// - GET /search/?p={query} - Search playlists (returns data.playlists.items array, undocumented)
/// - GET /info/?id={trackId} - Get track metadata (returns data object with full track info)
/// - GET /track/?id={trackId}&quality={quality} - Get track download info (returns manifest)
/// - GET /recommendations/?id={trackId} - Get recommended next/similar tracks
/// - GET /album/?id={albumId} - Get album with tracks (undocumented, returns data.items array)
/// - GET /artist/?f={artistId} - Get artist with albums (undocumented, returns albums.items array)
/// - GET /playlist/?id={playlistId} - Get playlist with tracks (undocumented)
///
/// Quality Options:
/// - HI_RES_LOSSLESS: 24-bit/192kHz FLAC
@@ -37,8 +36,7 @@ namespace allstarr.Services.SquidWTF;
/// - LOW: 96kbps AAC
///
/// Response Structure:
/// Responses follow the documented hifi-api 2.x envelopes.
/// Track search and ISRC search return: { "version": "2.x", "data": { "items": [ ... ] } }
/// All responses follow: { "version": "2.0", "data": { ... } }
/// Track objects include: id, title, duration, trackNumber, volumeNumber, explicit, bpm, isrc,
/// artist (singular), artists (array), album (object with id, title, cover UUID)
/// Cover art URLs: https://resources.tidal.com/images/{uuid-with-slashes}/{size}.jpg
@@ -54,12 +52,6 @@ namespace allstarr.Services.SquidWTF;
public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
{
private const int RemoteSearchMinLimit = 1;
private const int RemoteSearchMaxLimit = 500;
private const int DefaultSearchOffset = 0;
private const int IsrcLookupLimit = 1;
private const int IsrcFallbackLimit = 5;
private const int MetadataPageSize = 500;
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _settings;
private readonly ILogger<SquidWTFMetadataService> _logger;
@@ -95,13 +87,12 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeRemoteLimit(limit);
var allSongs = new List<Song>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var queryVariant in BuildSearchQueryVariants(query))
{
var songs = await SearchSongsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken);
var songs = await SearchSongsSingleQueryAsync(queryVariant, limit, cancellationToken);
foreach (var song in songs)
{
var key = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id;
@@ -111,13 +102,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
}
allSongs.Add(song);
if (allSongs.Count >= normalizedLimit)
if (allSongs.Count >= limit)
{
break;
}
}
if (allSongs.Count >= normalizedLimit)
if (allSongs.Count >= limit)
{
break;
}
@@ -129,13 +120,12 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeRemoteLimit(limit);
var allAlbums = new List<Album>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var queryVariant in BuildSearchQueryVariants(query))
{
var albums = await SearchAlbumsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken);
var albums = await SearchAlbumsSingleQueryAsync(queryVariant, limit, cancellationToken);
foreach (var album in albums)
{
var key = !string.IsNullOrWhiteSpace(album.ExternalId) ? album.ExternalId : album.Id;
@@ -145,13 +135,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
}
allAlbums.Add(album);
if (allAlbums.Count >= normalizedLimit)
if (allAlbums.Count >= limit)
{
break;
}
}
if (allAlbums.Count >= normalizedLimit)
if (allAlbums.Count >= limit)
{
break;
}
@@ -163,13 +153,12 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeRemoteLimit(limit);
var allArtists = new List<Artist>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var queryVariant in BuildSearchQueryVariants(query))
{
var artists = await SearchArtistsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken);
var artists = await SearchArtistsSingleQueryAsync(queryVariant, limit, cancellationToken);
foreach (var artist in artists)
{
var key = !string.IsNullOrWhiteSpace(artist.ExternalId) ? artist.ExternalId : artist.Id;
@@ -179,13 +168,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
}
allArtists.Add(artist);
if (allArtists.Count >= normalizedLimit)
if (allArtists.Count >= limit)
{
break;
}
}
if (allArtists.Count >= normalizedLimit)
if (allArtists.Count >= limit)
{
break;
}
@@ -197,12 +186,11 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
private async Task<List<Song>> SearchSongsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken)
{
var normalizedLimit = NormalizeRemoteLimit(limit);
// Use benchmark-ordered fallback (no endpoint racing).
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Use 's' parameter for track search as per hifi-api spec
var url = BuildSearchUrl(baseUrl, "s", query, normalizedLimit);
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
@@ -228,7 +216,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
int count = 0;
foreach (var track in items.EnumerateArray())
{
if (count >= normalizedLimit) break;
if (count >= limit) break;
var song = ParseTidalTrack(track);
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
@@ -238,23 +226,18 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
count++;
}
}
else
{
throw new InvalidOperationException("SquidWTF song search response did not contain data.items");
}
return songs;
}, new List<Song>());
}
private async Task<List<Album>> SearchAlbumsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken)
{
var normalizedLimit = NormalizeRemoteLimit(limit);
// Use benchmark-ordered fallback (no endpoint racing).
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Use 'al' parameter for album search
// a= is for artists, al= is for albums, p= is for playlists
var url = BuildSearchUrl(baseUrl, "al", query, normalizedLimit);
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
@@ -274,16 +257,12 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
int count = 0;
foreach (var album in items.EnumerateArray())
{
if (count >= normalizedLimit) break;
if (count >= limit) break;
albums.Add(ParseTidalAlbum(album));
count++;
}
}
else
{
throw new InvalidOperationException("SquidWTF album search response did not contain data.albums.items");
}
return albums;
}, new List<Album>());
@@ -291,12 +270,11 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
private async Task<List<Artist>> SearchArtistsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken)
{
var normalizedLimit = NormalizeRemoteLimit(limit);
// Use benchmark-ordered fallback (no endpoint racing).
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Per hifi-api spec: use 'a' parameter for artist search
var url = BuildSearchUrl(baseUrl, "a", query, normalizedLimit);
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
_logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
@@ -310,12 +288,6 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
{
throw new HttpRequestException("API returned error response");
}
var artists = new List<Artist>();
// Per hifi-api spec: artist search returns data.artists.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
@@ -325,7 +297,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
int count = 0;
foreach (var artist in items.EnumerateArray())
{
if (count >= normalizedLimit) break;
if (count >= limit) break;
var parsedArtist = ParseTidalArtist(artist);
artists.Add(parsedArtist);
@@ -333,10 +305,6 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
count++;
}
}
else
{
throw new InvalidOperationException("SquidWTF artist search response did not contain data.artists.items");
}
return artists;
}, new List<Artist>());
@@ -370,101 +338,18 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
}
}
private static int NormalizeRemoteLimit(int limit)
{
return Math.Clamp(limit, RemoteSearchMinLimit, RemoteSearchMaxLimit);
}
private static string BuildSearchUrl(string baseUrl, string field, string query, int limit, int offset = DefaultSearchOffset)
{
return $"{baseUrl}/search/?{field}={Uri.EscapeDataString(query)}&limit={NormalizeRemoteLimit(limit)}&offset={Math.Max(DefaultSearchOffset, offset)}";
}
private static string BuildPagedEndpointUrl(string baseUrl, string endpoint, string idParameterName, string externalId, int limit, int offset = DefaultSearchOffset)
{
return $"{baseUrl}/{endpoint}/?{idParameterName}={Uri.EscapeDataString(externalId)}&limit={NormalizeRemoteLimit(limit)}&offset={Math.Max(DefaultSearchOffset, offset)}";
}
private static string? GetArtistCoverFallbackUrl(JsonElement rootElement)
{
if (!rootElement.TryGetProperty("cover", out var cover) || cover.ValueKind != JsonValueKind.Object)
{
return null;
}
foreach (var propertyName in new[] { "750", "640", "320", "1280" })
{
if (cover.TryGetProperty(propertyName, out var value) &&
value.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(value.GetString()))
{
return value.GetString();
}
}
foreach (var property in cover.EnumerateObject())
{
if (property.Value.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(property.Value.GetString()))
{
return property.Value.GetString();
}
}
return null;
}
private static TimeSpan GetMetadataCacheTtl()
{
try
{
return CacheExtensions.MetadataTTL;
}
catch (InvalidOperationException)
{
return new CacheSettings().MetadataTTL;
}
}
private async Task<Song?> FindSongByIsrcViaTextSearchAsync(string isrc, CancellationToken cancellationToken)
{
var prefixedResults = await SearchSongsAsync($"isrc:{isrc}", limit: IsrcLookupLimit, cancellationToken);
var prefixedMatch = prefixedResults.FirstOrDefault(song =>
!string.IsNullOrWhiteSpace(song.Isrc) &&
song.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase));
if (prefixedMatch != null)
{
return prefixedMatch;
}
var rawResults = await SearchSongsAsync(isrc, limit: IsrcFallbackLimit, cancellationToken);
return rawResults.FirstOrDefault(song =>
!string.IsNullOrWhiteSpace(song.Isrc) &&
song.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase));
}
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeRemoteLimit(limit);
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Per hifi-api spec: use 'p' parameter for playlist search
var url = BuildSearchUrl(baseUrl, "p", query, normalizedLimit);
var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
{
throw new HttpRequestException("API returned error response");
}
var playlists = new List<ExternalPlaylist>();
// Per hifi-api spec: playlist search returns data.playlists.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
@@ -474,7 +359,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
int count = 0;
foreach(var playlist in items.EnumerateArray())
{
if (count >= normalizedLimit) break;
if (count >= limit) break;
try
{
@@ -485,32 +370,23 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
{
_logger.LogWarning(ex, "Failed to parse playlist, skipping");
// Skip this playlist and continue with others
}
}
}
else
{
throw new InvalidOperationException("SquidWTF playlist search response did not contain data.playlists.items");
}
}
return playlists;
}, new List<ExternalPlaylist>());
}
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
{
var songsTask = songLimit > 0
? SearchSongsAsync(query, songLimit, cancellationToken)
: Task.FromResult(new List<Song>());
var albumsTask = albumLimit > 0
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
: Task.FromResult(new List<Album>());
var artistsTask = artistLimit > 0
? SearchArtistsAsync(query, artistLimit, cancellationToken)
: Task.FromResult(new List<Artist>());
// Execute searches in parallel
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
await Task.WhenAll(songsTask, albumsTask, artistsTask);
var temp = new SearchResult
var temp = new SearchResult
{
Songs = await songsTask,
Albums = await albumsTask,
@@ -520,65 +396,6 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
return temp;
}
public async Task<Song?> FindSongByIsrcAsync(string isrc, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isrc))
{
return null;
}
var normalizedIsrc = isrc.Trim();
var exactMatch = await _fallbackHelper.TryWithFallbackAsync(
async (baseUrl) =>
{
var url = BuildSearchUrl(baseUrl, "i", normalizedIsrc, IsrcLookupLimit);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
{
throw new HttpRequestException("API returned error response");
}
if (!result.RootElement.TryGetProperty("data", out var data) ||
!data.TryGetProperty("items", out var items) ||
items.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException("SquidWTF ISRC search response did not contain data.items");
}
foreach (var track in items.EnumerateArray())
{
var song = ParseTidalTrack(track);
if (!ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
{
continue;
}
if (!string.IsNullOrWhiteSpace(song.Isrc) &&
song.Isrc.Equals(normalizedIsrc, StringComparison.OrdinalIgnoreCase))
{
return song;
}
}
return null;
},
song => song != null,
(Song?)null);
return exactMatch ?? await FindSongByIsrcViaTextSearchAsync(normalizedIsrc, cancellationToken);
}
public async Task<Song?> GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "squidwtf") return null;
@@ -589,19 +406,14 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
var url = $"{baseUrl}/info/?id={externalId}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
// Per hifi-api spec: response is { "version": "2.0", "data": { track object } }
if (!result.RootElement.TryGetProperty("data", out var track))
{
throw new InvalidOperationException($"SquidWTF /info response for track {externalId} did not contain data");
}
return null;
var song = ParseTidalTrackFull(track);
@@ -633,96 +445,84 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
{
if (string.IsNullOrWhiteSpace(externalId)) return new List<Song>();
return await _fallbackHelper.TryWithFallbackAsync(
async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/recommendations/?id={Uri.EscapeDataString(externalId)}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var url = $"{baseUrl}/recommendations/?id={Uri.EscapeDataString(externalId)}";
if (limit > 0)
_logger.LogDebug("SquidWTF recommendations request failed for track {TrackId} with status {StatusCode}",
externalId, response.StatusCode);
return new List<Song>();
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
if (!result.RootElement.TryGetProperty("data", out var data) ||
!data.TryGetProperty("items", out var items) ||
items.ValueKind != JsonValueKind.Array)
{
return new List<Song>();
}
var songs = new List<Song>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var recommendation in items.EnumerateArray())
{
JsonElement track;
if (recommendation.TryGetProperty("track", out var wrappedTrack))
{
url += $"&limit={limit}";
track = wrappedTrack;
}
else
{
track = recommendation;
}
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
if (!track.TryGetProperty("id", out _))
{
throw new HttpRequestException(
$"SquidWTF recommendations request failed for track {externalId} with status {response.StatusCode}");
continue;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
if (!result.RootElement.TryGetProperty("data", out var data) ||
!data.TryGetProperty("items", out var items) ||
items.ValueKind != JsonValueKind.Array)
Song song;
try
{
throw new InvalidOperationException(
$"SquidWTF recommendations response for track {externalId} did not contain data.items");
song = ParseTidalTrack(track);
}
catch
{
continue;
}
var songs = new List<Song>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var recommendation in items.EnumerateArray())
if (string.Equals(song.ExternalId, externalId, StringComparison.OrdinalIgnoreCase))
{
JsonElement track;
if (recommendation.TryGetProperty("track", out var wrappedTrack))
{
track = wrappedTrack;
}
else
{
track = recommendation;
}
if (!track.TryGetProperty("id", out _))
{
continue;
}
Song song;
try
{
song = ParseTidalTrack(track);
}
catch
{
continue;
}
if (string.Equals(song.ExternalId, externalId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var songKey = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id;
if (string.IsNullOrWhiteSpace(songKey) || !seenIds.Add(songKey))
{
continue;
}
if (!ShouldIncludeSong(song))
{
continue;
}
songs.Add(song);
if (songs.Count >= limit)
{
break;
}
continue;
}
_logger.LogDebug(
"SQUIDWTF: Recommendations returned {Count} songs for track {TrackId} from {BaseUrl}",
songs.Count,
externalId,
baseUrl);
return songs;
},
songs => songs.Count > 0,
new List<Song>());
var songKey = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id;
if (string.IsNullOrWhiteSpace(songKey) || !seenIds.Add(songKey))
{
continue;
}
if (!ShouldIncludeSong(song))
{
continue;
}
songs.Add(song);
if (songs.Count >= limit)
{
break;
}
}
_logger.LogDebug("SQUIDWTF: Recommendations returned {Count} songs for track {TrackId}", songs.Count, externalId);
return songs;
}, new List<Song>());
}
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
@@ -736,71 +536,43 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
Album? album = null;
var offset = DefaultSearchOffset;
var rawItemCount = 0;
// Note: hifi-api doesn't document album endpoint, but /album/?id={albumId} is commonly used
var url = $"{baseUrl}/album/?id={externalId}";
while (true)
{
var url = BuildPagedEndpointUrl(baseUrl, "album", "id", externalId, MetadataPageSize, offset);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var result = JsonDocument.Parse(json);
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
if (!result.RootElement.TryGetProperty("data", out var albumElement))
{
throw new InvalidOperationException($"SquidWTF /album response for album {externalId} did not contain data");
}
// Response structure: { "data": { album object with "items" array of tracks } }
if (!result.RootElement.TryGetProperty("data", out var albumElement))
return null;
album ??= ParseTidalAlbum(albumElement);
var album = ParseTidalAlbum(albumElement);
if (!albumElement.TryGetProperty("items", out var tracks) || tracks.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException($"SquidWTF /album response for album {externalId} did not contain data.items");
}
// Get album tracks from items array
if (albumElement.TryGetProperty("items", out var tracks))
{
foreach (var trackWrapper in tracks.EnumerateArray())
{
// Each item is wrapped: { "item": { track object } }
if (trackWrapper.TryGetProperty("item", out var track))
{
var song = ParseTidalTrack(track);
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
{
album.Songs.Add(song);
}
}
}
}
var pageCount = 0;
foreach (var trackWrapper in tracks.EnumerateArray())
{
pageCount++;
// Cache for configurable duration
await _cache.SetAsync(cacheKey, album, CacheExtensions.MetadataTTL);
if (!trackWrapper.TryGetProperty("item", out var track))
{
continue;
}
var song = ParseTidalTrack(track);
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
{
album.Songs.Add(song);
}
}
rawItemCount += pageCount;
if (pageCount == 0 ||
pageCount < MetadataPageSize ||
(album.SongCount.HasValue && rawItemCount >= album.SongCount.Value))
{
break;
}
offset += pageCount;
}
if (album == null)
{
throw new InvalidOperationException($"SquidWTF /album response for album {externalId} did not contain album data");
}
await _cache.SetAsync(cacheKey, album, GetMetadataCacheTtl());
return album;
}, (Album?)null);
return album;
}, (Album?)null);
}
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
@@ -820,52 +592,83 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/artist/?id={Uri.EscapeDataString(externalId)}";
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogDebug("Fetching artist from {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
_logger.LogError("SquidWTF artist request failed with status {StatusCode}", response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogDebug("SquidWTF artist response: {Json}", json.Length > 500 ? json.Substring(0, 500) + "..." : json);
using var result = JsonDocument.Parse(json);
var result = JsonDocument.Parse(json);
if (!result.RootElement.TryGetProperty("artist", out var artistElement))
{
var keys = string.Join(", ", result.RootElement.EnumerateObject().Select(p => p.Name));
throw new InvalidOperationException(
$"SquidWTF artist response for {externalId} did not contain artist data. Keys: {keys}");
JsonElement? artistSource = null;
int albumCount = 0;
// Response structure: { "albums": { "items": [ album objects ] }, "tracks": [ track objects ] }
// Extract artist info from albums.items[0].artist (most reliable source)
if (result.RootElement.TryGetProperty("albums", out var albums) &&
albums.TryGetProperty("items", out var albumItems) &&
albumItems.GetArrayLength() > 0)
{
albumCount = albumItems.GetArrayLength();
if (albumItems[0].TryGetProperty("artist", out var artistEl))
{
artistSource = artistEl;
_logger.LogDebug("Found artist from albums, albumCount={AlbumCount}", albumCount);
}
}
var artistName = artistElement.GetProperty("name").GetString() ?? string.Empty;
var pictureUuid = artistElement.TryGetProperty("picture", out var pictureEl) &&
pictureEl.ValueKind == JsonValueKind.String
? pictureEl.GetString()
: null;
var coverUrl = GetArtistCoverFallbackUrl(result.RootElement);
var imageUrl = !string.IsNullOrWhiteSpace(pictureUuid)
? BuildTidalImageUrl(pictureUuid, "320x320")
: coverUrl;
// Fallback: try to get artist from tracks[0].artists[0]
if (artistSource == null &&
result.RootElement.TryGetProperty("tracks", out var tracks) &&
tracks.GetArrayLength() > 0 &&
tracks[0].TryGetProperty("artists", out var artists) &&
artists.GetArrayLength() > 0)
{
artistSource = artists[0];
_logger.LogInformation("Found artist from tracks");
}
var artist = new Artist
if (artistSource == null)
{
Id = BuildExternalArtistId("squidwtf", externalId),
Name = artistName,
ImageUrl = imageUrl,
AlbumCount = null,
IsLocal = false,
ExternalProvider = "squidwtf",
ExternalId = externalId
};
_logger.LogDebug("Could not find artist data in response. Response keys: {Keys}",
string.Join(", ", result.RootElement.EnumerateObject().Select(p => p.Name)));
return null;
}
_logger.LogDebug("Successfully parsed artist {ArtistName} via /artist/?id=", artist.Name);
var artistElement = artistSource.Value;
await _cache.SetAsync(cacheKey, artist, GetMetadataCacheTtl());
// Extract picture UUID (may be null)
string? pictureUuid = null;
if (artistElement.TryGetProperty("picture", out var pictureEl) && pictureEl.ValueKind != JsonValueKind.Null)
{
pictureUuid = pictureEl.GetString();
}
return artist;
// Normalize artist data to include album count
var normalizedArtist = new JsonObject
{
["id"] = artistElement.GetProperty("id").GetInt64(),
["name"] = artistElement.GetProperty("name").GetString(),
["albums_count"] = albumCount,
["picture"] = pictureUuid
};
using var doc = JsonDocument.Parse(normalizedArtist.ToJsonString());
var artist = ParseTidalArtist(doc.RootElement);
_logger.LogDebug("Successfully parsed artist {ArtistName} with {AlbumCount} albums", artist.Name, albumCount);
// Cache for configurable duration
await _cache.SetAsync(cacheKey, artist, CacheExtensions.MetadataTTL);
return artist;
}, (Artist?)null);
}
@@ -877,14 +680,15 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
{
_logger.LogDebug("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
// Per hifi-api README: /artist/?f={artistId} returns aggregated releases and tracks
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogDebug("Fetching artist albums from URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
_logger.LogError("SquidWTF artist albums request failed with status {StatusCode}", response.StatusCode);
return new List<Album>();
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
@@ -908,8 +712,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
}
else
{
throw new InvalidOperationException(
$"SquidWTF artist albums response for {externalId} did not contain albums.items");
_logger.LogWarning("No albums found in response for artist {ExternalId}", externalId);
}
return albums;
@@ -924,14 +727,15 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
{
_logger.LogDebug("GetArtistTracksAsync called for SquidWTF artist {ExternalId}", externalId);
// Per hifi-api README: /artist/?f={artistId} returns both albums and tracks
// Same endpoint as albums - /artist/?f={artistId} returns both albums and tracks
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogDebug("Fetching artist tracks from URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
_logger.LogError("SquidWTF artist tracks request failed with status {StatusCode}", response.StatusCode);
return new List<Song>();
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
@@ -952,8 +756,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
}
else
{
throw new InvalidOperationException(
$"SquidWTF artist tracks response for {externalId} did not contain tracks");
_logger.LogWarning("No tracks found in response for artist {ExternalId}", externalId);
}
return tracks;
@@ -966,29 +769,21 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = BuildPagedEndpointUrl(baseUrl, "playlist", "id", externalId, RemoteSearchMinLimit);
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
if (!response.IsSuccessStatusCode) return null;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var result = JsonDocument.Parse(json);
var rootElement = result.RootElement;
var rootElement = JsonDocument.Parse(json).RootElement;
// Check for error response
if (rootElement.TryGetProperty("error", out _))
{
throw new InvalidOperationException($"SquidWTF playlist response for {externalId} contained an error payload");
}
if (rootElement.TryGetProperty("error", out _)) return null;
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
// Extract the playlist object from the response
if (!rootElement.TryGetProperty("playlist", out var playlistElement))
{
throw new InvalidOperationException($"SquidWTF playlist response for {externalId} did not contain playlist");
}
return null;
return ParseTidalPlaylist(playlistElement);
}, (ExternalPlaylist?)null);
@@ -1000,85 +795,64 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<Song>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var playlistElement = JsonDocument.Parse(json).RootElement;
// Check for error response
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
JsonElement? playlist = null;
JsonElement? tracks = null;
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
if (playlistElement.TryGetProperty("playlist", out var playlistEl))
{
playlist = playlistEl;
}
if (playlistElement.TryGetProperty("items", out var tracksEl))
{
tracks = tracksEl;
}
var songs = new List<Song>();
var offset = DefaultSearchOffset;
var rawTrackCount = 0;
var trackIndex = 1;
string playlistName = "Unknown Playlist";
int? expectedTrackCount = null;
while (true)
{
var url = BuildPagedEndpointUrl(baseUrl, "playlist", "id", externalId, MetadataPageSize, offset);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
// Get playlist name for album field
var playlistName = playlist?.TryGetProperty("title", out var titleEl) == true
? titleEl.GetString() ?? "Unknown Playlist"
: "Unknown Playlist";
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var result = JsonDocument.Parse(json);
var playlistElement = result.RootElement;
if (tracks.HasValue)
{
int trackIndex = 1;
foreach (var entry in tracks.Value.EnumerateArray())
{
// Each item is wrapped: { "item": { track object } }
if (!entry.TryGetProperty("item", out var track))
continue;
if (playlistElement.TryGetProperty("error", out _))
{
throw new InvalidOperationException($"SquidWTF playlist tracks response for {externalId} contained an error payload");
}
// For playlists, use the track's own artist (not a single album artist)
var song = ParseTidalTrack(track, trackIndex);
if (playlistElement.TryGetProperty("playlist", out var playlistEl))
{
if (playlistEl.TryGetProperty("title", out var titleEl))
{
playlistName = titleEl.GetString() ?? playlistName;
}
// Override album name to be the playlist name
song.Album = playlistName;
if (!expectedTrackCount.HasValue &&
playlistEl.TryGetProperty("numberOfTracks", out var trackCountEl) &&
trackCountEl.ValueKind == JsonValueKind.Number)
{
expectedTrackCount = trackCountEl.GetInt32();
}
}
if (!playlistElement.TryGetProperty("items", out var tracks) || tracks.ValueKind != JsonValueKind.Array)
{
throw new InvalidOperationException(
$"SquidWTF playlist tracks response for {externalId} did not contain items");
}
var pageCount = 0;
foreach (var entry in tracks.EnumerateArray())
{
pageCount++;
if (!entry.TryGetProperty("item", out var track))
{
continue;
}
var song = ParseTidalTrack(track, trackIndex);
song.Album = playlistName;
song.DiscNumber = null;
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
{
songs.Add(song);
}
trackIndex++;
}
rawTrackCount += pageCount;
if (pageCount == 0 ||
pageCount < MetadataPageSize ||
(expectedTrackCount.HasValue && rawTrackCount >= expectedTrackCount.Value))
{
break;
}
offset += pageCount;
}
// Playlists should not have disc numbers - always set to null
// This prevents Jellyfin from splitting the playlist into multiple "discs"
song.DiscNumber = null;
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
{
songs.Add(song);
}
trackIndex++;
}
}
return songs;
}, new List<Song>());
}
@@ -1405,18 +1179,10 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
var externalId = artist.GetProperty("id").GetInt64().ToString();
var artistName = artist.GetProperty("name").GetString() ?? "";
var imageUrl = artist.TryGetProperty("picture", out var picture) &&
picture.ValueKind == JsonValueKind.String
var imageUrl = artist.TryGetProperty("picture", out var picture)
? BuildTidalImageUrl(picture.GetString(), "320x320")
: null;
if (string.IsNullOrWhiteSpace(imageUrl) &&
artist.TryGetProperty("imageUrl", out var imageUrlElement) &&
imageUrlElement.ValueKind == JsonValueKind.String)
{
imageUrl = imageUrlElement.GetString();
}
if (!string.IsNullOrWhiteSpace(imageUrl))
{
_logger.LogDebug("Artist {ArtistName} picture: {ImageUrl}", artistName, imageUrl);
@@ -1438,7 +1204,8 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
/// <summary>
/// Parses a Tidal playlist from hifi-api /playlist/ endpoint response.
/// Response structure: { "playlist": { uuid, title, description, creator, created, numberOfTracks, duration, squareImage },
/// Per hifi-api spec (undocumented), response structure is:
/// { "playlist": { uuid, title, description, creator, created, numberOfTracks, duration, squareImage },
/// "items": [ { "item": { track object } } ] }
/// </summary>
/// <param name="playlistElement">Root JSON element containing playlist and items</param>
@@ -1588,14 +1355,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
/// </summary>
public async Task<List<Song?>> SearchSongsInParallelAsync(List<string> queries, int limit = 10, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeRemoteLimit(limit);
return await _fallbackHelper.ProcessInParallelAsync(
queries,
async (baseUrl, query, ct) =>
{
try
{
var url = BuildSearchUrl(baseUrl, "s", query, normalizedLimit);
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
+6 -11
View File
@@ -8,8 +8,7 @@
}
},
"Debug": {
"LogAllRequests": false,
"RedactSensitiveRequestValues": false
"LogAllRequests": false
},
"Backend": {
"Type": "Subsonic"
@@ -51,25 +50,22 @@
"Qobuz": {
"UserAuthToken": "your-qobuz-token",
"UserId": "your-qobuz-user-id",
"Quality": "FLAC",
"MinRequestIntervalMs": 200
"Quality": "FLAC"
},
"Deezer": {
"Arl": "your-deezer-arl-token",
"ArlFallback": "",
"Quality": "FLAC",
"MinRequestIntervalMs": 200
"Quality": "FLAC"
},
"SquidWTF": {
"Quality": "FLAC",
"MinRequestIntervalMs": 200
"Quality": "FLAC"
},
"Redis": {
"Enabled": true,
"ConnectionString": "localhost:6379"
},
"Cache": {
"SearchResultsMinutes": 1,
"SearchResultsMinutes": 120,
"PlaylistImagesHours": 168,
"SpotifyPlaylistItemsHours": 168,
"SpotifyMatchedTracksDays": 30,
@@ -77,8 +73,7 @@
"GenreDays": 30,
"MetadataDays": 7,
"OdesliLookupDays": 60,
"ProxyImagesDays": 14,
"TranscodeCacheMinutes": 60
"ProxyImagesDays": 14
},
"SpotifyImport": {
"Enabled": false,
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.5 KiB

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

Before

Width:  |  Height:  |  Size: 980 B

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

Before

Width:  |  Height:  |  Size: 3.2 KiB

+58 -150
View File
@@ -12,8 +12,8 @@
<!-- Restart Required Banner -->
<div class="restart-banner" id="restart-banner">
⚠️ Configuration changed. Restart required to apply changes.
<button data-action="restartContainer">Restart Allstarr</button>
<button data-action="dismissRestartBanner"
<button onclick="restartContainer()">Restart Now</button>
<button onclick="dismissRestartBanner()"
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
</div>
@@ -32,78 +32,36 @@
<div class="auth-error" id="auth-error" role="alert"></div>
</form>
</div>
<div class="support-badge">
<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 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 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>
<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="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>
<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>
</div>
</aside>
</div>
</header>
<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="endpoints">API Analytics</div>
</div>
<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>
<!-- Dashboard Tab -->
<div class="tab-content active" id="tab-dashboard">
@@ -155,9 +113,9 @@
</h2>
<div id="dashboard-guidance" class="guidance-stack"></div>
<div class="card-actions-row">
<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 class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
<button onclick="clearCache()">Clear Cache</button>
<button onclick="openAddPlaylist()">Add Playlist</button>
<button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button>
</div>
</div>
@@ -172,7 +130,7 @@
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
</div>
</h2>
<p class="text-secondary mb-16">
<p style="color: var(--text-secondary); margin-bottom: 16px;">
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
@@ -180,9 +138,10 @@
</p>
<div id="jellyfin-guidance" class="guidance-stack"></div>
<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>
<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>
<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>
@@ -258,7 +217,7 @@
</div>
</details>
<p class="text-secondary mb-12">
<p style="color: var(--text-secondary); margin-bottom: 12px;">
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music
service.
</p>
@@ -294,14 +253,15 @@
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" class="summary-box">
<div id="mappings-summary"
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div>
<span class="summary-label">Total:</span>
<span class="summary-value" id="mappings-total">0</span>
<span style="color: var(--text-secondary);">Total:</span>
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
</div>
<div>
<span class="summary-label">External:</span>
<span class="summary-value success"
<span style="color: var(--text-secondary);">External:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--success);"
id="mappings-external">0</span>
</div>
</div>
@@ -334,14 +294,15 @@
<button onclick="fetchMissingTracks()">Refresh</button>
</div>
</h2>
<p class="text-secondary mb-12">
<p style="color: var(--text-secondary); margin-bottom: 12px;">
Tracks that couldn't be matched locally or externally. Map them manually to add them to your
playlists.
</p>
<div id="missing-summary" class="summary-box">
<div id="missing-summary"
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div>
<span class="summary-label">Total Missing:</span>
<span class="summary-value warning"
<span style="color: var(--text-secondary);">Total Missing:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);"
id="missing-total">0</span>
</div>
</div>
@@ -372,23 +333,23 @@
<h2>
Kept Downloads
<div class="actions">
<button onclick="downloadAllKept()" class="primary">Download All</button>
<button onclick="deleteAllKept()" class="danger">Delete All</button>
<button onclick="downloadAllKept()" style="background:var(--accent);border-color:var(--accent);">Download All</button>
<button onclick="fetchDownloads()">Refresh</button>
</div>
</h2>
<p class="text-secondary mb-12">
<p style="color: var(--text-secondary); margin-bottom: 12px;">
Downloaded files stored permanently. Download individual tracks or download all as a zip archive.
</p>
<div id="downloads-summary" class="summary-box">
<div id="downloads-summary"
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
<div>
<span class="summary-label">Total Files:</span>
<span class="summary-value accent"
<span style="color: var(--text-secondary);">Total Files:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);"
id="downloads-count">0</span>
</div>
<div>
<span class="summary-label">Total Size:</span>
<span class="summary-value accent" id="downloads-size">0
<span style="color: var(--text-secondary);">Total Size:</span>
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0
B</span>
</div>
</div>
@@ -432,11 +393,6 @@
<span class="value" id="local-tracks-enabled-value">-</span>
<button onclick="toggleLocalTracksEnabled()">Toggle</button>
</div>
<div class="config-item">
<span class="label">Synthetic Local Played Signal</span>
<span class="value" id="synthetic-local-played-signal-enabled-value">-</span>
<button onclick="toggleSyntheticLocalPlayedSignalEnabled()">Toggle</button>
</div>
</div>
<div style="background: rgba(255, 193, 7, 0.15); border: 1px solid #ffc107; border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
@@ -444,7 +400,6 @@
<br><a href="https://github.com/danielfariati/jellyfin-plugin-lastfm" target="_blank" style="color: var(--accent);">Last.fm Plugin</a>
<br><a href="https://github.com/lyarenei/jellyfin-plugin-listenbrainz" target="_blank" style="color: var(--accent);">ListenBrainz Plugin</a>
<br>This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz).
<br><strong>Default:</strong> keep Synthetic Local Played Signal disabled to avoid duplicate plugin scrobbles.
</div>
</div>
@@ -670,12 +625,6 @@
<button
onclick="openEditSetting('DEEZER_QUALITY', 'Deezer Quality', 'select', '', ['FLAC', 'MP3_320', 'MP3_128'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Request Interval</span>
<span class="value" id="config-deezer-ratelimit">200 ms</span>
<button
onclick="openEditSetting('DEEZER_MIN_REQUEST_INTERVAL_MS', 'Deezer Request Interval', 'number', 'Minimum milliseconds between API requests (default: 200)')">Edit</button>
</div>
</div>
</div>
@@ -688,12 +637,6 @@
<button
onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', 'HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest)\\nLOSSLESS: 16-bit/44.1kHz FLAC (default)\\nHIGH: 320kbps AAC\\nLOW: 96kbps AAC', ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Request Interval</span>
<span class="value" id="config-squid-ratelimit">200 ms</span>
<button
onclick="openEditSetting('SQUIDWTF_MIN_REQUEST_INTERVAL_MS', 'SquidWTF Request Interval', 'number', 'Minimum milliseconds between API requests (default: 200)')">Edit</button>
</div>
</div>
</div>
@@ -731,16 +674,10 @@
onclick="openEditSetting('QOBUZ_USER_AUTH_TOKEN', 'Qobuz User Auth Token', 'password', 'Get from browser while logged into Qobuz')">Update</button>
</div>
<div class="config-item">
<span class="label">Preferred Quality</span>
<span class="label">Quality</span>
<span class="value" id="config-qobuz-quality">-</span>
<button
onclick="openEditSetting('QOBUZ_QUALITY', 'Qobuz Quality', 'select', 'Default: FLAC', ['FLAC', 'FLAC_24_HIGH', 'FLAC_24_LOW', 'FLAC_16', 'MP3_320'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Request Interval</span>
<span class="value" id="config-qobuz-ratelimit">200 ms</span>
<button
onclick="openEditSetting('QOBUZ_MIN_REQUEST_INTERVAL_MS', 'Qobuz Request Interval', 'number', 'Minimum milliseconds between API requests (default: 200)')">Edit</button>
onclick="openEditSetting('QOBUZ_QUALITY', 'Qobuz Quality', 'select', '', ['FLAC_24_192', 'FLAC_24_96', 'FLAC_16_44', 'MP3_320'])">Edit</button>
</div>
</div>
</div>
@@ -890,7 +827,7 @@
</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="danger" onclick="clearCache()">Clear All Cache</button>
<button class="danger" onclick="restartContainer()">Restart Allstarr</button>
<button class="danger" onclick="restartContainer()">Restart Container</button>
</div>
</div>
</div>
@@ -986,35 +923,6 @@
</p>
</div>
</div>
<footer class="support-footer">
<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>
</footer>
</main>
</div>
</div>
<!-- Add Playlist Modal -->
-84
View File
@@ -1,84 +0,0 @@
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 };
}
+3 -16
View File
@@ -124,14 +124,6 @@ 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",
@@ -152,15 +144,10 @@ export async function fetchJellyfinUsers() {
return requestOptionalJson("/api/admin/jellyfin/users");
}
export async function fetchJellyfinPlaylists(userId = null, includeStats = true) {
export async function fetchJellyfinPlaylists(userId = null) {
let url = "/api/admin/jellyfin/playlists";
const params = [];
if (userId) {
params.push("userId=" + encodeURIComponent(userId));
}
params.push("includeStats=" + String(Boolean(includeStats)));
if (params.length > 0) {
url += "?" + params.join("&");
url += "?userId=" + encodeURIComponent(userId);
}
return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
@@ -287,7 +274,7 @@ export async function restartContainer() {
return requestJson(
"/api/admin/restart",
{ method: "POST" },
"Failed to restart Allstarr",
"Failed to restart container",
);
}
+7 -173
View File
@@ -1,4 +1,4 @@
import { escapeHtml, escapeJs, showToast, formatCookieAge } from "./utils.js";
import { escapeHtml, showToast, formatCookieAge } from "./utils.js";
import * as API from "./api.js";
import * as UI from "./ui.js";
import { renderCookieAge } from "./settings-editor.js";
@@ -6,7 +6,6 @@ import { runAction } from "./operations.js";
let playlistAutoRefreshInterval = null;
let dashboardRefreshInterval = null;
let downloadActivityEventSource = null;
let isAuthenticated = () => false;
let isAdminSession = () => false;
@@ -15,8 +14,6 @@ let onCookieNeedsInit = async () => {};
let setCurrentConfigState = () => {};
let syncConfigUiExtras = () => {};
let loadScrobblingConfig = () => {};
let injectedPlaylistRequestToken = 0;
let jellyfinPlaylistRequestToken = 0;
async function fetchStatus() {
try {
@@ -40,20 +37,10 @@ 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");
@@ -141,7 +128,6 @@ async function fetchMissingTracks() {
missing.forEach((t) => {
missingTracks.push({
playlist: playlist.name,
provider: t.externalProvider || t.provider || "squidwtf",
...t,
});
});
@@ -164,7 +150,6 @@ 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;
@@ -177,7 +162,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="${escapeHtml(provider)}">🔍 Search</button>
data-provider="squidwtf">🔍 Search</button>
<button class="map-action-btn map-action-local missing-track-local-btn"
data-playlist="${escapeHtml(t.playlist)}"
data-position="${trackPosition}"
@@ -227,9 +212,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 data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
<button onclick="downloadFile('${escapeJs(f.path)}')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
<button onclick="deleteDownload('${escapeJs(f.path)}')"
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
</td>
</tr>
@@ -259,28 +244,11 @@ 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 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);
});
const data = await API.fetchJellyfinPlaylists(userId);
UI.updateJellyfinPlaylistsUI(data);
} catch (error) {
console.error("Failed to fetch Jellyfin playlists:", error);
tbody.innerHTML =
@@ -356,10 +324,6 @@ function stopDashboardRefresh() {
clearInterval(dashboardRefreshInterval);
dashboardRefreshInterval = null;
}
if (downloadActivityEventSource) {
downloadActivityEventSource.close();
downloadActivityEventSource = null;
}
stopPlaylistAutoRefresh();
}
@@ -377,10 +341,7 @@ function startDashboardRefresh() {
fetchPlaylists();
fetchTrackMappings();
fetchMissingTracks();
const keptTab = document.getElementById("tab-kept");
if (keptTab && keptTab.classList.contains("active")) {
fetchDownloads();
}
fetchDownloads();
const endpointsTab = document.getElementById("tab-endpoints");
if (endpointsTab && endpointsTab.classList.contains("active")) {
@@ -416,133 +377,6 @@ async function loadDashboardData() {
startDashboardRefresh();
}
function startDownloadActivityStream() {
if (!isAdminSession()) return;
if (downloadActivityEventSource) {
downloadActivityEventSource.close();
}
downloadActivityEventSource = new EventSource("/api/admin/downloads/activity");
downloadActivityEventSource.onmessage = (event) => {
try {
const downloads = JSON.parse(event.data);
renderDownloadActivity(downloads);
} catch (err) {
console.error("Failed to parse download activity:", err);
}
};
downloadActivityEventSource.onerror = (err) => {
console.error("Download activity SSE error:", err);
// EventSource will auto-reconnect
};
}
function renderDownloadActivity(downloads) {
const container = document.getElementById("download-activity-list");
if (!container) return;
if (!downloads || downloads.length === 0) {
container.innerHTML = '<div class="empty-state">No active downloads</div>';
return;
}
const statusIcons = {
0: '⏳', // NotStarted
1: '<span class="spinner" style="border-width:2px; height:12px; width:12px; display:inline-block; margin-right:4px;"></span> Downloading', // InProgress
2: '✅ Completed', // Completed
3: '❌ Failed' // Failed
};
const html = downloads.map(d => {
const downloadProgress = clampProgress(d.progress);
const playbackProgress = clampProgress(d.playbackProgress);
// Determine elapsed/duration text
let timeText = "";
if (d.startedAt) {
const start = new Date(d.startedAt);
const end = d.completedAt ? new Date(d.completedAt) : new Date();
const diffSecs = Math.floor((end.getTime() - start.getTime()) / 1000);
timeText = diffSecs < 60 ? `${diffSecs}s` : `${Math.floor(diffSecs/60)}m ${diffSecs%60}s`;
}
const progressMeta = [];
if (typeof d.durationSeconds === "number" && typeof d.playbackPositionSeconds === "number") {
progressMeta.push(`${formatSeconds(d.playbackPositionSeconds)} / ${formatSeconds(d.durationSeconds)}`);
} else if (typeof d.durationSeconds === "number") {
progressMeta.push(formatSeconds(d.durationSeconds));
}
if (d.requestedForStreaming) {
progressMeta.push("stream");
}
const progressMetaText = progressMeta.length > 0
? `<div class="download-progress-meta">${progressMeta.map(escapeHtml).join(" • ")}</div>`
: "";
const progressBar = `
<div class="download-progress-bar" aria-hidden="true">
<div class="download-progress-buffer" style="width:${downloadProgress * 100}%"></div>
<div class="download-progress-playback" style="width:${playbackProgress * 100}%"></div>
</div>
${progressMetaText}
`;
const title = d.title || 'Unknown Title';
const artist = d.artist || 'Unknown Artist';
const errorText = d.errorMessage ? `<div style="color:var(--error); font-size:0.8rem; margin-top:4px;">${escapeHtml(d.errorMessage)}</div>` : '';
const streamBadge = d.requestedForStreaming
? '<span class="download-queue-badge">Stream</span>'
: '';
const playingBadge = d.isPlaying
? '<span class="download-queue-badge is-playing">Playing</span>'
: '';
return `
<div class="download-queue-item">
<div class="download-queue-info">
<div class="download-queue-title">${escapeHtml(title)}</div>
<div class="download-queue-meta">
<span class="download-queue-artist">${escapeHtml(artist)}</span>
<span class="download-queue-provider">${escapeHtml(d.externalProvider)}</span>
${streamBadge}
${playingBadge}
</div>
${progressBar}
${errorText}
</div>
<div class="download-queue-status">
<span style="font-size:0.85rem;">${statusIcons[d.status] || 'Unknown'}</span>
<span class="download-queue-time">${timeText}</span>
</div>
</div>
`;
}).join('');
container.innerHTML = html;
}
function clampProgress(value) {
if (typeof value !== "number" || Number.isNaN(value)) {
return 0;
}
return Math.max(0, Math.min(1, value));
}
function formatSeconds(totalSeconds) {
if (typeof totalSeconds !== "number" || Number.isNaN(totalSeconds) || totalSeconds < 0) {
return "0:00";
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = Math.floor(totalSeconds % 60);
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
export function initDashboardData(options) {
isAuthenticated = options.isAuthenticated;
isAdminSession = options.isAdminSession;
+11 -32
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="#" 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>`
? `<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>`
: "";
const missingSearchLink =
t.isLocal === null && t.searchQuery
? `<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>`
? `<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>`
: "";
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>`;
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>`;
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)}" data-action="selectJellyfinTrack" data-arg-jellyfin-id="${escapeHtml(escapeJs(id))}">
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" onclick="selectJellyfinTrack('${escapeJs(id)}')">
<div>
<strong>${escapeHtml(title)}</strong>
<br>
@@ -344,15 +344,7 @@ export async function searchExternalTracks() {
const externalUrl = track.url || "";
return `
<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 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>
<strong>${escapeHtml(title)}</strong>
<br>
@@ -670,26 +662,13 @@ export async function saveLyricsMapping() {
// Search provider (open in new tab)
export async function searchProvider(query, provider) {
try {
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)}`;
}
const data = await API.getSquidWTFBaseUrl();
const baseUrl = data.baseUrl; // Use the actual property name from API
const searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
window.open(searchUrl, "_blank");
} catch (error) {
console.error("Failed to open provider search:", error);
showToast("Failed to open provider search link", "warning");
console.error("Failed to get SquidWTF base URL:", error);
// Fallback to first encoded URL (triton)
showToast("Failed to get SquidWTF URL, using fallback", "warning");
}
}
+28 -103
View File
@@ -34,13 +34,17 @@ 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";
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");
@@ -54,30 +58,17 @@ 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();
}
}
};
@@ -147,112 +138,46 @@ 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.searchProvider = searchProvider;
window.selectExternalTrack = selectExternalTrack;
window.validateExternalMapping = validateExternalMapping;
window.openLyricsMap = openLyricsMap;
window.saveLyricsMapping = saveLyricsMapping;
// Note: viewTracks/selectExternalTrack/selectJellyfinTrack/openLyricsMap/searchProvider
// are now wired via the ActionDispatcher and no longer require window exports.
window.searchProvider = searchProvider;
document.addEventListener("DOMContentLoaded", () => {
console.log("🚀 Allstarr Admin UI (Modular) loaded");
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);
}
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
window.switchTab(tab.dataset.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),
);
initNavigationView({ switchTab: window.switchTab });
const hash = window.location.hash.substring(1);
if (hash) {
window.switchTab(hash);
}
setupModalBackdropClose();
initScrobblingView({
isAuthenticated: () => authSession.isAuthenticated(),
loadScrobblingConfig: () => window.loadScrobblingConfig?.(),
});
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
if (scrobblingTab) {
scrobblingTab.addEventListener("click", () => {
if (authSession.isAuthenticated()) {
window.loadScrobblingConfig();
}
});
}
authSession.bootstrapAuth();
});
+6 -89
View File
@@ -1,100 +1,17 @@
// 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) {
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();
}
document.getElementById(id).classList.add('active');
}
export function closeModal(id) {
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);
document.getElementById(id).classList.remove('active');
}
export function setupModalBackdropClose() {
document.querySelectorAll(".modal").forEach((modal) => {
modal.setAttribute("aria-hidden", "true");
modal.addEventListener("click", (e) => {
if (e.target === modal) closeModal(modal.id);
document.querySelectorAll('.modal').forEach(modal => {
modal.addEventListener('click', e => {
if (e.target === modal) closeModal(modal.id);
});
});
});
}
+7 -22
View File
@@ -77,20 +77,6 @@ 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.`,
@@ -155,7 +141,7 @@ async function refreshPlaylist(name) {
async function clearPlaylistCache(name) {
const result = await runAction({
confirmMessage: `Rebuild "${name}" from scratch?\n\nThis will:\n• Clear all caches\n• Fetch fresh Spotify playlist data\n• Re-match all tracks\n\nThis uses the same workflow as that playlist's scheduled cron rebuild.\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`,
confirmMessage: `Rebuild "${name}" from scratch?\n\nThis will:\n• Clear all caches\n• Fetch fresh Spotify playlist data\n• Re-match all tracks\n\nThis is the SAME process as the scheduled cron job.\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`,
before: async () => {
setMatchingBannerVisible(true);
showToast(`Rebuilding ${name} from scratch...`, "info");
@@ -222,10 +208,10 @@ async function matchAllPlaylists() {
async function refreshAndMatchAll() {
const result = await runAction({
confirmMessage:
"Rebuild all playlists from scratch?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Re-match all tracks against local library and external providers\n\nThis is a manual bulk rebuild across all playlists.\n\nThis may take several minutes.",
"Rebuild all playlists from scratch?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Re-match all tracks against local library and external providers\n\nThis is the SAME process as the scheduled cron job.\n\nThis may take several minutes.",
before: async () => {
setMatchingBannerVisible(true);
showToast("Starting full rebuild for all playlists...", "info", 3000);
showToast("Starting full rebuild (same as cron job)...", "info", 3000);
},
task: () => API.rebuildAllPlaylists(),
success: "✓ Full rebuild complete!",
@@ -284,7 +270,7 @@ async function importEnv(event) {
const result = await runAction({
confirmMessage:
"Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart Allstarr for changes to take effect.",
"Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.",
task: () => API.importEnv(file),
success: (data) => data.message,
error: (err) => err.message || "Failed to import .env file",
@@ -297,7 +283,7 @@ async function importEnv(event) {
async function restartContainer() {
if (
!confirm(
"Restart Allstarr to reload /app/.env and apply configuration changes?\n\nThe dashboard will be temporarily unavailable.",
"Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.",
)
) {
return;
@@ -305,7 +291,7 @@ async function restartContainer() {
const result = await runAction({
task: () => API.restartContainer(),
error: "Failed to restart Allstarr",
error: "Failed to restart container",
});
if (!result) {
@@ -315,7 +301,7 @@ async function restartContainer() {
document.getElementById("restart-overlay")?.classList.add("active");
const statusEl = document.getElementById("restart-status");
if (statusEl) {
statusEl.textContent = "Restarting Allstarr...";
statusEl.textContent = "Stopping container...";
}
setTimeout(() => {
@@ -378,7 +364,6 @@ 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;
+1 -6
View File
@@ -70,12 +70,7 @@ async function openLinkPlaylist(jellyfinId, name) {
}
try {
const response = await API.fetchSpotifyUserPlaylists(selectedUserId);
spotifyUserPlaylists = Array.isArray(response?.playlists)
? response.playlists
: Array.isArray(response)
? response
: [];
spotifyUserPlaylists = await API.fetchSpotifyUserPlaylists(selectedUserId);
spotifyUserPlaylistsScopeUserId = selectedUserId;
const availablePlaylists = spotifyUserPlaylists.filter((p) => !p.isLinked);
-16
View File
@@ -70,12 +70,6 @@ async function loadScrobblingConfig() {
? "Enabled"
: "Disabled";
document.getElementById(
"synthetic-local-played-signal-enabled-value",
).textContent = data.scrobbling.syntheticLocalPlayedSignalEnabled
? "Enabled"
: "Disabled";
document.getElementById("lastfm-enabled-value").textContent = data
.scrobbling.lastFm.enabled
? "Enabled"
@@ -212,14 +206,6 @@ async function toggleLocalTracksEnabled() {
});
}
async function toggleSyntheticLocalPlayedSignalEnabled() {
await toggleScrobblingSetting(
"SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED",
"Synthetic local played signal",
(config) => config?.scrobbling?.syntheticLocalPlayedSignalEnabled,
);
}
async function toggleLastFmEnabled() {
await toggleScrobblingSetting(
"SCROBBLING_LASTFM_ENABLED",
@@ -346,8 +332,6 @@ export function initScrobblingAdmin(options) {
window.loadScrobblingConfig = loadScrobblingConfig;
window.toggleScrobblingEnabled = toggleScrobblingEnabled;
window.toggleLocalTracksEnabled = toggleLocalTracksEnabled;
window.toggleSyntheticLocalPlayedSignalEnabled =
toggleSyntheticLocalPlayedSignalEnabled;
window.toggleLastFmEnabled = toggleLastFmEnabled;
window.toggleListenBrainzEnabled = toggleListenBrainzEnabled;
window.editLastFmUsername = editLastFmUsername;
-21
View File
@@ -211,26 +211,12 @@ const SETTINGS_REGISTRY = {
ensureConfigSection(config, "deezer").quality = value;
},
),
DEEZER_MIN_REQUEST_INTERVAL_MS: numberBinding(
(config) => config?.deezer?.minRequestIntervalMs ?? 200,
(config, value) => {
ensureConfigSection(config, "deezer").minRequestIntervalMs = value;
},
200,
),
SQUIDWTF_QUALITY: textBinding(
(config) => config?.squidWtf?.quality ?? "LOSSLESS",
(config, value) => {
ensureConfigSection(config, "squidWtf").quality = value;
},
),
SQUIDWTF_MIN_REQUEST_INTERVAL_MS: numberBinding(
(config) => config?.squidWtf?.minRequestIntervalMs ?? 200,
(config, value) => {
ensureConfigSection(config, "squidWtf").minRequestIntervalMs = value;
},
200,
),
MUSICBRAINZ_ENABLED: toggleBinding(
(config) => config?.musicBrainz?.enabled ?? false,
(config, value) => {
@@ -261,13 +247,6 @@ const SETTINGS_REGISTRY = {
ensureConfigSection(config, "qobuz").quality = value;
},
),
QOBUZ_MIN_REQUEST_INTERVAL_MS: numberBinding(
(config) => config?.qobuz?.minRequestIntervalMs ?? 200,
(config, value) => {
ensureConfigSection(config, "qobuz").minRequestIntervalMs = value;
},
200,
),
JELLYFIN_URL: textBinding(
(config) => config?.jellyfin?.url ?? "",
(config, value) => {
+98 -432
View File
@@ -3,9 +3,6 @@
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
let rowMenuHandlersBound = false;
let tableRowHandlersBound = false;
const expandedInjectedPlaylistDetails = new Set();
let openInjectedPlaylistMenuKey = null;
function bindRowMenuHandlers() {
if (rowMenuHandlersBound) {
@@ -19,55 +16,12 @@ 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) {
@@ -78,13 +32,6 @@ 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;
}
}
}
@@ -101,14 +48,6 @@ 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) {
@@ -143,18 +82,6 @@ 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);
}
}
}
}
}
@@ -248,275 +175,6 @@ 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;
@@ -525,11 +183,10 @@ if (typeof window !== "undefined") {
}
bindRowMenuHandlers();
bindTableRowHandlers();
export function updateStatusUI(data) {
const sidebarVersionEl = document.getElementById("sidebar-version");
if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version;
const versionEl = document.getElementById("version");
if (versionEl) versionEl.textContent = "v" + data.version;
const backendTypeEl = document.getElementById("backend-type");
if (backendTypeEl) backendTypeEl.textContent = data.backendType;
@@ -611,15 +268,9 @@ 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", [
@@ -673,68 +324,89 @@ export function updatePlaylistsUI(data) {
});
renderGuidance("playlists-guidance", guidance);
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;
}
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 detailsRowId = mainRow.getAttribute("data-details-row");
const detailsRow =
(detailsRowId && document.getElementById(detailsRowId)) ||
mainRow.nextElementSibling;
if (!detailsRow) {
return;
}
const breakdownBadges = [
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
`<span class="status-pill info">${summary.externalMatched} External</span>`,
];
existingPairs.set(detailsKey, { mainRow, detailsRow });
});
if (summary.externalMissing > 0) {
breakdownBadges.push(
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
);
}
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;
}
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("");
}
export function updateTrackMappingsUI(data) {
@@ -806,9 +478,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 data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
<button onclick="downloadFile('${escapeJs(f.path)}')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
<button onclick="deleteDownload('${escapeJs(f.path)}')"
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
</td>
</tr>
@@ -864,12 +536,8 @@ export function updateConfigUI(data) {
data.deezer.arl || "(not set)";
document.getElementById("config-deezer-quality").textContent =
data.deezer.quality;
document.getElementById("config-deezer-ratelimit").textContent =
(data.deezer.minRequestIntervalMs || 200) + " ms";
document.getElementById("config-squid-quality").textContent =
data.squidWtf.quality;
document.getElementById("config-squid-ratelimit").textContent =
(data.squidWtf.minRequestIntervalMs || 200) + " ms";
document.getElementById("config-musicbrainz-enabled").textContent = data
.musicBrainz.enabled
? "Yes"
@@ -878,8 +546,6 @@ export function updateConfigUI(data) {
data.qobuz.userAuthToken || "(not set)";
document.getElementById("config-qobuz-quality").textContent =
data.qobuz.quality || "FLAC";
document.getElementById("config-qobuz-ratelimit").textContent =
(data.qobuz.minRequestIntervalMs || 200) + " ms";
document.getElementById("config-jellyfin-url").textContent =
data.jellyfin.url || "-";
document.getElementById("config-jellyfin-api-key").textContent =
@@ -962,27 +628,26 @@ 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 = escapeHtml(playlist.id);
const escapedName = escapeHtml(playlist.name);
const escapedId = escapeJs(playlist.id);
const escapedName = escapeJs(playlist.name);
const statusClass = playlist.isConfigured ? "success" : "info";
const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked";
const actionButtons = playlist.isConfigured
? `
<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}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
<button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); unlinkPlaylist('${escapedId}', '${escapedName}')">Unlink from Spotify</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>
<button onclick="closeRowMenu(event, '${menuId}'); openLinkPlaylist('${escapedId}', '${escapedName}')">Link to Spotify</button>
<button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
`;
return `
<tr class="compact-row" data-details-row="${detailsRowId}">
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
<td>
<div class="name-cell">
<strong>${escapeHtml(playlist.name)}</strong>
@@ -990,15 +655,16 @@ export function updateJellyfinPlaylistsUI(data) {
</div>
</td>
<td>
<span class="track-count">${statsPending ? "..." : localCount + externalAvailable}</span>
<div class="meta-text">${statsPending ? "Loading track stats..." : `L ${localCount} • E ${externalAvailable}/${externalCount}`}</div>
<span class="track-count">${localCount + externalAvailable}</span>
<div class="meta-text">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">Details</button>
<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"
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
onclick="toggleRowMenu(event, '${menuId}')">...</button>
<div class="row-actions-menu" id="${menuId}" role="menu">
${actionButtons}
</div>
@@ -1011,11 +677,11 @@ export function updateJellyfinPlaylistsUI(data) {
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">Local Tracks</span>
<span class="detail-value">${statsPending ? "..." : localCount}</span>
<span class="detail-value">${localCount}</span>
</div>
<div class="detail-item">
<span class="detail-label">External Tracks</span>
<span class="detail-value">${statsPending ? "Loading..." : `${externalAvailable}/${externalCount}`}</span>
<span class="detail-value">${externalAvailable}/${externalCount}</span>
</div>
<div class="detail-item">
<span class="detail-label">Linked Spotify ID</span>
-4
View File
@@ -1,4 +0,0 @@
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.
@@ -1,22 +0,0 @@
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);
}
}
@@ -1,30 +0,0 @@
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);
}
}
-48
View File
@@ -4,7 +4,6 @@
<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;
@@ -42,26 +41,6 @@
padding: 20px;
}
.support-footer {
max-width: 1400px;
margin: 0 auto 24px;
padding: 20px;
border-top: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.92rem;
text-align: center;
}
.support-footer a {
color: var(--accent);
text-decoration: none;
}
.support-footer a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
header {
display: flex;
justify-content: space-between;
@@ -667,32 +646,5 @@
</div>
</div>
</div>
<footer class="support-footer">
<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>
</footer>
</body>
</html>
-22
View File
@@ -15,7 +15,6 @@ 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");
@@ -248,26 +247,9 @@ 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);
}
}
@@ -645,10 +627,6 @@ function initializeEventListeners() {
closeLocalMapModal();
closeExternalMapModal();
});
document.querySelectorAll(".modal-overlay").forEach((modal) => {
modal.setAttribute("aria-hidden", "true");
});
}
// Initialize on page load
+19 -469
View File
@@ -69,202 +69,25 @@ body {
font-size: 0.85rem;
}
.support-badge {
position: fixed;
right: 20px;
bottom: 20px;
width: min(360px, calc(100vw - 32px));
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(22, 27, 34, 0.94);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28);
color: var(--text-secondary);
font-size: 0.82rem;
line-height: 1.45;
z-index: 10;
}
.support-badge a,
.support-footer a {
color: var(--accent);
text-decoration: none;
}
.support-badge a:hover,
.support-footer a:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.support-text {
margin: 0 0 10px;
}
.support-funding-icons {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
list-style: none;
margin: 0;
padding: 0;
}
.support-funding-icons li {
display: flex;
align-items: center;
}
.support-badge .support-funding-icons {
justify-content: flex-start;
}
.support-footer .support-funding-icons {
justify-content: center;
}
.support-funding-link {
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.9;
line-height: 0;
}
.support-badge .support-funding-link:hover,
.support-footer .support-funding-link:hover {
opacity: 1;
text-decoration: none;
color: inherit;
}
.support-funding-link img {
display: block;
height: 30px;
width: auto;
}
.container {
max-width: 1280px;
margin: 0 auto 0 0;
padding: 20px 20px 20px 8px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.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 {
header {
display: flex;
width: 100%;
justify-content: center;
padding: 8px 12px;
border-radius: 8px;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 30px;
}
.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 {
margin-top: 8px;
padding: 20px 0 8px;
border-top: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.92rem;
text-align: center;
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.auth-user {
@@ -280,6 +103,12 @@ h1 {
gap: 10px;
}
h1 .version {
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: normal;
}
.status-badge {
display: inline-flex;
align-items: center;
@@ -1030,35 +859,6 @@ input::placeholder {
border-bottom-color: var(--accent);
}
@media (max-width: 768px) {
.container {
padding: 12px;
}
.app-shell {
grid-template-columns: 1fr;
gap: 12px;
}
.sidebar {
position: static;
max-height: none;
}
.support-badge {
right: 12px;
bottom: 12px;
width: min(340px, calc(100vw - 24px));
padding: 10px 12px;
font-size: 0.78rem;
}
.support-footer {
padding-top: 16px;
font-size: 0.88rem;
}
}
.tab-content {
display: none;
}
@@ -1067,140 +867,6 @@ input::placeholder {
display: block;
}
/* 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;
@@ -1314,119 +980,3 @@ input::placeholder {
transform: rotate(360deg);
}
}
/* Download Activity Queue */
.download-queue-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.download-queue-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
animation: slideIn 0.3s ease;
}
.download-queue-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.download-queue-title {
font-weight: 500;
font-size: 0.95rem;
}
.download-queue-meta {
display: flex;
align-items: center;
gap: 8px;
}
.download-queue-artist {
color: var(--text-secondary);
font-size: 0.85rem;
}
.download-queue-provider {
font-size: 0.75rem;
padding: 2px 6px;
background: rgba(88, 166, 255, 0.1);
color: var(--accent);
border-radius: 4px;
text-transform: uppercase;
}
.download-queue-badge {
font-size: 0.75rem;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.08);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 999px;
text-transform: uppercase;
}
.download-queue-badge.is-playing {
color: #79c0ff;
border-color: rgba(121, 192, 255, 0.45);
background: rgba(56, 139, 253, 0.16);
}
.download-progress-bar {
position: relative;
height: 8px;
width: 100%;
margin-top: 6px;
background: rgba(255, 255, 255, 0.06);
border-radius: 999px;
overflow: hidden;
}
.download-progress-buffer {
position: absolute;
inset: 0 auto 0 0;
background: rgba(201, 209, 217, 0.28);
border-radius: 999px;
}
.download-progress-playback {
position: absolute;
inset: 0 auto 0 0;
background: linear-gradient(90deg, #2f81f7 0%, #79c0ff 100%);
border-radius: 999px;
}
.download-progress-meta {
margin-top: 4px;
color: var(--text-secondary);
font-size: 0.75rem;
}
.download-queue-status {
display: flex;
align-items: center;
gap: 12px;
}
.download-queue-time {
font-family: monospace;
color: var(--text-secondary);
font-size: 0.85rem;
}
.empty-state {
color: var(--text-secondary);
font-style: italic;
padding: 12px;
text-align: center;
}
+72
View File
@@ -0,0 +1,72 @@
# Admin UI Modularity Guide
This document defines the modular JavaScript architecture for `allstarr/wwwroot/js` and the guardrails future agents should follow.
## Goals
- Keep admin UI code split by feature and responsibility.
- Centralize request handling and async UI action handling.
- Minimize `window.*` globals to only those required by inline HTML handlers.
- Keep polling and refresh lifecycle in one place.
## Current Module Map
- `main.js`: Composition root only. Wires modules, shared globals, and bootstrap lifecycle.
- `auth-session.js`: Auth/session state, role-based scope, login/logout wiring, 401 recovery handling.
- `dashboard-data.js`: Polling lifecycle + data loading/render orchestration.
- `operations.js`: Shared `runAction` helper + non-domain operational actions.
- `settings-editor.js`: Settings registry, modal editor rendering, local config state sync.
- `playlist-admin.js`: Playlist linking and admin CRUD.
- `scrobbling-admin.js`: Scrobbling configuration actions and UI state updates.
- `api.js`: API transport layer wrappers and endpoint functions.
## Required Patterns
### 1) Request Layer Rules
- All HTTP requests must go through `api.js`.
- `api.js` owns low-level `fetch` usage (`requestJson`, `requestBlob`, `requestOptionalJson`).
- Feature modules should call `API.*` methods and avoid direct `fetch`.
### 2) Action Flow Rules
- UI actions with toast/error handling should use `runAction(...)` from `operations.js`.
- If an action always reloads scrobbling UI state, use `runScrobblingAction(...)` in `scrobbling-admin.js`.
### 3) Polling Rules
- Polling timers must stay in `dashboard-data.js`.
- New background refresh loops should be added to existing refresh lifecycle, not separate timers in other modules.
### 4) Global Surface Rules
- Expose only `window.*` members needed by current inline HTML (`onclick`, `onchange`, `oninput`) or legacy UI templates.
- Keep new feature logic module-scoped and expose narrow entry points in `init*` functions.
## Adding New Admin UI Behavior
1. Add/extend endpoint method in `api.js`.
2. Implement feature logic in the relevant module (`*-admin.js`, `dashboard-data.js`, etc.).
3. Prefer `runAction(...)` for async UI operations.
4. Export/init through module `init*` only.
5. Wire it from `main.js` if cross-module dependencies are needed.
6. Add/adjust tests in `allstarr.Tests/JavaScriptSyntaxTests.cs`.
## Tests That Enforce This Architecture
`allstarr.Tests/JavaScriptSyntaxTests.cs` includes checks for:
- Module existence and syntax.
- Coordinator bootstrap expectations.
- API request centralization (`fetch` calls constrained to helper functions in `api.js`).
- Scrobbling module prohibition on direct `fetch`.
## Fast Validation Commands
```bash
# Full suite
dotnet test allstarr.sln
# JS architecture/syntax focused
dotnet test allstarr.Tests/allstarr.Tests.csproj --filter JavaScriptSyntaxTests
```
-249
View File
@@ -1,249 +0,0 @@
services:
valkey:
image: valkey/valkey:8
container_name: allstarr-valkey
restart: unless-stopped
# Valkey is only accessible internally - no external port exposure
expose:
- "6379"
# Use a self-healing entrypoint to automatically handle Redis -> Valkey migration pitfalls (like RDB format 12 errors)
# Only delete Valkey/Redis persistence artifacts so misconfigured REDIS_DATA_PATH values do not wipe app cache files.
entrypoint:
- "sh"
- "-ec"
- |
log_file=/tmp/valkey-startup.log
log_pipe=/tmp/valkey-startup.pipe
server_pid=
tee_pid=
forward_signal() {
if [ -n "$$server_pid" ]; then
kill -TERM "$$server_pid" 2>/dev/null || true
wait "$$server_pid" 2>/dev/null || true
fi
if [ -n "$$tee_pid" ]; then
kill "$$tee_pid" 2>/dev/null || true
wait "$$tee_pid" 2>/dev/null || true
fi
rm -f "$$log_pipe"
exit 143
}
trap forward_signal TERM INT
start_valkey() {
rm -f "$$log_file" "$$log_pipe"
: > "$$log_file"
mkfifo "$$log_pipe"
tee -a "$$log_file" < "$$log_pipe" &
tee_pid=$$!
valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes > "$$log_pipe" 2>&1 &
server_pid=$$!
wait "$$server_pid"
status=$$?
wait "$$tee_pid" 2>/dev/null || true
rm -f "$$log_pipe"
server_pid=
tee_pid=
return "$$status"
}
is_incompatible_persistence_error() {
grep -Eq "Can't handle RDB format version|Error reading the RDB base file|AOF loading aborted" "$$log_file"
}
cleanup_incompatible_persistence() {
echo 'Valkey failed to start (likely incompatible Redis persistence files). Removing persisted RDB/AOF artifacts and retrying...'
rm -f /data/*.rdb /data/*.aof /data/*.manifest
rm -rf /data/appendonlydir /data/appendonlydir-*
}
if ! start_valkey; then
if is_incompatible_persistence_error; then
cleanup_incompatible_persistence
exec valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes
fi
exit 1
fi
healthcheck:
# Use CMD-SHELL for broader compatibility in some environments
test: ["CMD-SHELL", "valkey-cli ping || exit 1"]
interval: 10s
timeout: 3s
retries: 5
start_period: 20s
volumes:
- ${REDIS_DATA_PATH:-./redis-data}:/data
networks:
- allstarr-network
# Spotify Lyrics API sidecar service
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
spotify-lyrics:
image: akashrchandran/spotify-lyrics-api:latest
platform: linux/amd64
container_name: allstarr-spotify-lyrics
restart: unless-stopped
ports:
- "8365:8080"
environment:
- SP_DC=${SPOTIFY_API_SESSION_COOKIE:-}
networks:
- allstarr-network
allstarr:
# Use pre-built image from GitHub Container Registry
# For latest stable: ghcr.io/sopat712/allstarr:latest
# For beta/testing: ghcr.io/sopat712/allstarr:beta
# To build locally instead, uncomment the build section below
image: ghcr.io/sopat712/allstarr:latest
# Uncomment to build locally instead of using GHCR image:
# build:
# context: .
# dockerfile: Dockerfile
# image: allstarr:local
container_name: allstarr
restart: unless-stopped
ports:
- "5274:8080"
# Admin UI on port 5275 - for local/Tailscale access only
# DO NOT expose through reverse proxy - contains sensitive config
- "5275:5275"
depends_on:
valkey:
condition: service_healthy
spotify-lyrics:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- allstarr-network
environment:
- ASPNETCORE_ENVIRONMENT=Production
# Backend type: Subsonic or Jellyfin (default: Subsonic)
- Backend__Type=${BACKEND_TYPE:-Subsonic}
# Admin network controls (port 5275)
- Admin__BindAnyIp=${ADMIN_BIND_ANY_IP:-false}
- Admin__TrustedSubnets=${ADMIN_TRUSTED_SUBNETS:-}
# ===== REDIS / VALKEY CACHE =====
- Redis__ConnectionString=valkey:6379
- Redis__Enabled=${REDIS_ENABLED:-true}
# ===== CACHE TTL SETTINGS =====
- Cache__SearchResultsMinutes=${CACHE_SEARCH_RESULTS_MINUTES:-1}
- Cache__PlaylistImagesHours=${CACHE_PLAYLIST_IMAGES_HOURS:-168}
- Cache__SpotifyPlaylistItemsHours=${CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS:-168}
- Cache__SpotifyMatchedTracksDays=${CACHE_SPOTIFY_MATCHED_TRACKS_DAYS:-30}
- Cache__LyricsDays=${CACHE_LYRICS_DAYS:-14}
- Cache__GenreDays=${CACHE_GENRE_DAYS:-30}
- Cache__MetadataDays=${CACHE_METADATA_DAYS:-7}
- Cache__OdesliLookupDays=${CACHE_ODESLI_LOOKUP_DAYS:-60}
- Cache__ProxyImagesDays=${CACHE_PROXY_IMAGES_DAYS:-14}
- Cache__TranscodeCacheMinutes=${CACHE_TRANSCODE_MINUTES:-60}
# ===== SUBSONIC BACKEND =====
- Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533}
- Subsonic__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly}
- Subsonic__DownloadMode=${DOWNLOAD_MODE:-Track}
- Subsonic__MusicService=${MUSIC_SERVICE:-SquidWTF}
- Subsonic__StorageMode=${STORAGE_MODE:-Permanent}
- Subsonic__CacheDurationHours=${CACHE_DURATION_HOURS:-1}
- Subsonic__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true}
- Subsonic__PlaylistsDirectory=${PLAYLISTS_DIRECTORY:-playlists}
# ===== JELLYFIN BACKEND =====
- Jellyfin__Url=${JELLYFIN_URL:-http://localhost:8096}
- Jellyfin__ApiKey=${JELLYFIN_API_KEY:-}
- Jellyfin__UserId=${JELLYFIN_USER_ID:-}
- Jellyfin__LibraryId=${JELLYFIN_LIBRARY_ID:-}
- Jellyfin__ClientUsername=${JELLYFIN_CLIENT_USERNAME:-}
- Jellyfin__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly}
- Jellyfin__DownloadMode=${DOWNLOAD_MODE:-Track}
- Jellyfin__MusicService=${MUSIC_SERVICE:-SquidWTF}
- Jellyfin__StorageMode=${STORAGE_MODE:-Permanent}
- Jellyfin__CacheDurationHours=${CACHE_DURATION_HOURS:-1}
- Jellyfin__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true}
- Jellyfin__PlaylistsDirectory=${PLAYLISTS_DIRECTORY:-playlists}
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
- SpotifyImport__Enabled=${SPOTIFY_IMPORT_ENABLED:-false}
- SpotifyImport__SyncStartHour=${SPOTIFY_IMPORT_SYNC_START_HOUR:-16}
- SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15}
- SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2}
- SpotifyImport__MatchingIntervalHours=${SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS:-24}
- SpotifyImport__Playlists=${SPOTIFY_IMPORT_PLAYLISTS:-}
- SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-}
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
- SpotifyImport__PlaylistLocalTracksPositions=${SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS:-}
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
- SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100}
- SpotifyApi__PreferIsrcMatching=${SPOTIFY_API_PREFER_ISRC_MATCHING:-true}
# Spotify Lyrics API sidecar service URL (internal)
- SpotifyApi__LyricsApiUrl=${SPOTIFY_LYRICS_API_URL:-http://spotify-lyrics:8080}
# ===== SCROBBLING (LAST.FM, LISTENBRAINZ) =====
- Scrobbling__Enabled=${SCROBBLING_ENABLED:-false}
- Scrobbling__LocalTracksEnabled=${SCROBBLING_LOCAL_TRACKS_ENABLED:-false}
- Scrobbling__SyntheticLocalPlayedSignalEnabled=${SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED:-false}
- Scrobbling__LastFm__Enabled=${SCROBBLING_LASTFM_ENABLED:-false}
- Scrobbling__LastFm__ApiKey=${SCROBBLING_LASTFM_API_KEY:-}
- Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-}
- Scrobbling__LastFm__SessionKey=${SCROBBLING_LASTFM_SESSION_KEY:-}
- Scrobbling__LastFm__Username=${SCROBBLING_LASTFM_USERNAME:-}
- Scrobbling__LastFm__Password=${SCROBBLING_LASTFM_PASSWORD:-}
- Scrobbling__ListenBrainz__Enabled=${SCROBBLING_LISTENBRAINZ_ENABLED:-false}
- Scrobbling__ListenBrainz__UserToken=${SCROBBLING_LISTENBRAINZ_USER_TOKEN:-}
# ===== DEBUG SETTINGS =====
- Debug__LogAllRequests=${DEBUG_LOG_ALL_REQUESTS:-false}
- Debug__RedactSensitiveRequestValues=${DEBUG_REDACT_SENSITIVE_REQUEST_VALUES:-false}
# ===== SHARED =====
- Library__DownloadPath=/app/downloads
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}
- SquidWTF__MinRequestIntervalMs=${SQUIDWTF_MIN_REQUEST_INTERVAL_MS:-200}
- Deezer__Arl=${DEEZER_ARL:-}
- Deezer__ArlFallback=${DEEZER_ARL_FALLBACK:-}
- Deezer__Quality=${DEEZER_QUALITY:-FLAC}
- Deezer__MinRequestIntervalMs=${DEEZER_MIN_REQUEST_INTERVAL_MS:-200}
- Qobuz__UserAuthToken=${QOBUZ_USER_AUTH_TOKEN:-}
- Qobuz__UserId=${QOBUZ_USER_ID:-}
- Qobuz__Quality=${QOBUZ_QUALITY:-FLAC}
- Qobuz__MinRequestIntervalMs=${QOBUZ_MIN_REQUEST_INTERVAL_MS:-200}
- MusicBrainz__Enabled=${MUSICBRAINZ_ENABLED:-true}
- MusicBrainz__Username=${MUSICBRAINZ_USERNAME:-}
- MusicBrainz__Password=${MUSICBRAINZ_PASSWORD:-}
volumes:
- ${DOWNLOAD_PATH:-./downloads}:/app/downloads
- ${KEPT_PATH:-./kept}:/app/kept
- ${CACHE_PATH:-./cache}:/app/cache
# Mount .env file for runtime configuration updates from admin UI
- ./.env:/app/.env
# Docker socket for self-restart capability (admin UI only)
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
allstarr-network:
name: allstarr-network
driver: bridge
+11 -20
View File
@@ -1,19 +1,17 @@
services:
valkey:
image: valkey/valkey:8
container_name: allstarr-valkey
redis:
image: redis:7-alpine
container_name: allstarr-redis
restart: unless-stopped
# Valkey is only accessible internally - no external port exposure
# Redis is only accessible internally - no external port exposure
expose:
- "6379"
command: valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes
command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes
healthcheck:
# Use CMD-SHELL for broader compatibility in some environments
test: ["CMD-SHELL", "valkey-cli ping || exit 1"]
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
start_period: 20s
retries: 3
volumes:
- ${REDIS_DATA_PATH:-./redis-data}:/data
networks:
@@ -54,7 +52,7 @@ services:
# DO NOT expose through reverse proxy - contains sensitive config
- "5275:5275"
depends_on:
valkey:
redis:
condition: service_healthy
spotify-lyrics:
condition: service_started
@@ -74,12 +72,12 @@ services:
- Admin__BindAnyIp=${ADMIN_BIND_ANY_IP:-false}
- Admin__TrustedSubnets=${ADMIN_TRUSTED_SUBNETS:-}
# ===== REDIS / VALKEY CACHE =====
- Redis__ConnectionString=valkey:6379
# ===== REDIS CACHE =====
- Redis__ConnectionString=redis:6379
- Redis__Enabled=${REDIS_ENABLED:-true}
# ===== CACHE TTL SETTINGS =====
- Cache__SearchResultsMinutes=${CACHE_SEARCH_RESULTS_MINUTES:-1}
- Cache__SearchResultsMinutes=${CACHE_SEARCH_RESULTS_MINUTES:-120}
- Cache__PlaylistImagesHours=${CACHE_PLAYLIST_IMAGES_HOURS:-168}
- Cache__SpotifyPlaylistItemsHours=${CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS:-168}
- Cache__SpotifyMatchedTracksDays=${CACHE_SPOTIFY_MATCHED_TRACKS_DAYS:-30}
@@ -88,7 +86,6 @@ services:
- Cache__MetadataDays=${CACHE_METADATA_DAYS:-7}
- Cache__OdesliLookupDays=${CACHE_ODESLI_LOOKUP_DAYS:-60}
- Cache__ProxyImagesDays=${CACHE_PROXY_IMAGES_DAYS:-14}
- Cache__TranscodeCacheMinutes=${CACHE_TRANSCODE_MINUTES:-60}
# ===== SUBSONIC BACKEND =====
- Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533}
@@ -137,8 +134,6 @@ services:
# ===== SCROBBLING (LAST.FM, LISTENBRAINZ) =====
- Scrobbling__Enabled=${SCROBBLING_ENABLED:-false}
- Scrobbling__LocalTracksEnabled=${SCROBBLING_LOCAL_TRACKS_ENABLED:-false}
- Scrobbling__SyntheticLocalPlayedSignalEnabled=${SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED:-false}
- Scrobbling__LastFm__Enabled=${SCROBBLING_LASTFM_ENABLED:-false}
- Scrobbling__LastFm__ApiKey=${SCROBBLING_LASTFM_API_KEY:-}
- Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-}
@@ -150,20 +145,16 @@ services:
# ===== DEBUG SETTINGS =====
- Debug__LogAllRequests=${DEBUG_LOG_ALL_REQUESTS:-false}
- Debug__RedactSensitiveRequestValues=${DEBUG_REDACT_SENSITIVE_REQUEST_VALUES:-false}
# ===== SHARED =====
- Library__DownloadPath=/app/downloads
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}
- SquidWTF__MinRequestIntervalMs=${SQUIDWTF_MIN_REQUEST_INTERVAL_MS:-200}
- Deezer__Arl=${DEEZER_ARL:-}
- Deezer__ArlFallback=${DEEZER_ARL_FALLBACK:-}
- Deezer__Quality=${DEEZER_QUALITY:-FLAC}
- Deezer__MinRequestIntervalMs=${DEEZER_MIN_REQUEST_INTERVAL_MS:-200}
- Qobuz__UserAuthToken=${QOBUZ_USER_AUTH_TOKEN:-}
- Qobuz__UserId=${QOBUZ_USER_ID:-}
- Qobuz__Quality=${QOBUZ_QUALITY:-FLAC}
- Qobuz__MinRequestIntervalMs=${QOBUZ_MIN_REQUEST_INTERVAL_MS:-200}
- MusicBrainz__Enabled=${MUSICBRAINZ_ENABLED:-true}
- MusicBrainz__Username=${MUSICBRAINZ_USERNAME:-}
- MusicBrainz__Password=${MUSICBRAINZ_PASSWORD:-}