mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-23 10:42:37 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c9344a6832
|
+9
-31
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
[](https://github.com/SoPat712/allstarr/pkgs/container/allstarr)
|
||||
[](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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,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()
|
||||
{
|
||||
|
||||
@@ -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,34 +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 GetJsonAsync_WithEndpointAndExplicitQuery_MergesWithExplicitPrecedence()
|
||||
{
|
||||
@@ -395,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]
|
||||
|
||||
@@ -75,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")]
|
||||
|
||||
@@ -1,186 +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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -346,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()
|
||||
{
|
||||
@@ -998,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
||||
/// <summary>
|
||||
/// Current application version.
|
||||
/// </summary>
|
||||
public const string Version = "1.4.3";
|
||||
public const string Version = "1.2.1";
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
+50
-103
@@ -83,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;
|
||||
@@ -129,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;
|
||||
@@ -181,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);
|
||||
}
|
||||
}
|
||||
@@ -199,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
|
||||
@@ -407,48 +396,6 @@ public partial class JellyfinController
|
||||
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -54,10 +53,8 @@ public partial class JellyfinController
|
||||
// Post session capabilities in background if we have a token
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
|
||||
// Capture token in closure - don't use Request.Headers (will be disposed)
|
||||
var token = accessToken;
|
||||
var authHeader = AuthHeaderHelper.CreateAuthHeader(token, client, device, deviceId, version);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
@@ -67,7 +64,6 @@ public partial class JellyfinController
|
||||
// Build auth header with the new token
|
||||
var authHeaders = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] = authHeader,
|
||||
["X-Emby-Token"] = token
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1664,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");
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -35,7 +34,6 @@ public partial class JellyfinController
|
||||
|
||||
// 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);
|
||||
@@ -65,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))
|
||||
{
|
||||
@@ -93,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);
|
||||
|
||||
@@ -134,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);
|
||||
@@ -190,8 +170,7 @@ public partial class JellyfinController
|
||||
sortBy,
|
||||
Request.Query["SortOrder"].ToString(),
|
||||
recursive,
|
||||
userId,
|
||||
Request.Query["IsFavorite"].ToString());
|
||||
userId);
|
||||
var cachedResult = await _cache.GetAsync<object>(cacheKey);
|
||||
|
||||
if (cachedResult != null)
|
||||
@@ -210,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);
|
||||
@@ -256,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);
|
||||
|
||||
@@ -277,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);
|
||||
@@ -312,15 +284,13 @@ public partial class JellyfinController
|
||||
userId);
|
||||
|
||||
// Use parallel metadata service if available (races providers), otherwise use primary
|
||||
var externalTask = favoritesOnlyRequest
|
||||
? Task.FromResult(new SearchResult())
|
||||
: _parallelMetadataService != null
|
||||
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
|
||||
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, 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);
|
||||
@@ -439,11 +409,6 @@ public partial class JellyfinController
|
||||
|
||||
// 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);
|
||||
mergedAlbumsAndPlaylists = ApplyRequestedAlbumOrderingIfApplicable(
|
||||
mergedAlbumsAndPlaylists,
|
||||
itemTypes,
|
||||
Request.Query["SortBy"].ToString(),
|
||||
Request.Query["SortOrder"].ToString());
|
||||
|
||||
_logger.LogDebug(
|
||||
"Merged results: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}",
|
||||
@@ -539,7 +504,7 @@ public partial class JellyfinController
|
||||
StartIndex = startIndex
|
||||
};
|
||||
|
||||
// Cache search results in Redis using the configured search TTL.
|
||||
// Cache search results in Redis (15 min TTL, no file persistence)
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds))
|
||||
{
|
||||
if (externalHasRequestedTypeResults)
|
||||
@@ -553,8 +518,7 @@ public partial class JellyfinController
|
||||
sortBy,
|
||||
Request.Query["SortOrder"].ToString(),
|
||||
recursive,
|
||||
userId,
|
||||
Request.Query["IsFavorite"].ToString());
|
||||
userId);
|
||||
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
|
||||
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
|
||||
CacheExtensions.SearchResultsTTL.TotalMinutes);
|
||||
@@ -746,167 +710,6 @@ public partial class JellyfinController
|
||||
return MaskSensitiveQueryString(query);
|
||||
}
|
||||
|
||||
private bool IsFavoritesOnlyRequest()
|
||||
{
|
||||
return string.Equals(Request.Query["IsFavorite"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IActionResult CreateEmptyItemsResponse(int startIndex)
|
||||
{
|
||||
return new JsonResult(new
|
||||
{
|
||||
Items = Array.Empty<object>(),
|
||||
TotalRecordCount = 0,
|
||||
StartIndex = startIndex
|
||||
});
|
||||
}
|
||||
|
||||
private List<Dictionary<string, object?>> ApplyRequestedAlbumOrderingIfApplicable(
|
||||
List<Dictionary<string, object?>> items,
|
||||
string[]? requestedTypes,
|
||||
string? sortBy,
|
||||
string? sortOrder)
|
||||
{
|
||||
if (items.Count <= 1 || string.IsNullOrWhiteSpace(sortBy))
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
if (requestedTypes == null || requestedTypes.Length == 0)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
var isAlbumOnlyRequest = requestedTypes.All(type =>
|
||||
string.Equals(type, "MusicAlbum", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(type, "Playlist", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!isAlbumOnlyRequest)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
var sortFields = sortBy
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(field => !string.IsNullOrWhiteSpace(field))
|
||||
.ToList();
|
||||
|
||||
if (sortFields.Count == 0)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
var descending = string.Equals(sortOrder, "Descending", StringComparison.OrdinalIgnoreCase);
|
||||
var sorted = items.ToList();
|
||||
sorted.Sort((left, right) => CompareAlbumItemsByRequestedSort(left, right, sortFields, descending));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private int CompareAlbumItemsByRequestedSort(
|
||||
Dictionary<string, object?> left,
|
||||
Dictionary<string, object?> right,
|
||||
IReadOnlyList<string> sortFields,
|
||||
bool descending)
|
||||
{
|
||||
foreach (var field in sortFields)
|
||||
{
|
||||
var comparison = CompareAlbumItemsByField(left, right, field);
|
||||
if (comparison == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return descending ? -comparison : comparison;
|
||||
}
|
||||
|
||||
return string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private int CompareAlbumItemsByField(Dictionary<string, object?> left, Dictionary<string, object?> right, string field)
|
||||
{
|
||||
return field.ToLowerInvariant() switch
|
||||
{
|
||||
"sortname" => string.Compare(GetItemStringValue(left, "SortName"), GetItemStringValue(right, "SortName"), StringComparison.OrdinalIgnoreCase),
|
||||
"name" => string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase),
|
||||
"datecreated" => DateTime.Compare(GetItemDateValue(left, "DateCreated"), GetItemDateValue(right, "DateCreated")),
|
||||
"premieredate" => DateTime.Compare(GetItemDateValue(left, "PremiereDate"), GetItemDateValue(right, "PremiereDate")),
|
||||
"productionyear" => CompareIntValues(GetItemIntValue(left, "ProductionYear"), GetItemIntValue(right, "ProductionYear")),
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static int CompareIntValues(int? left, int? right)
|
||||
{
|
||||
if (left.HasValue && right.HasValue)
|
||||
{
|
||||
return left.Value.CompareTo(right.Value);
|
||||
}
|
||||
|
||||
if (left.HasValue)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (right.HasValue)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static DateTime GetItemDateValue(Dictionary<string, object?> item, string key)
|
||||
{
|
||||
if (!item.TryGetValue(key, out var value) || value == null)
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind == JsonValueKind.String &&
|
||||
DateTime.TryParse(jsonElement.GetString(), out var parsedDate))
|
||||
{
|
||||
return parsedDate;
|
||||
}
|
||||
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(value.ToString(), out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
private static int? GetItemIntValue(Dictionary<string, object?> item, string key)
|
||||
{
|
||||
if (!item.TryGetValue(key, out var value) || value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt32(out var intValue))
|
||||
{
|
||||
return intValue;
|
||||
}
|
||||
|
||||
if (jsonElement.ValueKind == JsonValueKind.String &&
|
||||
int.TryParse(jsonElement.GetString(), out var parsedInt))
|
||||
{
|
||||
return parsedInt;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(value.ToString(), out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
|
||||
@@ -63,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)",
|
||||
@@ -111,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);
|
||||
@@ -249,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);
|
||||
|
||||
@@ -325,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,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);
|
||||
|
||||
@@ -424,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.
|
||||
@@ -724,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).
|
||||
@@ -744,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)
|
||||
{
|
||||
@@ -760,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)
|
||||
@@ -785,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);
|
||||
}
|
||||
}
|
||||
@@ -804,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);
|
||||
@@ -818,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>
|
||||
@@ -825,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)
|
||||
|
||||
@@ -201,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);
|
||||
@@ -260,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")
|
||||
{
|
||||
@@ -289,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);
|
||||
@@ -610,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))
|
||||
{
|
||||
@@ -633,8 +531,7 @@ public partial class JellyfinController : ControllerBase
|
||||
itemId,
|
||||
imageType,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
tag);
|
||||
maxHeight);
|
||||
|
||||
if (imageBytes == null || contentType == null)
|
||||
{
|
||||
@@ -683,15 +580,6 @@ public partial class JellyfinController : ControllerBase
|
||||
return File(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 File(cachedImageBytes, "image/jpeg");
|
||||
}
|
||||
|
||||
// Get external cover art URL
|
||||
string? coverUrl = type switch
|
||||
{
|
||||
@@ -755,10 +643,7 @@ 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 File(imageBytes, "image/jpeg");
|
||||
}
|
||||
@@ -1489,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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,11 +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");
|
||||
|
||||
// Start bidirectional proxying
|
||||
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
||||
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
|
||||
@@ -163,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
|
||||
{
|
||||
@@ -222,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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -510,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>();
|
||||
@@ -733,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
|
||||
@@ -759,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..])}");
|
||||
@@ -893,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);
|
||||
|
||||
@@ -920,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>();
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
/// < 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -115,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);
|
||||
}
|
||||
|
||||
@@ -220,9 +209,14 @@ public class JellyfinProxyService
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (!isBrowserStaticRequest && !isPublicEndpoint)
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
|
||||
// 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
|
||||
@@ -316,7 +310,17 @@ public class JellyfinProxyService
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
LogUpstreamFailure(HttpMethod.Post, 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))
|
||||
@@ -451,7 +455,8 @@ public class JellyfinProxyService
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
LogUpstreamFailure(HttpMethod.Delete, response.StatusCode, url, errorContent);
|
||||
_logger.LogError("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||
response.StatusCode, url, errorContent);
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
@@ -772,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);
|
||||
@@ -803,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
|
||||
@@ -910,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]"))
|
||||
@@ -502,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) ||
|
||||
|
||||
@@ -190,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>
|
||||
@@ -296,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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,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)
|
||||
{
|
||||
@@ -454,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();
|
||||
@@ -693,16 +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 sealed record ActivePlaybackState(
|
||||
string DeviceId,
|
||||
string ItemId,
|
||||
long PositionTicks,
|
||||
DateTime LastActivity);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_keepAliveTimer?.Dispose();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}";
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -87,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)
|
||||
@@ -117,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)
|
||||
@@ -130,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)
|
||||
{
|
||||
@@ -156,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)
|
||||
{
|
||||
@@ -223,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)
|
||||
{
|
||||
@@ -347,64 +322,36 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
/// <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()
|
||||
{
|
||||
_logger.LogInformation("Manual full rebuild triggered for all playlists");
|
||||
_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>
|
||||
@@ -597,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)
|
||||
@@ -629,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)
|
||||
@@ -648,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)
|
||||
{
|
||||
@@ -656,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;
|
||||
@@ -668,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}",
|
||||
@@ -1154,7 +1095,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Local tracks will be found via fuzzy matching instead
|
||||
|
||||
// STEP 2: Search EXTERNAL by ISRC
|
||||
return await metadataService.FindSongByIsrcAsync(isrc);
|
||||
var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1);
|
||||
if (results.Count > 0 && results[0].Isrc == isrc)
|
||||
{
|
||||
return results[0];
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
@@ -1396,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)
|
||||
@@ -1898,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,13 +370,9 @@ 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>());
|
||||
}
|
||||
@@ -515,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;
|
||||
@@ -584,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);
|
||||
|
||||
@@ -628,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)
|
||||
@@ -731,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)
|
||||
@@ -815,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);
|
||||
}
|
||||
|
||||
@@ -872,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);
|
||||
@@ -903,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;
|
||||
@@ -919,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);
|
||||
@@ -947,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;
|
||||
@@ -961,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);
|
||||
@@ -995,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>());
|
||||
}
|
||||
@@ -1400,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);
|
||||
@@ -1433,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>
|
||||
@@ -1583,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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<!-- Restart Required Banner -->
|
||||
<div class="restart-banner" id="restart-banner">
|
||||
⚠️ Configuration changed. Restart required to apply changes.
|
||||
<button onclick="restartContainer()">Restart Allstarr</button>
|
||||
<button onclick="restartContainer()">Restart Now</button>
|
||||
<button onclick="dismissRestartBanner()"
|
||||
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
||||
</div>
|
||||
@@ -65,13 +65,6 @@
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
<div class="tab-content active" id="tab-dashboard">
|
||||
<div class="card" id="download-activity-card">
|
||||
<h2>Live Download Queue</h2>
|
||||
<div id="download-activity-list" class="download-queue-list">
|
||||
<div class="empty-state">No active downloads</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Spotify API</h2>
|
||||
@@ -400,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);">
|
||||
@@ -412,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>
|
||||
|
||||
@@ -638,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>
|
||||
|
||||
@@ -656,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>
|
||||
|
||||
@@ -699,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>
|
||||
@@ -858,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>
|
||||
|
||||
@@ -274,7 +274,7 @@ export async function restartContainer() {
|
||||
return requestJson(
|
||||
"/api/admin/restart",
|
||||
{ method: "POST" },
|
||||
"Failed to restart Allstarr",
|
||||
"Failed to restart container",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { runAction } from "./operations.js";
|
||||
|
||||
let playlistAutoRefreshInterval = null;
|
||||
let dashboardRefreshInterval = null;
|
||||
let downloadActivityEventSource = null;
|
||||
|
||||
let isAuthenticated = () => false;
|
||||
let isAdminSession = () => false;
|
||||
@@ -325,10 +324,6 @@ function stopDashboardRefresh() {
|
||||
clearInterval(dashboardRefreshInterval);
|
||||
dashboardRefreshInterval = null;
|
||||
}
|
||||
if (downloadActivityEventSource) {
|
||||
downloadActivityEventSource.close();
|
||||
downloadActivityEventSource = null;
|
||||
}
|
||||
stopPlaylistAutoRefresh();
|
||||
}
|
||||
|
||||
@@ -380,134 +375,6 @@ async function loadDashboardData() {
|
||||
}
|
||||
|
||||
startDashboardRefresh();
|
||||
startDownloadActivityStream();
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -141,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");
|
||||
@@ -208,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!",
|
||||
@@ -270,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",
|
||||
@@ -283,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;
|
||||
@@ -291,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) {
|
||||
@@ -301,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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -536,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"
|
||||
@@ -550,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 =
|
||||
|
||||
@@ -980,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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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:-}
|
||||
|
||||
Reference in New Issue
Block a user