Compare commits

...

26 Commits

Author SHA1 Message Date
joshpatra f9e5b7f323 v1.3.3-beta.1: MAJOR FIX - fix auto logging out behavior, harden Jellyfin Auth, block bot probes earlier, let Jellyfin handle playback sessions, add [E] tag to explicit external tracks 2026-03-12 19:13:29 -04:00
joshpatra db714fee2d v1.3.1-beta.1: MAJOR FIX - fix auto logging out behavior, harden Jellyfin Auth, block bot probes earlier, let Jellyfin handle playback sessions, add [E] tag to explicit external tracks 2026-03-12 15:33:36 -04:00
joshpatra efe1660d81 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-06 02:18:29 -05:00
joshpatra 639070556a v1.3.0-beta.1: Fixed double scrobbling, inferring stops much better, fixed playlist cron rebuilding, stale injected playlist artwork, and search cache TTL 2026-03-06 01:54:58 -05:00
joshpatra 00a5d152a5 v1.2.1-beta.1: Massive WebUI cleanup, Fixed/Stabilized scrobbling, Significant security hardening, added user login to WebUI, refactored searching/interleaving to work MUCH better, Tidal Powered recommendations for SquidWTF provider, General bug fixes and optimizations
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-26 11:16:51 -05:00
joshpatra 1ba6135115 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-21 00:25:40 -05:00
joshpatra ec994773dd Merge branch 'main' into beta 2026-02-20 20:02:55 -05:00
joshpatra 39c8f16b59 v1.1.3-beta.1: version bump, removed duplicate method; this is why we run tests... 2026-02-20 20:01:22 -05:00
joshpatra a6a423d5a1 v1.1.1-beta-1: fix: redid logic for sync schedule in playlist injection, made a constant for versioning, fixed external artist album and track fetching
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-20 18:57:10 -05:00
joshpatra 899451d405 v1.1.0-beta.1: fix: Scrobbling to LastFM and Listenbrainz, fixed transparent proxying, added playlists to search (shown as albums), shows all libraries and only require library id for injected playlists; refactor: rewrote all the MD's basically, split up JellyfinController in separate files, dozens of other smaller changes
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-20 01:22:26 -05:00
joshpatra 8d6dd7ccf1 v1.0.3-beta.1: Refactored all large files, Fixed the cron schedule bug, hardened security, added global mapping for much more stable matchings
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-16 14:59:21 -05:00
joshpatra ebdd8d4e2a v1.0.2-beta.1: WebUI refactored for better understanding, gitignore updated
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 23:17:08 -05:00
joshpatra e4599a419e v1.0.1-beta.1: fixed and rewrote caching, WebUI fixes, logging fixes 2026-02-11 16:54:30 -05:00
joshpatra 86290dff0d v1.0.0-beta.1: initial beta release
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 10:16:09 -05:00
joshpatra 0a9e528418 v1.3.0: Bump version to 1.3.0
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 00:01:06 -05:00
joshpatra f74728fc73 fix: use MBID lookup for MusicBrainz genre enrichment
Search API doesn't return genres even with inc=genres parameter.
Now doing search to get MBID, then lookup by MBID to get genres.
2026-02-10 23:52:14 -05:00
joshpatra 87467be61b feat: add LyricsPlus API with modular orchestrator architecture
Add multi-source lyrics support with clean, modular architecture for easier debugging and maintenance.

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

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

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

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

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

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

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

This ensures all external tracks have genre metadata for better
organization and filtering in music clients.
2026-02-10 10:25:41 -05:00
joshpatra f3c791496e v1.2.0: Spotify playlist improvements and admin UI fixes
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
Enhanced Spotify playlist integration with GraphQL API, fixed track counts and folder filtering, improved session IP tracking with X-Forwarded-For support, and added per-playlist cron scheduling.
2026-02-09 18:17:15 -05:00
31 changed files with 1568 additions and 371 deletions
+2 -2
View File
@@ -40,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: 120 = 2 hours)
CACHE_SEARCH_RESULTS_MINUTES=120
# Search results cache duration in minutes (default: 1)
CACHE_SEARCH_RESULTS_MINUTES=1
# Playlist cover images cache duration in hours (default: 168 = 1 week)
CACHE_PLAYLIST_IMAGES_HOURS=168
+28 -75
View File
@@ -5,7 +5,7 @@
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io%2Fsopat712%2Fallstarr-blue)](https://github.com/SoPat712/allstarr/pkgs/container/allstarr)
[![License](https://img.shields.io/badge/license-GPL--3.0-green)](LICENSE)
A media server proxy that integrates music streaming providers with your local library. Works with **Jellyfin** 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.
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.
## Quick Start
@@ -39,7 +39,6 @@ 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
@@ -76,7 +75,6 @@ The web UI updates your `.env` file directly. Changes persist across container r
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)
@@ -87,20 +85,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;
@@ -119,7 +117,7 @@ This project brings together all the music streaming providers into one unified
## Features
- **Dual Backend Support**: Works with Jellyfin and Subsonic-compatible servers (Navidrome, Airsonic, etc.)
- **Dual Backend Support**: Works with Jellyfin
- **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
@@ -139,43 +137,21 @@ 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
@@ -198,13 +174,12 @@ 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
@@ -212,47 +187,39 @@ 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:
**For Jellyfin backend:**
**Server Settings:**
```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
```
@@ -260,7 +227,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.
@@ -272,21 +239,24 @@ 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": {
@@ -303,33 +273,18 @@ 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
@@ -341,7 +296,6 @@ 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.
@@ -356,7 +310,6 @@ GPL-3.0
- [Jellyfin Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file) - The plugin that I **strongly** recommend using alongside this repo
- [Jellyfin](https://jellyfin.org/) - The free and open-source media server
- [Navidrome](https://www.navidrome.org/) - The excellent self-hosted music server
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification
- [Hi-Fi API](https://github.com/binimum/hifi-api) - These people do some great work, and you should thank them for this even existing!
- [Deezer](https://www.deezer.com/) - Music streaming service
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
+16
View File
@@ -39,6 +39,22 @@ 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()
{
@@ -0,0 +1,60 @@
using System.Net;
using allstarr.Middleware;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
namespace allstarr.Tests;
public class BotProbeBlockMiddlewareTests
{
[Fact]
public async Task InvokeAsync_ScannerPath_Returns404WithoutCallingNext()
{
var nextInvoked = false;
var middleware = new BotProbeBlockMiddleware(
_ =>
{
nextInvoked = true;
return Task.CompletedTask;
},
NullLogger<BotProbeBlockMiddleware>.Instance);
var context = CreateContext("/.env");
await middleware.InvokeAsync(context);
Assert.False(nextInvoked);
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
}
[Fact]
public async Task InvokeAsync_NormalPath_CallsNext()
{
var nextInvoked = false;
var middleware = new BotProbeBlockMiddleware(
context =>
{
nextInvoked = true;
context.Response.StatusCode = StatusCodes.Status204NoContent;
return Task.CompletedTask;
},
NullLogger<BotProbeBlockMiddleware>.Instance);
var context = CreateContext("/System/Info/Public");
await middleware.InvokeAsync(context);
Assert.True(nextInvoked);
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
}
private static DefaultHttpContext CreateContext(string path)
{
var context = new DefaultHttpContext();
context.Request.Path = path;
context.Request.Method = HttpMethods.Get;
context.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.10");
context.Response.Body = new MemoryStream();
return context;
}
}
+34
View File
@@ -0,0 +1,34 @@
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));
}
}
+33 -1
View File
@@ -20,10 +20,42 @@ 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()
{
@@ -117,6 +117,35 @@ 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()
{
@@ -75,6 +75,44 @@ 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")]
@@ -0,0 +1,145 @@
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);
}
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);
}
}
}
@@ -0,0 +1,229 @@
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);
}
[Fact]
public async Task GetTrackDownloadInfoAsync_FallsBackToLowerQualityWhenPreferredQualityIsUnavailable()
{
var requests = new List<string>();
using var handler = new StubHttpMessageHandler(request =>
{
var url = request.RequestUri!.ToString();
requests.Add(url);
if (url.Contains("quality=LOSSLESS", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.Forbidden);
}
if (url.Contains("quality=HIGH", StringComparison.Ordinal) &&
url.StartsWith("http://127.0.0.1:18082/", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.Forbidden);
}
if (url.Contains("quality=HIGH", StringComparison.Ordinal))
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(CreateTrackResponseJson("HIGH", "audio/mp4", "https://cdn.example.com/334284374.m4a"))
};
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var service = CreateService(handler, quality: "FLAC");
var result = await InvokePrivateAsync(service, "GetTrackDownloadInfoAsync", "334284374", CancellationToken.None);
Assert.Equal("http://127.0.0.1:18081", GetProperty<string>(result, "Endpoint"));
Assert.Equal("https://cdn.example.com/334284374.m4a", GetProperty<string>(result, "DownloadUrl"));
Assert.Equal("audio/mp4", GetProperty<string>(result, "MimeType"));
Assert.Equal("HIGH", GetProperty<string>(result, "AudioQuality"));
Assert.Contains(requests, url => url.Contains("quality=LOSSLESS", StringComparison.Ordinal));
Assert.Contains(requests, url => url.Contains("quality=HIGH", StringComparison.Ordinal));
var lastLosslessRequest = requests.FindLastIndex(url => url.Contains("quality=LOSSLESS", StringComparison.Ordinal));
var firstHighRequest = requests.FindIndex(url => url.Contains("quality=HIGH", StringComparison.Ordinal));
Assert.True(lastLosslessRequest >= 0);
Assert.True(firstHighRequest > lastLosslessRequest);
}
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,8 +7,11 @@ 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;
@@ -343,6 +346,168 @@ 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 void BuildSearchQueryVariants_WithAmpersand_AddsAndVariant()
{
@@ -561,4 +726,19 @@ public class SquidWTFMetadataServiceTests
Assert.NotNull(result);
return (T)result!;
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(_handler(request));
}
}
}
+1 -1
View File
@@ -9,5 +9,5 @@ public static class AppVersion
/// <summary>
/// Current application version.
/// </summary>
public const string Version = "1.3.0";
public const string Version = "1.3.3";
}
@@ -184,7 +184,19 @@ public partial class JellyfinController
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
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);
}
return StatusCode(500, new { error = "Streaming failed" });
}
}
@@ -1,5 +1,6 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using allstarr.Services.Common;
namespace allstarr.Controllers;
@@ -53,8 +54,10 @@ 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
@@ -64,6 +67,7 @@ public partial class JellyfinController
// Build auth header with the new token
var authHeaders = new HeaderDictionary
{
["X-Emby-Authorization"] = authHeader,
["X-Emby-Token"] = token
};
@@ -145,12 +145,11 @@ public partial class JellyfinController
return NotFound(new { error = "Song not found" });
}
// 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();
// 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();
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
{
@@ -379,11 +378,11 @@ public partial class JellyfinController
return;
}
// 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();
// 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();
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
{
@@ -467,5 +466,18 @@ 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,5 +1,6 @@
using System.Text.Json;
using System.Text;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Common;
using Microsoft.AspNetCore.Mvc;
@@ -34,6 +35,7 @@ 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);
@@ -63,6 +65,12 @@ 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))
{
@@ -85,6 +93,12 @@ 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);
@@ -120,6 +134,12 @@ 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);
@@ -170,7 +190,8 @@ public partial class JellyfinController
sortBy,
Request.Query["SortOrder"].ToString(),
recursive,
userId);
userId,
Request.Query["IsFavorite"].ToString());
var cachedResult = await _cache.GetAsync<object>(cacheKey);
if (cachedResult != null)
@@ -291,13 +312,15 @@ public partial class JellyfinController
userId);
// Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
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 playlistTask = _settings.EnableExternalPlaylists
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit, HttpContext.RequestAborted)
: Task.FromResult(new List<ExternalPlaylist>());
var playlistTask = favoritesOnlyRequest || !_settings.EnableExternalPlaylists
? Task.FromResult(new List<ExternalPlaylist>())
: _metadataService.SearchPlaylistsAsync(cleanQuery, limit, HttpContext.RequestAborted);
_logger.LogDebug("Playlist search enabled: {Enabled}, searching for: '{Query}'",
_settings.EnableExternalPlaylists, cleanQuery);
@@ -516,7 +539,7 @@ public partial class JellyfinController
StartIndex = startIndex
};
// Cache search results in Redis (15 min TTL, no file persistence)
// Cache search results in Redis using the configured search TTL.
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds))
{
if (externalHasRequestedTypeResults)
@@ -530,7 +553,8 @@ public partial class JellyfinController
sortBy,
Request.Query["SortOrder"].ToString(),
recursive,
userId);
userId,
Request.Query["IsFavorite"].ToString());
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
CacheExtensions.SearchResultsTTL.TotalMinutes);
@@ -722,6 +746,21 @@ 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,
@@ -201,6 +201,16 @@ 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;
@@ -280,6 +290,13 @@ public partial class JellyfinController : ControllerBase
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();
@@ -0,0 +1,37 @@
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;
}
}
@@ -120,6 +120,11 @@ 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();
+3
View File
@@ -911,6 +911,9 @@ 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>();
+12 -1
View File
@@ -28,7 +28,18 @@ 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)
{
@@ -467,7 +467,18 @@ public abstract class BaseDownloadService : IDownloadService
Logger.LogDebug("Cleaned up failed download tracking for {SongId}", songId);
});
}
Logger.LogError(ex, "Download failed 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);
}
throw;
}
finally
@@ -0,0 +1,107 @@
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);
}
}
+4 -2
View File
@@ -22,7 +22,8 @@ public static class CacheKeyBuilder
string? sortBy,
string? sortOrder,
bool? recursive,
string? userId)
string? userId,
string? isFavorite = null)
{
var normalizedTerm = Normalize(searchTerm);
var normalizedItemTypes = Normalize(itemTypes);
@@ -30,9 +31,10 @@ 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}";
return $"search:{normalizedTerm}:{normalizedItemTypes}:{limit}:{startIndex}:{normalizedParentId}:{normalizedSortBy}:{normalizedSortOrder}:{normalizedRecursive}:{normalizedUserId}:{normalizedIsFavorite}";
}
private static string Normalize(string? value)
@@ -235,8 +235,7 @@ public class RoundRobinFallbackHelper
}
catch (Exception ex)
{
_logger.LogError(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl);
LogEndpointFailure(baseUrl, ex, willRetry: attempt < orderedEndpoints.Count - 1);
// Mark as unhealthy in cache
lock (_healthCacheLock)
@@ -351,8 +350,7 @@ public class RoundRobinFallbackHelper
}
catch (Exception ex)
{
_logger.LogError(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl);
LogEndpointFailure(baseUrl, ex, willRetry: attempt < orderedEndpoints.Count - 1);
// Mark as unhealthy in cache
lock (_healthCacheLock)
@@ -371,10 +369,110 @@ 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,
@@ -2,6 +2,7 @@ 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;
@@ -209,14 +210,9 @@ public class JellyfinProxyService
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
if (!isBrowserStaticRequest && !isPublicEndpoint)
{
// 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);
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
}
// Try to parse error response to pass through to client
@@ -310,17 +306,7 @@ public class JellyfinProxyService
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
// 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);
}
LogUpstreamFailure(HttpMethod.Post, response.StatusCode, url, errorContent);
// Try to parse error response as JSON to pass through to client
if (!string.IsNullOrWhiteSpace(errorContent))
@@ -455,8 +441,7 @@ public class JellyfinProxyService
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent);
LogUpstreamFailure(HttpMethod.Delete, response.StatusCode, url, errorContent);
return (null, statusCode);
}
@@ -915,6 +900,62 @@ 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 " [S]" suffix to external song titles (S = streaming source)
// Add external/explicit labels to song titles for external tracks.
var songTitle = song.Title;
var artistName = song.Artist;
var albumName = song.Album;
@@ -302,7 +302,7 @@ public class JellyfinResponseBuilder
if (!song.IsLocal)
{
songTitle = $"{song.Title} [S]";
songTitle = BuildExternalSongTitle(song);
// Also add [S] to artist and album names for consistency
if (!string.IsNullOrEmpty(artistName) && !artistName.EndsWith(" [S]"))
@@ -502,6 +502,18 @@ 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) ||
@@ -298,32 +298,16 @@ public class JellyfinSessionManager : IDisposable
/// <summary>
/// Marks a session as potentially ended (e.g., after playback stops).
/// The session will be cleaned up if no new activity occurs within the timeout.
/// Jellyfin should decide when the upstream playback session expires.
/// </summary>
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
{
if (_sessions.TryGetValue(deviceId, out var session))
if (_sessions.TryGetValue(deviceId, out _))
{
_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);
}
});
_logger.LogDebug(
"⏰ SESSION: Playback stopped for {DeviceId}; leaving upstream session lifetime to Jellyfin (timeout hint {Seconds}s ignored)",
deviceId,
timeout.TotalSeconds);
}
}
@@ -398,8 +382,7 @@ public class JellyfinSessionManager : IDisposable
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
}
// Notify Jellyfin that the session is ending
await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers);
// Let Jellyfin retire the session naturally; internal cleanup must not revoke the user's token.
}
catch (Exception ex)
{
@@ -452,6 +435,12 @@ 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();
@@ -100,8 +100,12 @@ public class SquidWTFDownloadService : BaseDownloadService
{
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);
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);
// Determine extension from MIME type
var extension = downloadInfo.MimeType?.ToLower() switch
@@ -127,65 +131,11 @@ public class SquidWTFDownloadService : BaseDownloadService
// 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
{
"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();
}
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);
});
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
request.Headers.Add("User-Agent", "Mozilla/5.0");
request.Headers.Add("Accept", "*/*");
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
@@ -235,81 +185,139 @@ public class SquidWTFDownloadService : BaseDownloadService
/// 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)
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
{
return await QueueRequestAsync(async () =>
{
// Use round-robin with fallback instead of racing to reduce CPU usage
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
Exception? lastException = null;
var qualityOrder = BuildQualityFallbackOrder(_squidwtfSettings.Quality);
foreach (var quality in qualityOrder)
{
// Map quality settings to Tidal's quality levels per hifi-api spec
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
try
{
"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}";
return await _fallbackHelper.TryWithFallbackAsync(baseUrl =>
FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken));
}
catch (Exception ex)
{
lastException = ex;
Logger.LogDebug("Fetching track download info from: {Url}", url);
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));
Logger.LogDebug(ex,
"Detailed SquidWTF quality failure for track {TrackId} at quality {Quality}",
trackId,
quality);
}
}
}
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"
};
});
throw lastException ?? new Exception($"Unable to fetch SquidWTF download info for track {trackId}");
});
}
private async Task<DownloadResult> FetchTrackDownloadInfoAsync(
string baseUrl,
string trackId,
string quality,
CancellationToken cancellationToken)
{
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
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;
}
#endregion
@@ -367,8 +375,9 @@ 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;
}
}
}
@@ -226,6 +226,10 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
count++;
}
}
else
{
throw new InvalidOperationException("SquidWTF song search response did not contain data.items");
}
return songs;
}, new List<Song>());
}
@@ -263,6 +267,10 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
count++;
}
}
else
{
throw new InvalidOperationException("SquidWTF album search response did not contain data.albums.items");
}
return albums;
}, new List<Album>());
@@ -288,6 +296,12 @@ 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) &&
@@ -305,6 +319,10 @@ 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>());
@@ -345,11 +363,20 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
// Per hifi-api spec: use 'p' parameter for playlist search
var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
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");
}
var playlists = new List<ExternalPlaylist>();
// Per hifi-api spec: playlist search returns data.playlists.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
@@ -370,9 +397,13 @@ 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>());
}
@@ -406,14 +437,19 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
var url = $"{baseUrl}/info/?id={externalId}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
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))
return null;
{
throw new InvalidOperationException($"SquidWTF /info response for track {externalId} did not contain data");
}
var song = ParseTidalTrackFull(track);
@@ -445,84 +481,96 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
{
if (string.IsNullOrWhiteSpace(externalId)) return new List<Song>();
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var url = $"{baseUrl}/recommendations/?id={Uri.EscapeDataString(externalId)}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
return await _fallbackHelper.TryWithFallbackAsync(
async (baseUrl) =>
{
_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))
var url = $"{baseUrl}/recommendations/?id={Uri.EscapeDataString(externalId)}";
if (limit > 0)
{
track = wrappedTrack;
}
else
{
track = recommendation;
url += $"&limit={limit}";
}
if (!track.TryGetProperty("id", out _))
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
continue;
throw new HttpRequestException(
$"SquidWTF recommendations request failed for track {externalId} with status {response.StatusCode}");
}
Song song;
try
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 = ParseTidalTrack(track);
}
catch
{
continue;
throw new InvalidOperationException(
$"SquidWTF recommendations response for track {externalId} did not contain data.items");
}
if (string.Equals(song.ExternalId, externalId, StringComparison.OrdinalIgnoreCase))
var songs = new List<Song>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var recommendation in items.EnumerateArray())
{
continue;
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;
}
}
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>());
_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>());
}
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
@@ -540,14 +588,19 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
var url = $"{baseUrl}/album/?id={externalId}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return null;
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
// Response structure: { "data": { album object with "items" array of tracks } }
if (!result.RootElement.TryGetProperty("data", out var albumElement))
return null;
{
throw new InvalidOperationException($"SquidWTF /album response for album {externalId} did not contain data");
}
var album = ParseTidalAlbum(albumElement);
@@ -599,8 +652,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("SquidWTF artist request failed with status {StatusCode}", response.StatusCode);
return null;
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
@@ -637,9 +689,9 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
if (artistSource == null)
{
_logger.LogDebug("Could not find artist data in response. Response keys: {Keys}",
string.Join(", ", result.RootElement.EnumerateObject().Select(p => p.Name)));
return null;
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}");
}
var artistElement = artistSource.Value;
@@ -687,8 +739,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
if (!response.IsSuccessStatusCode)
{
_logger.LogError("SquidWTF artist albums request failed with status {StatusCode}", response.StatusCode);
return new List<Album>();
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
@@ -712,7 +763,8 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
}
else
{
_logger.LogWarning("No albums found in response for artist {ExternalId}", externalId);
throw new InvalidOperationException(
$"SquidWTF artist albums response for {externalId} did not contain albums.items");
}
return albums;
@@ -734,8 +786,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
if (!response.IsSuccessStatusCode)
{
_logger.LogError("SquidWTF artist tracks request failed with status {StatusCode}", response.StatusCode);
return new List<Song>();
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
@@ -756,7 +807,8 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
}
else
{
_logger.LogWarning("No tracks found in response for artist {ExternalId}", externalId);
throw new InvalidOperationException(
$"SquidWTF artist tracks response for {externalId} did not contain tracks");
}
return tracks;
@@ -772,18 +824,26 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
// 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 null;
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var rootElement = JsonDocument.Parse(json).RootElement;
// Check for error response
if (rootElement.TryGetProperty("error", out _)) return null;
if (rootElement.TryGetProperty("error", out _))
{
throw new InvalidOperationException($"SquidWTF playlist response for {externalId} contained an error payload");
}
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
// Extract the playlist object from the response
if (!rootElement.TryGetProperty("playlist", out var playlistElement))
return null;
{
throw new InvalidOperationException($"SquidWTF playlist response for {externalId} did not contain playlist");
}
return ParseTidalPlaylist(playlistElement);
}, (ExternalPlaylist?)null);
@@ -798,13 +858,19 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
// 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>();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
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>();
if (playlistElement.TryGetProperty("error", out _))
{
throw new InvalidOperationException($"SquidWTF playlist tracks response for {externalId} contained an error payload");
}
JsonElement? playlist = null;
JsonElement? tracks = null;
@@ -820,6 +886,12 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
tracks = tracksEl;
}
if (!tracks.HasValue)
{
throw new InvalidOperationException(
$"SquidWTF playlist tracks response for {externalId} did not contain items");
}
var songs = new List<Song>();
// Get playlist name for album field
+1 -1
View File
@@ -77,7 +77,7 @@ services:
- Redis__Enabled=${REDIS_ENABLED:-true}
# ===== CACHE TTL SETTINGS =====
- Cache__SearchResultsMinutes=${CACHE_SEARCH_RESULTS_MINUTES:-120}
- 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}