Compare commits

...

34 Commits

Author SHA1 Message Date
joshpatra 30f68729fc v1.4.2-beta.1: added an env migratino service, fixed DOWNLOAD_PATH requiring Subsonic settings in the backend 2026-03-24 11:10:29 -04:00
joshpatra 53f7b5e8b3 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-23 13:13:01 -04:00
joshpatra 4b423eecb2 Updated funding sources in funding.yml
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-23 13:12:22 -04:00
joshpatra da33ba9fbd Updated funding sources in funding.yml 2026-03-23 13:07:32 -04:00
joshpatra 6c95cfd2d6 Merge branch 'main' into beta 2026-03-23 11:20:34 -04:00
joshpatra d4230a2f79 v1.4.1: MAJOR FIX - Moved from Redis to Valkey, added migration service to support, Utilizing Hi-Fi API 2.7 with ISRC search, preserve local item json objects, add a quality fallback, added "transcoding" support that just reduces the fetched quality, while still downloading at the quality set in the .env, introduced real-time download visualizer on web-ui (not complete), move some stuff from json to redis, better retry logic, configurable timeouts per provider 2026-03-23 11:20:28 -04:00
joshpatra 50157db484 v1.4.1-beta.1: MAJOR FIX - Moved from Redis to Valkey, added migration service to support, Utilizing Hi-Fi API 2.7 with ISRC search, preserve local item json objects, add a quality fallback, added "transcoding" support that just reduces the fetched quality, while still downloading at the quality set in the .env, introduced real-time download visualizer on web-ui (not complete), move some stuff from json to redis, better retry logic, configurable timeouts per provider 2026-03-23 11:18:39 -04:00
joshpatra 2d11d913e8 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-12 19:14:27 -04:00
joshpatra f9e5b7f323 v1.3.3-beta.1: MAJOR FIX - fix auto logging out behavior, harden Jellyfin Auth, block bot probes earlier, let Jellyfin handle playback sessions, add [E] tag to explicit external tracks 2026-03-12 19:13:29 -04:00
joshpatra db714fee2d v1.3.1-beta.1: MAJOR FIX - fix auto logging out behavior, harden Jellyfin Auth, block bot probes earlier, let Jellyfin handle playback sessions, add [E] tag to explicit external tracks 2026-03-12 15:33:36 -04:00
joshpatra efe1660d81 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-06 02:18:29 -05:00
joshpatra 639070556a v1.3.0-beta.1: Fixed double scrobbling, inferring stops much better, fixed playlist cron rebuilding, stale injected playlist artwork, and search cache TTL 2026-03-06 01:54:58 -05:00
joshpatra 00a5d152a5 v1.2.1-beta.1: Massive WebUI cleanup, Fixed/Stabilized scrobbling, Significant security hardening, added user login to WebUI, refactored searching/interleaving to work MUCH better, Tidal Powered recommendations for SquidWTF provider, General bug fixes and optimizations
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-26 11:16:51 -05:00
joshpatra 1ba6135115 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-21 00:25:40 -05:00
joshpatra ec994773dd Merge branch 'main' into beta 2026-02-20 20:02:55 -05:00
joshpatra 39c8f16b59 v1.1.3-beta.1: version bump, removed duplicate method; this is why we run tests... 2026-02-20 20:01:22 -05:00
joshpatra a6a423d5a1 v1.1.1-beta-1: fix: redid logic for sync schedule in playlist injection, made a constant for versioning, fixed external artist album and track fetching
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-20 18:57:10 -05:00
joshpatra 899451d405 v1.1.0-beta.1: fix: Scrobbling to LastFM and Listenbrainz, fixed transparent proxying, added playlists to search (shown as albums), shows all libraries and only require library id for injected playlists; refactor: rewrote all the MD's basically, split up JellyfinController in separate files, dozens of other smaller changes
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-20 01:22:26 -05:00
joshpatra 8d6dd7ccf1 v1.0.3-beta.1: Refactored all large files, Fixed the cron schedule bug, hardened security, added global mapping for much more stable matchings
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-16 14:59:21 -05:00
joshpatra ebdd8d4e2a v1.0.2-beta.1: WebUI refactored for better understanding, gitignore updated
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 23:17:08 -05:00
joshpatra e4599a419e v1.0.1-beta.1: fixed and rewrote caching, WebUI fixes, logging fixes 2026-02-11 16:54:30 -05:00
joshpatra 86290dff0d v1.0.0-beta.1: initial beta release
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 10:16:09 -05:00
joshpatra 0a9e528418 v1.3.0: Bump version to 1.3.0
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 00:01:06 -05:00
joshpatra f74728fc73 fix: use MBID lookup for MusicBrainz genre enrichment
Search API doesn't return genres even with inc=genres parameter.
Now doing search to get MBID, then lookup by MBID to get genres.
2026-02-10 23:52:14 -05:00
joshpatra 87467be61b feat: add LyricsPlus API with modular orchestrator architecture
Add multi-source lyrics support with clean, modular architecture for easier debugging and maintenance.

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

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

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

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

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

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

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

This ensures all external tracks have genre metadata for better
organization and filtering in music clients.
2026-02-10 10:25:41 -05:00
joshpatra f3c791496e v1.2.0: Spotify playlist improvements and admin UI fixes
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
Enhanced Spotify playlist integration with GraphQL API, fixed track counts and folder filtering, improved session IP tracking with X-Forwarded-For support, and added per-playlist cron scheduling.
2026-02-09 18:17:15 -05:00
59 changed files with 4014 additions and 754 deletions
+19 -6
View File
@@ -32,6 +32,7 @@ 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 =====
@@ -68,6 +69,11 @@ 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)
@@ -94,12 +100,10 @@ JELLYFIN_LIBRARY_ID=
# Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF)
MUSIC_SERVICE=SquidWTF
# 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
# 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
# ===== SQUIDWTF CONFIGURATION =====
# Preferred audio quality (optional, default: LOSSLESS)
@@ -110,6 +114,9 @@ Library__DownloadPath=./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
@@ -122,6 +129,9 @@ 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
@@ -136,6 +146,9 @@ 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
+1 -1
View File
@@ -1,6 +1,6 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
github: [SoPat712]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: joshpatra
+1
View File
@@ -13,6 +13,7 @@ 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
@@ -0,0 +1,69 @@
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);
}
}
@@ -0,0 +1,85 @@
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));
}
}
@@ -0,0 +1,51 @@
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,6 +3,7 @@ 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;
@@ -220,6 +221,35 @@ 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()
{
@@ -283,6 +283,34 @@ 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()
{
@@ -91,6 +91,47 @@ public class JellyfinSessionManagerTests
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);
@@ -0,0 +1,115 @@
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);
}
}
@@ -58,55 +58,7 @@ public class SquidWTFDownloadServiceTests : IDisposable
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)
{
@@ -508,6 +508,278 @@ public class SquidWTFMetadataServiceTests
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()
{
@@ -727,6 +999,242 @@ public class SquidWTFMetadataServiceTests
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;
+1 -1
View File
@@ -9,5 +9,5 @@ public static class AppVersion
/// <summary>
/// Current application version.
/// </summary>
public const string Version = "1.3.3";
public const string Version = "1.4.1";
}
+82 -47
View File
@@ -198,17 +198,20 @@ 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")
quality = GetEnvString(envVars, "DEEZER_QUALITY", _deezerSettings.Quality ?? "FLAC"),
minRequestIntervalMs = GetEnvInt(envVars, "DEEZER_MIN_REQUEST_INTERVAL_MS", _deezerSettings.MinRequestIntervalMs)
},
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")
quality = GetEnvString(envVars, "QOBUZ_QUALITY", _qobuzSettings.Quality ?? "FLAC"),
minRequestIntervalMs = GetEnvInt(envVars, "QOBUZ_MIN_REQUEST_INTERVAL_MS", _qobuzSettings.MinRequestIntervalMs)
},
squidWtf = new
{
quality = GetEnvString(envVars, "SQUIDWTF_QUALITY", _squidWtfSettings.Quality ?? "LOSSLESS")
quality = GetEnvString(envVars, "SQUIDWTF_QUALITY", _squidWtfSettings.Quality ?? "LOSSLESS"),
minRequestIntervalMs = GetEnvInt(envVars, "SQUIDWTF_MIN_REQUEST_INTERVAL_MS", _squidWtfSettings.MinRequestIntervalMs)
},
musicBrainz = new
{
@@ -228,7 +231,8 @@ 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))
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))
},
scrobbling = await GetScrobblingSettingsFromEnvAsync()
});
@@ -470,70 +474,101 @@ public class ConfigController : ControllerBase
_logger.LogWarning(".env file not found at {Path}, creating new file", _helperService.GetEnvFilePath());
}
// Read current .env file or create new one
var envContent = new Dictionary<string, string>();
var envFilePath = _helperService.GetEnvFilePath();
var envLines = new List<string>();
if (System.IO.File.Exists(_helperService.GetEnvFilePath()))
if (System.IO.File.Exists(envFilePath))
{
var lines = await System.IO.File.ReadAllLinesAsync(_helperService.GetEnvFilePath());
foreach (var line in lines)
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))
{
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;
}
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();
}
_logger.LogDebug("Loaded {Count} existing env vars from {Path}", envContent.Count, _helperService.GetEnvFilePath());
}
// Apply updates with validation
var appliedUpdates = new List<string>();
foreach (var (key, value) in request.Updates)
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)
{
// 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);
_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 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);
// Auto-set cookie date when Spotify session cookie is updated
if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value))
var keyPrefix = $"{key}=";
var found = false;
// 1. Look for active exact key
for (int i = 0; i < envLines.Count; i++)
{
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);
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}");
}
}
// 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");
await System.IO.File.WriteAllLinesAsync(envFilePath, envLines);
_logger.LogDebug("Config file updated successfully at {Path}", _helperService.GetEnvFilePath());
@@ -0,0 +1,181 @@
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; }
}
}
+59 -48
View File
@@ -129,35 +129,46 @@ public partial class JellyfinController
}
}
// Try loading from file cache if Redis is empty
if (matchedTracks == null || matchedTracks.Count == 0)
// 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))
{
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;
}
exactServedCount = cachedPlaylistItems.Count;
exactServedRunTimeTicks =
SpotifyPlaylistCountHelper.SumCachedPlaylistRunTimeTicks(cachedPlaylistItems);
_logger.LogDebug(
"Using Redis playlist items cache metrics for {Playlist}: count={Count}, runtimeTicks={RunTimeTicks}",
playlistName, exactServedCount, exactServedRunTimeTicks);
}
// 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))
if (exactServedCount > 0)
{
// Get local tracks count from Jellyfin
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.
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>();
var queryParams = new Dictionary<string, string>
{
["Fields"] = "Id,RunTimeTicks",
["Limit"] = "10000"
};
if (!string.IsNullOrEmpty(userId))
{
queryParams["UserId"] = userId;
@@ -170,8 +181,16 @@ public partial class JellyfinController
if (localTracksResponse != null &&
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
{
localTracksCount = localItems.GetArrayLength();
_logger.LogDebug("Found {Count} total items in Jellyfin playlist {Name}",
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, playlistName);
}
}
@@ -180,33 +199,25 @@ public partial class JellyfinController
_logger.LogError(ex, "Failed to get local tracks count for {Name}", playlistName);
}
// Count external matched tracks (not local)
var externalMatchedCount = 0;
if (matchedTracks != null)
{
externalMatchedCount = matchedTracks.Count(t =>
t.MatchedSong != null && !t.MatchedSong.IsLocal);
}
var totalAvailableCount = SpotifyPlaylistCountHelper.ComputeServedItemCount(
exactServedCount > 0 ? exactServedCount : null,
localTracksCount,
matchedTracks);
var totalRunTimeTicks = SpotifyPlaylistCountHelper.ComputeServedRunTimeTicks(
exactServedCount > 0 ? exactServedRunTimeTicks : null,
localRunTimeTicks,
matchedTracks);
// 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);
}
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);
}
}
else
@@ -69,8 +69,9 @@ public partial class JellyfinController
return await ProxyJellyfinStream(fullPath, itemId);
}
// Handle external content
return await StreamExternalContent(provider!, externalId!);
// Handle external content with quality override from client transcoding params
var quality = StreamQualityHelper.ParseFromQueryString(Request.Query);
return await StreamExternalContent(provider!, externalId!, quality);
}
/// <summary>
@@ -150,8 +151,9 @@ 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)
private async Task<IActionResult> StreamExternalContent(string provider, string externalId, StreamQuality quality = StreamQuality.Original)
{
// Check for locally cached file
var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider, externalId);
@@ -178,9 +180,16 @@ public partial class JellyfinController
var downloadStream = await _downloadService.DownloadAndStreamAsync(
provider,
externalId,
quality != StreamQuality.Original ? quality : null,
HttpContext.RequestAborted);
return File(downloadStream, "audio/mpeg", enableRangeProcessing: true);
var contentType = "audio/mpeg";
if (downloadStream is FileStream fs)
{
contentType = GetContentType(fs.Name);
}
return File(downloadStream, contentType, enableRangeProcessing: true);
}
catch (Exception ex)
{
@@ -228,8 +237,9 @@ public partial class JellyfinController
return await ProxyJellyfinStream(fullPath, itemId);
}
// For external content, use simple streaming (no transcoding support yet)
return await StreamExternalContent(provider!, externalId!);
// For external content, parse quality override from client transcoding params
var quality = StreamQualityHelper.ParseFromQueryString(Request.Query);
return await StreamExternalContent(provider!, externalId!, quality);
}
#endregion
@@ -63,11 +63,33 @@ 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)",
@@ -89,7 +111,26 @@ public partial class JellyfinController
// Check file cache as fallback
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
if (fileItems != null && fileItems.Count > 0)
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)
{
_logger.LogDebug("✅ Loaded {Count} playlist items from file cache for {Playlist}",
fileItems.Count, spotifyPlaylistName);
@@ -208,6 +249,7 @@ 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);
@@ -283,9 +325,26 @@ 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})",
"⚠️ Position #{Pos}: '{Title}' marked as LOCAL but not found in Jellyfin items (ID: {Id}); refusing to synthesize a replacement local object",
spotifyTrack.Position, spotifyTrack.Title, matched.MatchedSong.Id);
unresolvedLocalCount++;
continue;
}
}
@@ -316,6 +375,24 @@ 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);
@@ -347,6 +424,30 @@ 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.
@@ -623,8 +724,18 @@ public partial class JellyfinController
}
#region Persistent Favorites Tracking
private readonly string _favoritesFilePath = "/app/cache/favorites.json";
/// <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>
/// Checks if a track is already favorited (persistent across restarts).
@@ -633,13 +744,7 @@ public partial class JellyfinController
{
try
{
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);
return await _cache.ExistsAsync($"favorites:{itemId}");
}
catch (Exception ex)
{
@@ -655,29 +760,16 @@ public partial class JellyfinController
{
try
{
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
var info = new FavoriteTrackInfo
{
ItemId = itemId,
Title = song.Title,
Artist = song.Artist,
Album = song.Album,
Title = song.Title ?? "Unknown Title",
Artist = song.Artist ?? "Unknown Artist",
Album = song.Album ?? "Unknown Album",
FavoritedAt = DateTime.UtcNow
};
// 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);
await _cache.SetAsync($"favorites:{itemId}", info);
_logger.LogDebug("Marked track as favorited: {ItemId}", itemId);
}
catch (Exception ex)
@@ -693,17 +785,9 @@ public partial class JellyfinController
{
try
{
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))
if (await _cache.ExistsAsync($"favorites:{itemId}"))
{
var updatedJson =
JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
await _cache.DeleteAsync($"favorites:{itemId}");
_logger.LogDebug("Removed track from favorites: {ItemId}", itemId);
}
}
@@ -720,24 +804,8 @@ public partial class JellyfinController
{
try
{
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);
var deletionTime = DateTime.UtcNow.AddHours(24);
await _cache.SetStringAsync($"pending_deletion:{itemId}", deletionTime.ToString("O"));
// Also remove from favorites immediately
await UnmarkTrackAsFavoritedAsync(itemId);
@@ -750,18 +818,6 @@ 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>
@@ -769,31 +825,29 @@ public partial class JellyfinController
{
try
{
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 deletionKeys = _cache.GetKeysByPattern("pending_deletion:*").ToList();
if (deletionKeys.Count == 0) return;
var now = DateTime.UtcNow;
var toDelete = pendingDeletions.Where(kvp => kvp.Value <= now).ToList();
var remaining = pendingDeletions.Where(kvp => kvp.Value > now)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
int deletedCount = 0;
foreach (var (itemId, _) in toDelete)
foreach (var key in deletionKeys)
{
await ActuallyDeleteTrackAsync(itemId);
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++;
}
}
if (toDelete.Count > 0)
if (deletedCount > 0)
{
// 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);
_logger.LogDebug("Processed {Count} pending deletions", deletedCount);
}
}
catch (Exception ex)
+13 -1
View File
@@ -683,6 +683,15 @@ 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
{
@@ -746,7 +755,10 @@ public partial class JellyfinController : ControllerBase
return await GetPlaceholderImageAsync();
}
_logger.LogDebug("Successfully fetched external image from host {Host}, size: {Size} bytes",
// 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",
safeCoverUri.Host, imageBytes.Length);
return File(imageBytes, "image/jpeg");
}
+9 -2
View File
@@ -161,8 +161,15 @@ public class SubsonicController : ControllerBase
try
{
var downloadStream = await _downloadService.DownloadAndStreamAsync(provider!, externalId!, HttpContext.RequestAborted);
return File(downloadStream, "audio/mpeg", enableRangeProcessing: true);
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);
}
catch (Exception ex)
{
+2 -2
View File
@@ -112,8 +112,8 @@ public class Song
public int? ExplicitContentLyrics { get; set; }
/// <summary>
/// Raw Jellyfin metadata (MediaSources, etc.) for local tracks
/// Preserved to maintain bitrate and other technical details
/// Raw Jellyfin metadata for local tracks, including MediaSources and cached item snapshots
/// Preserved to maintain full Jellyfin object fidelity across cache round-trips
/// </summary>
public Dictionary<string, object?>? JellyfinMetadata { get; set; }
}
+4
View File
@@ -8,8 +8,12 @@ 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; }
@@ -61,6 +61,14 @@ 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);
@@ -71,4 +79,5 @@ 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,4 +22,10 @@ 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,4 +22,10 @@ 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;
}
@@ -14,4 +14,10 @@ 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;
}
+8
View File
@@ -509,6 +509,7 @@ 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>();
@@ -891,6 +892,13 @@ 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);
+252 -60
View File
@@ -29,13 +29,40 @@ public abstract class BaseDownloadService : IDownloadService
protected readonly string CachePath;
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
// Concurrency and state locking
protected readonly SemaphoreSlim _stateSemaphore = new(1, 1);
protected readonly SemaphoreSlim _concurrencySemaphore;
// Rate limiting fields
private readonly SemaphoreSlim _requestLock = new(1, 1);
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly int _minRequestIntervalMs = 200;
protected 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>
@@ -84,6 +111,13 @@ 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
@@ -95,12 +129,25 @@ 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, cancellationToken);
return await DownloadSongInternalAsync(
externalProvider,
externalId,
triggerAlbumDownload: true,
requestedForStreaming: false,
cancellationToken);
}
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
public async Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, StreamQuality? qualityOverride = null, 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
@@ -111,7 +158,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 (SubsonicSettings.StorageMode == StorageMode.Cache)
if (CurrentStorageMode == StorageMode.Cache)
{
IOFile.SetLastWriteTime(localPath, DateTime.UtcNow);
}
@@ -134,7 +181,12 @@ 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, CancellationToken.None);
localPath = await DownloadSongInternalAsync(
externalProvider,
externalId,
triggerAlbumDownload: true,
requestedForStreaming: true,
CancellationToken.None);
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath);
@@ -157,6 +209,65 @@ 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.
@@ -194,6 +305,11 @@ 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)
{
@@ -213,6 +329,24 @@ 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)
{
@@ -249,6 +383,23 @@ 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"
@@ -272,20 +423,25 @@ 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, CancellationToken cancellationToken = default)
protected async Task<string> DownloadSongInternalAsync(
string externalProvider,
string externalId,
bool triggerAlbumDownload,
bool requestedForStreaming = false,
CancellationToken cancellationToken = default)
{
if (externalProvider != ProviderName)
{
throw new NotSupportedException($"Provider '{externalProvider}' is not supported");
}
var songId = $"ext-{externalProvider}-{externalId}";
var isCache = SubsonicSettings.StorageMode == StorageMode.Cache;
var songId = BuildTrackedSongId(externalProvider, externalId);
var isCache = CurrentStorageMode == StorageMode.Cache;
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
await DownloadLock.WaitAsync(cancellationToken);
var lockHeld = true;
bool isInitiator = false;
// 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)
@@ -306,40 +462,73 @@ public abstract class BaseDownloadService : IDownloadService
// Check if download in progress
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
{
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
// 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)
if (requestedForStreaming)
{
// 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);
activeDownload.RequestedForStreaming = true;
}
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");
}
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
{
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");
}
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");
}
// --- 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 (SubsonicSettings.DownloadMode == DownloadMode.Album)
if (CurrentDownloadMode == DownloadMode.Album)
{
// First try to get the song to extract album ID
var tempSong = await MetadataService.GetSongAsync(externalProvider, externalId);
@@ -370,21 +559,23 @@ public abstract class BaseDownloadService : IDownloadService
throw new Exception("Song not found");
}
var downloadInfo = new DownloadInfo
// Update ActiveDownloads with the real title/artist information
if (ActiveDownloads.TryGetValue(songId, out var info))
{
SongId = songId,
ExternalId = externalId,
ExternalProvider = externalProvider,
Status = DownloadStatus.InProgress,
StartedAt = DateTime.UtcNow
};
ActiveDownloads[songId] = downloadInfo;
info.Title = song.Title ?? "Unknown Title";
info.Artist = song.Artist ?? "Unknown Artist";
info.DurationSeconds = song.Duration;
}
var localPath = await DownloadTrackAsync(externalId, song, cancellationToken);
downloadInfo.Status = DownloadStatus.Completed;
downloadInfo.LocalPath = localPath;
downloadInfo.CompletedAt = DateTime.UtcNow;
if (ActiveDownloads.TryGetValue(songId, out var successInfo))
{
successInfo.Status = DownloadStatus.Completed;
successInfo.Progress = 1.0;
successInfo.LocalPath = localPath;
successInfo.CompletedAt = DateTime.UtcNow;
}
song.LocalPath = localPath;
@@ -434,7 +625,7 @@ public abstract class BaseDownloadService : IDownloadService
});
// If download mode is Album and triggering is enabled, start background download of remaining tracks
if (triggerAlbumDownload && SubsonicSettings.DownloadMode == DownloadMode.Album && !string.IsNullOrEmpty(song.AlbumId))
if (triggerAlbumDownload && CurrentDownloadMode == DownloadMode.Album && !string.IsNullOrEmpty(song.AlbumId))
{
var albumExternalId = ExtractExternalIdFromAlbumId(song.AlbumId);
if (!string.IsNullOrEmpty(albumExternalId))
@@ -467,12 +658,11 @@ 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);
songId, (int)httpRequestException.StatusCode.Value, httpRequestException.StatusCode.Value);
Logger.LogDebug(ex, "Detailed download failure for {SongId}", songId);
}
else
@@ -483,10 +673,7 @@ public abstract class BaseDownloadService : IDownloadService
}
finally
{
if (lockHeld)
{
DownloadLock.Release();
}
_concurrencySemaphore.Release();
}
}
@@ -521,7 +708,7 @@ public abstract class BaseDownloadService : IDownloadService
}
// Check if download is already in progress or recently completed
var songId = $"ext-{ProviderName}-{track.ExternalId}";
var songId = BuildTrackedSongId(track.ExternalId!);
if (ActiveDownloads.TryGetValue(songId, out var activeDownload))
{
if (activeDownload.Status == DownloadStatus.InProgress)
@@ -538,7 +725,12 @@ public abstract class BaseDownloadService : IDownloadService
}
Logger.LogInformation("Downloading track '{Title}' from album '{Album}'", track.Title, album.Title);
await DownloadSongInternalAsync(ProviderName, track.ExternalId!, triggerAlbumDownload: false, CancellationToken.None);
await DownloadSongInternalAsync(
ProviderName,
track.ExternalId!,
triggerAlbumDownload: false,
requestedForStreaming: false,
CancellationToken.None);
}
catch (Exception ex)
{
@@ -45,6 +45,7 @@ public class CacheCleanupService : BackgroundService
try
{
await CleanupOldCachedFilesAsync(stoppingToken);
await CleanupTranscodedCacheAsync(stoppingToken);
await ProcessPendingDeletionsAsync(stoppingToken);
await Task.Delay(_cleanupInterval, stoppingToken);
}
@@ -134,6 +135,71 @@ 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,4 +49,5 @@ 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;
}
+10 -1
View File
@@ -153,13 +153,22 @@ public static class CacheKeyBuilder
#endregion
#region Playlist Keys
#region Image 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
+102 -5
View File
@@ -35,13 +35,13 @@ public class EnvMigrationService
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
continue;
// Migrate DOWNLOAD_PATH to Library__DownloadPath
if (line.StartsWith("DOWNLOAD_PATH="))
// Migrate Library__DownloadPath to DOWNLOAD_PATH (inverse migration)
if (line.StartsWith("Library__DownloadPath="))
{
var value = line.Substring("DOWNLOAD_PATH=".Length);
lines[i] = $"Library__DownloadPath={value}";
var value = line.Substring("Library__DownloadPath=".Length);
lines[i] = $"DOWNLOAD_PATH={value}";
modified = true;
_logger.LogDebug("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file");
_logger.LogInformation("Migrated Library__DownloadPath to DOWNLOAD_PATH in .env file");
}
// Migrate old SquidWTF quality values to new format
@@ -104,10 +104,107 @@ 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");
}
}
}
@@ -0,0 +1,168 @@
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; }
}
}
@@ -0,0 +1,79 @@
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()
};
}
}
@@ -0,0 +1,61 @@
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,6 +248,25 @@ 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.
@@ -0,0 +1,100 @@
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
};
}
}
@@ -0,0 +1,110 @@
namespace allstarr.Services.Common;
/// <summary>
/// Represents the quality tier requested by a client for streaming.
/// Used to map client transcoding parameters to provider-specific quality levels.
/// The .env quality setting acts as a ceiling — client requests can only go equal or lower.
/// </summary>
public enum StreamQuality
{
/// <summary>
/// Use the quality configured in .env / appsettings (default behavior).
/// This is the "Lossless" / "no transcoding" selection in a client.
/// </summary>
Original,
/// <summary>
/// High quality lossy (e.g., 320kbps AAC/MP3).
/// Covers client selections: 320K, 256K, 192K.
/// Maps to: SquidWTF HIGH, Deezer MP3_320, Qobuz MP3_320.
/// </summary>
High,
/// <summary>
/// Low quality lossy (e.g., 96-128kbps AAC/MP3).
/// Covers client selections: 128K, 64K.
/// Maps to: SquidWTF LOW, Deezer MP3_128, Qobuz MP3_320 (lowest available).
/// </summary>
Low
}
/// <summary>
/// Parses Jellyfin client transcoding query parameters to determine
/// the requested stream quality tier for external tracks.
///
/// Typical client quality options: Lossless, 320K, 256K, 192K, 128K, 64K
/// These are mapped to StreamQuality tiers which providers then translate
/// to their own quality levels, capped at the .env ceiling.
/// </summary>
public static class StreamQualityHelper
{
/// <summary>
/// Parses the request query string to determine what quality the client wants.
/// Jellyfin clients send parameters like AudioBitRate, MaxStreamingBitrate,
/// AudioCodec, TranscodingContainer when requesting transcoded streams.
/// </summary>
public static StreamQuality ParseFromQueryString(IQueryCollection query)
{
// Check for explicit audio bitrate (e.g., AudioBitRate=128000)
if (query.TryGetValue("AudioBitRate", out var audioBitRateVal) &&
int.TryParse(audioBitRateVal.FirstOrDefault(), out var audioBitRate))
{
return MapBitRateToQuality(audioBitRate);
}
// Check for MaxStreamingBitrate (e.g., MaxStreamingBitrate=140000000 for lossless)
if (query.TryGetValue("MaxStreamingBitrate", out var maxBitrateVal) &&
long.TryParse(maxBitrateVal.FirstOrDefault(), out var maxBitrate))
{
// Very high values (>= 10Mbps) indicate lossless / no transcoding
if (maxBitrate >= 10_000_000)
{
return StreamQuality.Original;
}
return MapBitRateToQuality((int)(maxBitrate / 1000));
}
// Check for audioBitRate (lowercase variant used by some clients)
if (query.TryGetValue("audioBitRate", out var audioBitRateLower) &&
int.TryParse(audioBitRateLower.FirstOrDefault(), out var audioBitRateLowerVal))
{
return MapBitRateToQuality(audioBitRateLowerVal);
}
// Check TranscodingContainer — if client requests mp3/aac, they want lossy
if (query.TryGetValue("TranscodingContainer", out var container))
{
var containerStr = container.FirstOrDefault()?.ToLowerInvariant();
if (containerStr is "mp3" or "aac" or "m4a")
{
// Container specified but no bitrate — default to High (320kbps)
return StreamQuality.High;
}
}
// No transcoding parameters — use original quality from .env
return StreamQuality.Original;
}
/// <summary>
/// Maps a bitrate value (in bps) to a StreamQuality tier.
/// Client options are typically: Lossless, 320K, 256K, 192K, 128K, 64K
///
/// >= 192kbps → High (covers 320K, 256K, 192K selections)
/// &lt; 192kbps → Low (covers 128K, 64K selections)
/// </summary>
private static StreamQuality MapBitRateToQuality(int bitRate)
{
// >= 192kbps → High (320kbps tier)
// Covers client selections: 320K, 256K, 192K
if (bitRate >= 192_000)
{
return StreamQuality.High;
}
// < 192kbps → Low (96-128kbps tier)
// Covers client selections: 128K, 64K
return StreamQuality.Low;
}
}
+144 -10
View File
@@ -57,6 +57,7 @@ public class DeezerDownloadService : BaseDownloadService
_arl = deezer.Arl;
_arlFallback = deezer.ArlFallback;
_preferredQuality = deezer.Quality;
_minRequestIntervalMs = deezer.MinRequestIntervalMs;
}
#region BaseDownloadService Implementation
@@ -98,10 +99,9 @@ public class DeezerDownloadService : BaseDownloadService
// 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 basePath = CurrentStorageMode == StorageMode.Cache
? Path.Combine(DownloadPath, "cache")
: Path.Combine(DownloadPath, "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", "*/*");
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
var res = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
res.EnsureSuccessStatusCode();
return res;
}, Logger);
response.EnsureSuccessStatusCode();
// Download and decrypt
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath);
@@ -140,6 +140,140 @@ 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)
@@ -185,7 +319,7 @@ public class DeezerDownloadService : BaseDownloadService
}, Logger);
}
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken, string? qualityOverride = null)
{
var tryDownload = async (string arl) =>
{
@@ -213,8 +347,8 @@ public class DeezerDownloadService : BaseDownloadService
: "";
// Get download URL via media API
// Build format list based on preferred quality
var formatsList = BuildFormatsList(_preferredQuality);
// Build format list based on preferred quality (or overridden quality for transcoding)
var formatsList = BuildFormatsList(qualityOverride ?? _preferredQuality);
var mediaRequest = new
{
@@ -62,6 +62,19 @@ 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
+11 -2
View File
@@ -21,13 +21,17 @@ public interface IDownloadService
Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Downloads a song and streams the result progressively
/// 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.
/// </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, CancellationToken cancellationToken = default);
Task<Stream> DownloadAndStreamAsync(string externalProvider, string externalId, Common.StreamQuality? qualityOverride = null, CancellationToken cancellationToken = default);
/// <summary>
/// Downloads remaining tracks from an album in background (excluding the specified track)
@@ -42,6 +46,11 @@ 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,6 +40,11 @@ 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,6 +2,7 @@ using System.Text.Json;
using allstarr.Models.Domain;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Common;
namespace allstarr.Services.Jellyfin;
@@ -186,10 +187,13 @@ public class JellyfinModelMapper
// Cover art URL construction
song.CoverArtUrl = $"/Items/{id}/Images/Primary";
// Preserve Jellyfin metadata (MediaSources, etc.) for local tracks
// This ensures bitrate and other technical details are maintained
song.JellyfinMetadata = new Dictionary<string, object?>();
// 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?>();
if (item.TryGetProperty("MediaSources", out var mediaSources))
{
song.JellyfinMetadata["MediaSources"] = JsonSerializer.Deserialize<object>(mediaSources.GetRawText());
@@ -115,27 +115,37 @@ public class JellyfinProxyService
var baseEndpoint = parts[0];
var existingQuery = parts[1];
// Parse existing query string
var mergedParams = new Dictionary<string, string>();
foreach (var param in existingQuery.Split('&'))
// 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))
{
var kv = param.Split('=', 2);
if (kv.Length == 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))
{
mergedParams[Uri.UnescapeDataString(kv[0])] = Uri.UnescapeDataString(kv[1]);
continue;
}
preservedParams.Add(param);
}
// Merge with provided queryParams (provided params take precedence)
if (queryParams != null)
{
foreach (var kv in queryParams)
{
mergedParams[kv.Key] = kv.Value;
}
}
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}";
var url = BuildUrl(baseEndpoint, mergedParams);
return await GetJsonAsyncInternal(url, clientHeaders);
}
@@ -296,6 +296,25 @@ 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.
@@ -678,6 +697,12 @@ public class JellyfinSessionManager : IDisposable
public DateTime? LastExplicitStopAtUtc { get; set; }
}
public sealed record ActivePlaybackState(
string DeviceId,
string ItemId,
long PositionTicks,
DateTime LastActivity);
public void Dispose()
{
_keepAliveTimer?.Dispose();
+145 -4
View File
@@ -55,6 +55,7 @@ public class QobuzDownloadService : BaseDownloadService
_userAuthToken = qobuzConfig.UserAuthToken;
_userId = qobuzConfig.UserId;
_preferredQuality = qobuzConfig.Quality;
_minRequestIntervalMs = qobuzConfig.MinRequestIntervalMs;
}
#region BaseDownloadService Implementation
@@ -101,8 +102,7 @@ public class QobuzDownloadService : BaseDownloadService
// Build organized folder structure 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
var basePath = CurrentStorageMode == 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,8 +113,12 @@ public class QobuzDownloadService : BaseDownloadService
outputPath = PathHelper.ResolveUniquePath(outputPath);
// Download the file (Qobuz files are NOT encrypted like Deezer)
var response = await _httpClient.GetAsync(downloadInfo.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
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);
@@ -130,6 +134,143 @@ 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,6 +81,19 @@ 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
@@ -26,6 +26,9 @@ namespace allstarr.Services.Spotify;
/// </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;
@@ -594,6 +597,16 @@ 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)
@@ -616,14 +629,16 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
if (!hasNewManualMappings)
if (!hasNewManualMappings && !hasIncompleteLocalSnapshots)
{
_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("New manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
_logger.LogInformation(
"Rebuilding matched track cache for {Playlist} to apply updated mappings or snapshot completeness",
playlistName);
}
// PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin)
@@ -633,6 +648,7 @@ 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)
{
@@ -640,7 +656,7 @@ public class SpotifyTrackMatchingService : BackgroundService
{
var userId = jellyfinSettings.UserId;
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
var queryParams = new Dictionary<string, string> { ["Fields"] = "ProviderIds" };
var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields };
if (!string.IsNullOrEmpty(userId))
{
queryParams["UserId"] = userId;
@@ -652,14 +668,7 @@ public class SpotifyTrackMatchingService : BackgroundService
{
foreach (var item in items.EnumerateArray())
{
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
};
var song = jellyfinModelMapper?.ParseSong(item) ?? CreateLocalSongSnapshot(item);
jellyfinTracks.Add(song);
}
_logger.LogInformation("📚 Loaded {Count} tracks from Jellyfin playlist {Playlist}",
@@ -1145,19 +1154,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Local tracks will be found via fuzzy matching instead
// STEP 2: Search EXTERNAL by ISRC
var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1);
if (results.Count > 0 && results[0].Isrc == isrc)
{
return results[0];
}
// 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;
return await metadataService.FindSongByIsrcAsync(isrc);
}
catch
{
@@ -1399,7 +1396,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// Request all fields that clients typically need (not just MediaSources)
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=Genres,DateCreated,MediaSources,ParentId,People,Tags,SortName,ProviderIds";
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields={CachedPlaylistItemFields}";
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
if (statusCode != 200 || existingTracksResponse == null)
@@ -1901,4 +1898,39 @@ 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,6 +81,7 @@ public class SquidWTFDownloadService : BaseDownloadService
// Increase timeout for large downloads and slow endpoints
_httpClient.Timeout = TimeSpan.FromMinutes(5);
_minRequestIntervalMs = _squidwtfSettings.MinRequestIntervalMs;
}
@@ -96,132 +97,216 @@ public class SquidWTFDownloadService : BaseDownloadService
}
protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
private async Task<string> RunDownloadWithFallbackAsync(string trackId, Song song, string quality, string basePath, CancellationToken cancellationToken)
{
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, 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);
// Determine extension from MIME type
var extension = downloadInfo.MimeType?.ToLower() switch
return await _fallbackHelper.TryWithFallbackAsync(async baseUrl =>
{
"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);
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);
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
request.Headers.Add("User-Agent", "Mozilla/5.0");
request.Headers.Add("Accept", "*/*");
var extension = downloadInfo.MimeType?.ToLower() switch
{
"audio/flac" => ".flac", "audio/mpeg" => ".mp3", "audio/mp4" => ".m4a", _ => ".flac"
};
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
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);
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);
}
});
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();
// Write metadata and cover art (without Spotify ID - it's only needed for lyrics)
await WriteMetadataAsync(outputPath, song, cancellationToken);
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;
return outputPath;
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;
});
}
#endregion
#region SquidWTF API Methods
/// <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)
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");
foreach (var quality in qualityOrder)
{
try
{
return await _fallbackHelper.TryWithFallbackAsync(baseUrl =>
FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken));
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));
Logger.LogDebug(ex,
"Detailed SquidWTF quality failure for track {TrackId} at quality {Quality}",
trackId,
quality);
Logger.LogWarning("Track {TrackId} unavailable at SquidWTF quality {Quality}: {Error}. Trying lower quality", trackId, quality, DescribeException(ex));
}
}
}
throw lastException ?? new Exception($"Unable to fetch SquidWTF download info for track {trackId}");
throw lastException ?? new Exception($"Unable to download track {trackId}");
});
}
#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,
@@ -7,7 +7,6 @@ using allstarr.Services.Common;
using System.Text.Json;
using System.Text;
using Microsoft.Extensions.Options;
using System.Text.Json.Nodes;
namespace allstarr.Services.SquidWTF;
@@ -17,17 +16,19 @@ 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 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)
/// 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
///
/// Quality Options:
/// - HI_RES_LOSSLESS: 24-bit/192kHz FLAC
@@ -36,7 +37,8 @@ namespace allstarr.Services.SquidWTF;
/// - LOW: 96kbps AAC
///
/// Response Structure:
/// All responses follow: { "version": "2.0", "data": { ... } }
/// Responses follow the documented hifi-api 2.x envelopes.
/// Track search and ISRC search return: { "version": "2.x", "data": { "items": [ ... ] } }
/// 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
@@ -52,6 +54,12 @@ 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;
@@ -87,12 +95,13 @@ 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, limit, cancellationToken);
var songs = await SearchSongsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken);
foreach (var song in songs)
{
var key = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id;
@@ -102,13 +111,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
}
allSongs.Add(song);
if (allSongs.Count >= limit)
if (allSongs.Count >= normalizedLimit)
{
break;
}
}
if (allSongs.Count >= limit)
if (allSongs.Count >= normalizedLimit)
{
break;
}
@@ -120,12 +129,13 @@ 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, limit, cancellationToken);
var albums = await SearchAlbumsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken);
foreach (var album in albums)
{
var key = !string.IsNullOrWhiteSpace(album.ExternalId) ? album.ExternalId : album.Id;
@@ -135,13 +145,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
}
allAlbums.Add(album);
if (allAlbums.Count >= limit)
if (allAlbums.Count >= normalizedLimit)
{
break;
}
}
if (allAlbums.Count >= limit)
if (allAlbums.Count >= normalizedLimit)
{
break;
}
@@ -153,12 +163,13 @@ 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, limit, cancellationToken);
var artists = await SearchArtistsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken);
foreach (var artist in artists)
{
var key = !string.IsNullOrWhiteSpace(artist.ExternalId) ? artist.ExternalId : artist.Id;
@@ -168,13 +179,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
}
allArtists.Add(artist);
if (allArtists.Count >= limit)
if (allArtists.Count >= normalizedLimit)
{
break;
}
}
if (allArtists.Count >= limit)
if (allArtists.Count >= normalizedLimit)
{
break;
}
@@ -186,11 +197,12 @@ 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 = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var url = BuildSearchUrl(baseUrl, "s", query, normalizedLimit);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
@@ -216,7 +228,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
int count = 0;
foreach (var track in items.EnumerateArray())
{
if (count >= limit) break;
if (count >= normalizedLimit) break;
var song = ParseTidalTrack(track);
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
@@ -236,12 +248,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
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 = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
var url = BuildSearchUrl(baseUrl, "al", query, normalizedLimit);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
@@ -261,7 +274,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
int count = 0;
foreach (var album in items.EnumerateArray())
{
if (count >= limit) break;
if (count >= normalizedLimit) break;
albums.Add(ParseTidalAlbum(album));
count++;
@@ -278,11 +291,12 @@ 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 = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
var url = BuildSearchUrl(baseUrl, "a", query, normalizedLimit);
_logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
@@ -311,7 +325,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
int count = 0;
foreach (var artist in items.EnumerateArray())
{
if (count >= limit) break;
if (count >= normalizedLimit) break;
var parsedArtist = ParseTidalArtist(artist);
artists.Add(parsedArtist);
@@ -356,12 +370,86 @@ 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 = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
var url = BuildSearchUrl(baseUrl, "p", query, normalizedLimit);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -386,7 +474,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
int count = 0;
foreach(var playlist in items.EnumerateArray())
{
if (count >= limit) break;
if (count >= normalizedLimit) break;
try
{
@@ -427,6 +515,65 @@ 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,48 +731,71 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Note: hifi-api doesn't document album endpoint, but /album/?id={albumId} is commonly used
var url = $"{baseUrl}/album/?id={externalId}";
Album? album = null;
var offset = DefaultSearchOffset;
var rawItemCount = 0;
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
while (true)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
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 json = await response.Content.ReadAsStringAsync(cancellationToken);
using 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");
}
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");
}
var pageCount = 0;
foreach (var trackWrapper in tracks.EnumerateArray())
{
pageCount++;
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;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
if (album == null)
{
throw new InvalidOperationException($"SquidWTF /album response for album {externalId} did not contain album data");
}
// Response structure: { "data": { album object with "items" array of tracks } }
if (!result.RootElement.TryGetProperty("data", out var albumElement))
{
throw new InvalidOperationException($"SquidWTF /album response for album {externalId} did not contain data");
}
await _cache.SetAsync(cacheKey, album, GetMetadataCacheTtl());
var album = ParseTidalAlbum(albumElement);
// 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);
}
}
}
}
// Cache for configurable duration
await _cache.SetAsync(cacheKey, album, CacheExtensions.MetadataTTL);
return album;
}, (Album?)null);
return album;
}, (Album?)null);
}
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
@@ -645,8 +815,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
var url = $"{baseUrl}/artist/?f={externalId}";
var url = $"{baseUrl}/artist/?id={Uri.EscapeDataString(externalId)}";
_logger.LogDebug("Fetching artist from {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
@@ -654,73 +823,44 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogDebug("SquidWTF artist response: {Json}", json.Length > 500 ? json.Substring(0, 500) + "..." : json);
var result = JsonDocument.Parse(json);
using var result = JsonDocument.Parse(json);
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);
}
}
// 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");
}
if (artistSource == null)
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}");
}
var artistElement = artistSource.Value;
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;
// Extract picture UUID (may be null)
string? pictureUuid = null;
if (artistElement.TryGetProperty("picture", out var pictureEl) && pictureEl.ValueKind != JsonValueKind.Null)
var artist = new Artist
{
pictureUuid = pictureEl.GetString();
}
Id = BuildExternalArtistId("squidwtf", externalId),
Name = artistName,
ImageUrl = imageUrl,
AlbumCount = null,
IsLocal = false,
ExternalProvider = "squidwtf",
ExternalId = externalId
};
// 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
};
_logger.LogDebug("Successfully parsed artist {ArtistName} via /artist/?id=", artist.Name);
using var doc = JsonDocument.Parse(normalizedArtist.ToJsonString());
var artist = ParseTidalArtist(doc.RootElement);
await _cache.SetAsync(cacheKey, artist, GetMetadataCacheTtl());
_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;
return artist;
}, (Artist?)null);
}
@@ -732,7 +872,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
{
_logger.LogDebug("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
// Per hifi-api README: /artist/?f={artistId} returns aggregated releases and tracks
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogDebug("Fetching artist albums from URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
@@ -779,7 +919,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
{
_logger.LogDebug("GetArtistTracksAsync called for SquidWTF artist {ExternalId}", externalId);
// Same endpoint as albums - /artist/?f={artistId} returns both albums and tracks
// Per hifi-api README: /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);
@@ -821,8 +961,7 @@ 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 url = BuildPagedEndpointUrl(baseUrl, "playlist", "id", externalId, RemoteSearchMinLimit);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -830,7 +969,8 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var rootElement = JsonDocument.Parse(json).RootElement;
using var result = JsonDocument.Parse(json);
var rootElement = result.RootElement;
// Check for error response
if (rootElement.TryGetProperty("error", out _))
@@ -855,76 +995,85 @@ 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)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var songs = new List<Song>();
var offset = DefaultSearchOffset;
var rawTrackCount = 0;
var trackIndex = 1;
string playlistName = "Unknown Playlist";
int? expectedTrackCount = null;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var playlistElement = JsonDocument.Parse(json).RootElement;
// Check for error response
if (playlistElement.TryGetProperty("error", out _))
{
throw new InvalidOperationException($"SquidWTF playlist tracks response for {externalId} contained an error payload");
}
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;
}
if (!tracks.HasValue)
while (true)
{
throw new InvalidOperationException(
$"SquidWTF playlist tracks response for {externalId} did not contain items");
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}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var result = JsonDocument.Parse(json);
var playlistElement = result.RootElement;
if (playlistElement.TryGetProperty("error", out _))
{
throw new InvalidOperationException($"SquidWTF playlist tracks response for {externalId} contained an error payload");
}
if (playlistElement.TryGetProperty("playlist", out var playlistEl))
{
if (playlistEl.TryGetProperty("title", out var titleEl))
{
playlistName = titleEl.GetString() ?? 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;
}
var songs = new List<Song>();
// Get playlist name for album field
var playlistName = playlist?.TryGetProperty("title", out var titleEl) == true
? titleEl.GetString() ?? "Unknown Playlist"
: "Unknown Playlist";
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;
// For playlists, use the track's own artist (not a single album artist)
var song = ParseTidalTrack(track, trackIndex);
// Override album name to be the playlist name
song.Album = playlistName;
// 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>());
}
@@ -1251,10 +1400,18 @@ 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)
var imageUrl = artist.TryGetProperty("picture", out var picture) &&
picture.ValueKind == JsonValueKind.String
? 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);
@@ -1276,8 +1433,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
/// <summary>
/// Parses a Tidal playlist from hifi-api /playlist/ endpoint response.
/// Per hifi-api spec (undocumented), response structure is:
/// { "playlist": { uuid, title, description, creator, created, numberOfTracks, duration, squareImage },
/// Response structure: { "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>
@@ -1427,13 +1583,14 @@ 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 = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var url = BuildSearchUrl(baseUrl, "s", query, normalizedLimit);
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
+8 -4
View File
@@ -51,15 +51,18 @@
"Qobuz": {
"UserAuthToken": "your-qobuz-token",
"UserId": "your-qobuz-user-id",
"Quality": "FLAC"
"Quality": "FLAC",
"MinRequestIntervalMs": 200
},
"Deezer": {
"Arl": "your-deezer-arl-token",
"ArlFallback": "",
"Quality": "FLAC"
"Quality": "FLAC",
"MinRequestIntervalMs": 200
},
"SquidWTF": {
"Quality": "FLAC"
"Quality": "FLAC",
"MinRequestIntervalMs": 200
},
"Redis": {
"Enabled": true,
@@ -74,7 +77,8 @@
"GenreDays": 30,
"MetadataDays": 7,
"OdesliLookupDays": 60,
"ProxyImagesDays": 14
"ProxyImagesDays": 14,
"TranscodeCacheMinutes": 60
},
"SpotifyImport": {
"Enabled": false,
+27 -2
View File
@@ -65,6 +65,13 @@
<!-- 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>
@@ -631,6 +638,12 @@
<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>
@@ -643,6 +656,12 @@
<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>
@@ -680,10 +699,16 @@
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">Quality</span>
<span class="label">Preferred Quality</span>
<span class="value" id="config-qobuz-quality">-</span>
<button
onclick="openEditSetting('QOBUZ_QUALITY', 'Qobuz Quality', 'select', '', ['FLAC_24_192', 'FLAC_24_96', 'FLAC_16_44', 'MP3_320'])">Edit</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>
</div>
</div>
</div>
+133
View File
@@ -6,6 +6,7 @@ import { runAction } from "./operations.js";
let playlistAutoRefreshInterval = null;
let dashboardRefreshInterval = null;
let downloadActivityEventSource = null;
let isAuthenticated = () => false;
let isAdminSession = () => false;
@@ -324,6 +325,10 @@ function stopDashboardRefresh() {
clearInterval(dashboardRefreshInterval);
dashboardRefreshInterval = null;
}
if (downloadActivityEventSource) {
downloadActivityEventSource.close();
downloadActivityEventSource = null;
}
stopPlaylistAutoRefresh();
}
@@ -375,6 +380,134 @@ 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) {
+21
View File
@@ -211,12 +211,26 @@ 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) => {
@@ -247,6 +261,13 @@ 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) => {
+6
View File
@@ -536,8 +536,12 @@ 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"
@@ -546,6 +550,8 @@ 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 =
+116
View File
@@ -980,3 +980,119 @@ input::placeholder {
transform: rotate(360deg);
}
}
/* Download Activity Queue */
.download-queue-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.download-queue-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
animation: slideIn 0.3s ease;
}
.download-queue-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.download-queue-title {
font-weight: 500;
font-size: 0.95rem;
}
.download-queue-meta {
display: flex;
align-items: center;
gap: 8px;
}
.download-queue-artist {
color: var(--text-secondary);
font-size: 0.85rem;
}
.download-queue-provider {
font-size: 0.75rem;
padding: 2px 6px;
background: rgba(88, 166, 255, 0.1);
color: var(--accent);
border-radius: 4px;
text-transform: uppercase;
}
.download-queue-badge {
font-size: 0.75rem;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.08);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 999px;
text-transform: uppercase;
}
.download-queue-badge.is-playing {
color: #79c0ff;
border-color: rgba(121, 192, 255, 0.45);
background: rgba(56, 139, 253, 0.16);
}
.download-progress-bar {
position: relative;
height: 8px;
width: 100%;
margin-top: 6px;
background: rgba(255, 255, 255, 0.06);
border-radius: 999px;
overflow: hidden;
}
.download-progress-buffer {
position: absolute;
inset: 0 auto 0 0;
background: rgba(201, 209, 217, 0.28);
border-radius: 999px;
}
.download-progress-playback {
position: absolute;
inset: 0 auto 0 0;
background: linear-gradient(90deg, #2f81f7 0%, #79c0ff 100%);
border-radius: 999px;
}
.download-progress-meta {
margin-top: 4px;
color: var(--text-secondary);
font-size: 0.75rem;
}
.download-queue-status {
display: flex;
align-items: center;
gap: 12px;
}
.download-queue-time {
font-family: monospace;
color: var(--text-secondary);
font-size: 0.85rem;
}
.empty-state {
color: var(--text-secondary);
font-style: italic;
padding: 12px;
text-align: center;
}
-72
View File
@@ -1,72 +0,0 @@
# Admin UI Modularity Guide
This document defines the modular JavaScript architecture for `allstarr/wwwroot/js` and the guardrails future agents should follow.
## Goals
- Keep admin UI code split by feature and responsibility.
- Centralize request handling and async UI action handling.
- Minimize `window.*` globals to only those required by inline HTML handlers.
- Keep polling and refresh lifecycle in one place.
## Current Module Map
- `main.js`: Composition root only. Wires modules, shared globals, and bootstrap lifecycle.
- `auth-session.js`: Auth/session state, role-based scope, login/logout wiring, 401 recovery handling.
- `dashboard-data.js`: Polling lifecycle + data loading/render orchestration.
- `operations.js`: Shared `runAction` helper + non-domain operational actions.
- `settings-editor.js`: Settings registry, modal editor rendering, local config state sync.
- `playlist-admin.js`: Playlist linking and admin CRUD.
- `scrobbling-admin.js`: Scrobbling configuration actions and UI state updates.
- `api.js`: API transport layer wrappers and endpoint functions.
## Required Patterns
### 1) Request Layer Rules
- All HTTP requests must go through `api.js`.
- `api.js` owns low-level `fetch` usage (`requestJson`, `requestBlob`, `requestOptionalJson`).
- Feature modules should call `API.*` methods and avoid direct `fetch`.
### 2) Action Flow Rules
- UI actions with toast/error handling should use `runAction(...)` from `operations.js`.
- If an action always reloads scrobbling UI state, use `runScrobblingAction(...)` in `scrobbling-admin.js`.
### 3) Polling Rules
- Polling timers must stay in `dashboard-data.js`.
- New background refresh loops should be added to existing refresh lifecycle, not separate timers in other modules.
### 4) Global Surface Rules
- Expose only `window.*` members needed by current inline HTML (`onclick`, `onchange`, `oninput`) or legacy UI templates.
- Keep new feature logic module-scoped and expose narrow entry points in `init*` functions.
## Adding New Admin UI Behavior
1. Add/extend endpoint method in `api.js`.
2. Implement feature logic in the relevant module (`*-admin.js`, `dashboard-data.js`, etc.).
3. Prefer `runAction(...)` for async UI operations.
4. Export/init through module `init*` only.
5. Wire it from `main.js` if cross-module dependencies are needed.
6. Add/adjust tests in `allstarr.Tests/JavaScriptSyntaxTests.cs`.
## Tests That Enforce This Architecture
`allstarr.Tests/JavaScriptSyntaxTests.cs` includes checks for:
- Module existence and syntax.
- Coordinator bootstrap expectations.
- API request centralization (`fetch` calls constrained to helper functions in `api.js`).
- Scrobbling module prohibition on direct `fetch`.
## Fast Validation Commands
```bash
# Full suite
dotnet test allstarr.sln
# JS architecture/syntax focused
dotnet test allstarr.Tests/allstarr.Tests.csproj --filter JavaScriptSyntaxTests
```
+249
View File
@@ -0,0 +1,249 @@
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
+16 -10
View File
@@ -1,17 +1,19 @@
services:
redis:
image: redis:7-alpine
container_name: allstarr-redis
valkey:
image: valkey/valkey:8
container_name: allstarr-valkey
restart: unless-stopped
# Redis is only accessible internally - no external port exposure
# Valkey is only accessible internally - no external port exposure
expose:
- "6379"
command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes
command: valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
# Use CMD-SHELL for broader compatibility in some environments
test: ["CMD-SHELL", "valkey-cli ping || exit 1"]
interval: 10s
timeout: 3s
retries: 3
retries: 5
start_period: 20s
volumes:
- ${REDIS_DATA_PATH:-./redis-data}:/data
networks:
@@ -52,7 +54,7 @@ services:
# DO NOT expose through reverse proxy - contains sensitive config
- "5275:5275"
depends_on:
redis:
valkey:
condition: service_healthy
spotify-lyrics:
condition: service_started
@@ -72,8 +74,8 @@ services:
- Admin__BindAnyIp=${ADMIN_BIND_ANY_IP:-false}
- Admin__TrustedSubnets=${ADMIN_TRUSTED_SUBNETS:-}
# ===== REDIS CACHE =====
- Redis__ConnectionString=redis:6379
# ===== REDIS / VALKEY CACHE =====
- Redis__ConnectionString=valkey:6379
- Redis__Enabled=${REDIS_ENABLED:-true}
# ===== CACHE TTL SETTINGS =====
@@ -86,6 +88,7 @@ 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}
@@ -152,12 +155,15 @@ services:
# ===== 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:-}