Compare commits

...

18 Commits

Author SHA1 Message Date
joshpatra 4c1e6979b3 v1.4.4-beta.1: re-releasing tag
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-25 16:30:19 -04:00
joshpatra 0738e2d588 Merge branch 'main' into beta 2026-03-25 16:28:27 -04:00
joshpatra 0a5b383526 v1.4.3: fixed .env restarting from Admin UI, re-release of prev ver 2026-03-25 16:11:27 -04:00
joshpatra 5e8cb13d1a v1.4.3-beta.1: fixed .env restarting from Admin UI, re-release of prev ver 2026-03-25 16:05:59 -04:00
joshpatra efdeef927a Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-24 11:12:49 -04:00
joshpatra 5c184d38c8 v1.4.2: added an env migration service, fixed DOWNLOAD_PATH requiring Subsonic settings in the backend
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-24 11:11:46 -04:00
joshpatra 30f68729fc v1.4.2-beta.1: added an env migratino service, fixed DOWNLOAD_PATH requiring Subsonic settings in the backend 2026-03-24 11:10:29 -04:00
joshpatra 53f7b5e8b3 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-23 13:13:01 -04:00
joshpatra 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 299cb025f1 v1.3.3: 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
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-12 19:14:17 -04:00
joshpatra b737db93be whoops, forgot version bump 2026-03-12 15:36:07 -04:00
joshpatra 953719e796 version bump 2026-03-12 15:35:36 -04:00
joshpatra ecdd514579 v1.3.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:35:04 -04:00
66 changed files with 4355 additions and 772 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 -1
View File
@@ -73,7 +73,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
+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
+2 -2
View File
@@ -65,13 +65,13 @@ Allstarr includes a web UI for easy configuration and playlist management, acces
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
4. **Restart** to apply changes (should be a banner)
4. **Restart Allstarr** to apply changes (should be a banner)
Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them.
### Configuration Persistence
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
The web UI updates your `.env` file directly. Allstarr reloads that file on startup, so a normal container restart is enough for UI changes to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
There's an environment variable to modify this.
@@ -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,88 @@
using allstarr.Services.Common;
using Microsoft.Extensions.Configuration;
namespace allstarr.Tests;
public sealed class RuntimeEnvConfigurationTests : IDisposable
{
private readonly string _envFilePath = Path.Combine(
Path.GetTempPath(),
$"allstarr-runtime-{Guid.NewGuid():N}.env");
[Fact]
public void MapEnvVarToConfiguration_MapsFlatKeyToNestedConfigKey()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS", "7")
.ToList();
var mapping = Assert.Single(mappings);
Assert.Equal("SpotifyImport:MatchingIntervalHours", mapping.Key);
Assert.Equal("7", mapping.Value);
}
[Fact]
public void MapEnvVarToConfiguration_MapsSharedBackendKeysToBothSections()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("MUSIC_SERVICE", "Qobuz")
.OrderBy(x => x.Key, StringComparer.Ordinal)
.ToList();
Assert.Equal(2, mappings.Count);
Assert.Equal("Jellyfin:MusicService", mappings[0].Key);
Assert.Equal("Qobuz", mappings[0].Value);
Assert.Equal("Subsonic:MusicService", mappings[1].Key);
Assert.Equal("Qobuz", mappings[1].Value);
}
[Fact]
public void MapEnvVarToConfiguration_IgnoresComposeOnlyMountKeys()
{
var mappings = RuntimeEnvConfiguration
.MapEnvVarToConfiguration("DOWNLOAD_PATH", "./downloads")
.ToList();
Assert.Empty(mappings);
}
[Fact]
public void LoadDotEnvOverrides_StripsQuotesAndSupportsDoubleUnderscoreKeys()
{
File.WriteAllText(
_envFilePath,
"""
SPOTIFY_API_SESSION_COOKIE="secret-cookie"
Admin__EnableEnvExport=true
""");
var overrides = RuntimeEnvConfiguration.LoadDotEnvOverrides(_envFilePath);
Assert.Equal("secret-cookie", overrides["SpotifyApi:SessionCookie"]);
Assert.Equal("true", overrides["Admin:EnableEnvExport"]);
}
[Fact]
public void AddDotEnvOverrides_OverridesEarlierConfigurationValues()
{
File.WriteAllText(_envFilePath, "SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=7\n");
var configuration = new ConfigurationManager();
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["SpotifyImport:MatchingIntervalHours"] = "24"
});
RuntimeEnvConfiguration.AddDotEnvOverrides(configuration, _envFilePath);
Assert.Equal(7, configuration.GetValue<int>("SpotifyImport:MatchingIntervalHours"));
}
public void Dispose()
{
if (File.Exists(_envFilePath))
{
File.Delete(_envFilePath);
}
}
}
@@ -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.3";
}
+87 -52
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());
@@ -545,7 +580,7 @@ public class ConfigController : ControllerBase
return Ok(new
{
message = "Configuration updated. Restart container to apply changes.",
message = "Configuration updated. Restart Allstarr to apply changes.",
updatedKeys = appliedUpdates,
requiresRestart = true,
envFilePath = _helperService.GetEnvFilePath()
@@ -661,7 +696,7 @@ public class ConfigController : ControllerBase
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
return StatusCode(503, new {
error = "Docker socket not available",
message = "Please restart manually: docker-compose restart allstarr"
message = "Please restart manually: docker restart allstarr"
});
}
@@ -714,7 +749,7 @@ public class ConfigController : ControllerBase
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new {
error = "Failed to restart container",
message = "Please restart manually: docker-compose restart allstarr"
message = "Please restart manually: docker restart allstarr"
});
}
}
@@ -723,7 +758,7 @@ public class ConfigController : ControllerBase
_logger.LogError(ex, "Error restarting container");
return StatusCode(500, new {
error = "Failed to restart container",
message = "Please restart manually: docker-compose restart allstarr"
message = "Please restart manually: docker restart allstarr"
});
}
}
@@ -855,7 +890,7 @@ public class ConfigController : ControllerBase
return Ok(new
{
success = true,
message = ".env file imported successfully. Restart the application for changes to take effect."
message = ".env file imported successfully. Restart Allstarr for changes to take effect."
});
}
catch (Exception ex)
@@ -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;
}
+9
View File
@@ -16,6 +16,7 @@ using Microsoft.Extensions.Http;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
// Discover SquidWTF API and streaming endpoints from uptime feeds.
var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync();
@@ -509,6 +510,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 +893,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);
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
namespace allstarr.Services.Admin;
@@ -20,9 +21,7 @@ public class AdminHelperService
{
_logger = logger;
_jellyfinSettings = jellyfinSettings.Value;
_envFilePath = environment.IsDevelopment()
? Path.Combine(environment.ContentRootPath, "..", ".env")
: "/app/.env";
_envFilePath = RuntimeEnvConfiguration.ResolveEnvFilePath(environment);
}
public string GetJellyfinAuthHeader()
+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,235 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
namespace allstarr.Services.Common;
/// <summary>
/// Loads supported flat .env keys into ASP.NET configuration so Docker/admin UI
/// updates stored in /app/.env take effect on the next application startup.
/// </summary>
public static class RuntimeEnvConfiguration
{
private static readonly IReadOnlyDictionary<string, string[]> ExactKeyMappings =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["BACKEND_TYPE"] = ["Backend:Type"],
["ADMIN_BIND_ANY_IP"] = ["Admin:BindAnyIp"],
["ADMIN_TRUSTED_SUBNETS"] = ["Admin:TrustedSubnets"],
["ADMIN_ENABLE_ENV_EXPORT"] = ["Admin:EnableEnvExport"],
["CORS_ALLOWED_ORIGINS"] = ["Cors:AllowedOrigins"],
["CORS_ALLOWED_METHODS"] = ["Cors:AllowedMethods"],
["CORS_ALLOWED_HEADERS"] = ["Cors:AllowedHeaders"],
["CORS_ALLOW_CREDENTIALS"] = ["Cors:AllowCredentials"],
["SUBSONIC_URL"] = ["Subsonic:Url"],
["JELLYFIN_URL"] = ["Jellyfin:Url"],
["JELLYFIN_API_KEY"] = ["Jellyfin:ApiKey"],
["JELLYFIN_USER_ID"] = ["Jellyfin:UserId"],
["JELLYFIN_CLIENT_USERNAME"] = ["Jellyfin:ClientUsername"],
["JELLYFIN_LIBRARY_ID"] = ["Jellyfin:LibraryId"],
["LIBRARY_DOWNLOAD_PATH"] = ["Library:DownloadPath"],
["LIBRARY_KEPT_PATH"] = ["Library:KeptPath"],
["REDIS_ENABLED"] = ["Redis:Enabled"],
["REDIS_CONNECTION_STRING"] = ["Redis:ConnectionString"],
["SPOTIFY_IMPORT_ENABLED"] = ["SpotifyImport:Enabled"],
["SPOTIFY_IMPORT_SYNC_START_HOUR"] = ["SpotifyImport:SyncStartHour"],
["SPOTIFY_IMPORT_SYNC_START_MINUTE"] = ["SpotifyImport:SyncStartMinute"],
["SPOTIFY_IMPORT_SYNC_WINDOW_HOURS"] = ["SpotifyImport:SyncWindowHours"],
["SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS"] = ["SpotifyImport:MatchingIntervalHours"],
["SPOTIFY_IMPORT_PLAYLISTS"] = ["SpotifyImport:Playlists"],
["SPOTIFY_IMPORT_PLAYLIST_IDS"] = ["SpotifyImport:PlaylistIds"],
["SPOTIFY_IMPORT_PLAYLIST_NAMES"] = ["SpotifyImport:PlaylistNames"],
["SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS"] = ["SpotifyImport:PlaylistLocalTracksPositions"],
["SPOTIFY_API_ENABLED"] = ["SpotifyApi:Enabled"],
["SPOTIFY_API_SESSION_COOKIE"] = ["SpotifyApi:SessionCookie"],
["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = ["SpotifyApi:SessionCookieSetDate"],
["SPOTIFY_API_CACHE_DURATION_MINUTES"] = ["SpotifyApi:CacheDurationMinutes"],
["SPOTIFY_API_RATE_LIMIT_DELAY_MS"] = ["SpotifyApi:RateLimitDelayMs"],
["SPOTIFY_API_PREFER_ISRC_MATCHING"] = ["SpotifyApi:PreferIsrcMatching"],
["SPOTIFY_LYRICS_API_URL"] = ["SpotifyApi:LyricsApiUrl"],
["SCROBBLING_ENABLED"] = ["Scrobbling:Enabled"],
["SCROBBLING_LOCAL_TRACKS_ENABLED"] = ["Scrobbling:LocalTracksEnabled"],
["SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED"] = ["Scrobbling:SyntheticLocalPlayedSignalEnabled"],
["SCROBBLING_LASTFM_ENABLED"] = ["Scrobbling:LastFm:Enabled"],
["SCROBBLING_LASTFM_API_KEY"] = ["Scrobbling:LastFm:ApiKey"],
["SCROBBLING_LASTFM_SHARED_SECRET"] = ["Scrobbling:LastFm:SharedSecret"],
["SCROBBLING_LASTFM_SESSION_KEY"] = ["Scrobbling:LastFm:SessionKey"],
["SCROBBLING_LASTFM_USERNAME"] = ["Scrobbling:LastFm:Username"],
["SCROBBLING_LASTFM_PASSWORD"] = ["Scrobbling:LastFm:Password"],
["SCROBBLING_LISTENBRAINZ_ENABLED"] = ["Scrobbling:ListenBrainz:Enabled"],
["SCROBBLING_LISTENBRAINZ_USER_TOKEN"] = ["Scrobbling:ListenBrainz:UserToken"],
["DEBUG_LOG_ALL_REQUESTS"] = ["Debug:LogAllRequests"],
["DEBUG_REDACT_SENSITIVE_REQUEST_VALUES"] = ["Debug:RedactSensitiveRequestValues"],
["DEEZER_ARL"] = ["Deezer:Arl"],
["DEEZER_ARL_FALLBACK"] = ["Deezer:ArlFallback"],
["DEEZER_QUALITY"] = ["Deezer:Quality"],
["DEEZER_MIN_REQUEST_INTERVAL_MS"] = ["Deezer:MinRequestIntervalMs"],
["QOBUZ_USER_AUTH_TOKEN"] = ["Qobuz:UserAuthToken"],
["QOBUZ_USER_ID"] = ["Qobuz:UserId"],
["QOBUZ_QUALITY"] = ["Qobuz:Quality"],
["QOBUZ_MIN_REQUEST_INTERVAL_MS"] = ["Qobuz:MinRequestIntervalMs"],
["SQUIDWTF_QUALITY"] = ["SquidWTF:Quality"],
["SQUIDWTF_MIN_REQUEST_INTERVAL_MS"] = ["SquidWTF:MinRequestIntervalMs"],
["MUSICBRAINZ_ENABLED"] = ["MusicBrainz:Enabled"],
["MUSICBRAINZ_USERNAME"] = ["MusicBrainz:Username"],
["MUSICBRAINZ_PASSWORD"] = ["MusicBrainz:Password"],
["CACHE_SEARCH_RESULTS_MINUTES"] = ["Cache:SearchResultsMinutes"],
["CACHE_PLAYLIST_IMAGES_HOURS"] = ["Cache:PlaylistImagesHours"],
["CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS"] = ["Cache:SpotifyPlaylistItemsHours"],
["CACHE_SPOTIFY_MATCHED_TRACKS_DAYS"] = ["Cache:SpotifyMatchedTracksDays"],
["CACHE_LYRICS_DAYS"] = ["Cache:LyricsDays"],
["CACHE_GENRE_DAYS"] = ["Cache:GenreDays"],
["CACHE_METADATA_DAYS"] = ["Cache:MetadataDays"],
["CACHE_ODESLI_LOOKUP_DAYS"] = ["Cache:OdesliLookupDays"],
["CACHE_PROXY_IMAGES_DAYS"] = ["Cache:ProxyImagesDays"],
["CACHE_TRANSCODE_MINUTES"] = ["Cache:TranscodeCacheMinutes"]
};
private static readonly IReadOnlyDictionary<string, string[]> SharedBackendKeyMappings =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["MUSIC_SERVICE"] = ["Subsonic:MusicService", "Jellyfin:MusicService"],
["EXPLICIT_FILTER"] = ["Subsonic:ExplicitFilter", "Jellyfin:ExplicitFilter"],
["DOWNLOAD_MODE"] = ["Subsonic:DownloadMode", "Jellyfin:DownloadMode"],
["STORAGE_MODE"] = ["Subsonic:StorageMode", "Jellyfin:StorageMode"],
["CACHE_DURATION_HOURS"] = ["Subsonic:CacheDurationHours", "Jellyfin:CacheDurationHours"],
["ENABLE_EXTERNAL_PLAYLISTS"] = ["Subsonic:EnableExternalPlaylists", "Jellyfin:EnableExternalPlaylists"],
["PLAYLISTS_DIRECTORY"] = ["Subsonic:PlaylistsDirectory", "Jellyfin:PlaylistsDirectory"]
};
private static readonly HashSet<string> IgnoredComposeOnlyKeys = new(StringComparer.OrdinalIgnoreCase)
{
"DOWNLOAD_PATH",
"KEPT_PATH",
"CACHE_PATH",
"REDIS_DATA_PATH"
};
public static string ResolveEnvFilePath(IHostEnvironment environment)
{
return environment.IsDevelopment()
? Path.GetFullPath(Path.Combine(environment.ContentRootPath, "..", ".env"))
: "/app/.env";
}
public static void AddDotEnvOverrides(
ConfigurationManager configuration,
IHostEnvironment environment,
TextWriter? logWriter = null)
{
AddDotEnvOverrides(configuration, ResolveEnvFilePath(environment), logWriter);
}
public static void AddDotEnvOverrides(
ConfigurationManager configuration,
string envFilePath,
TextWriter? logWriter = null)
{
var overrides = LoadDotEnvOverrides(envFilePath);
if (overrides.Count == 0)
{
if (File.Exists(envFilePath))
{
logWriter?.WriteLine($"No supported runtime overrides found in {envFilePath}");
}
return;
}
configuration.AddInMemoryCollection(overrides);
logWriter?.WriteLine($"Loaded {overrides.Count} runtime override(s) from {envFilePath}");
}
public static Dictionary<string, string?> LoadDotEnvOverrides(string envFilePath)
{
var overrides = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(envFilePath))
{
return overrides;
}
foreach (var line in File.ReadLines(envFilePath))
{
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
{
continue;
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}
var envKey = line[..separatorIndex].Trim();
var envValue = StripQuotes(line[(separatorIndex + 1)..].Trim());
foreach (var mapping in MapEnvVarToConfiguration(envKey, envValue))
{
overrides[mapping.Key] = mapping.Value;
}
}
return overrides;
}
public static IEnumerable<KeyValuePair<string, string?>> MapEnvVarToConfiguration(string envKey, string? envValue)
{
if (string.IsNullOrWhiteSpace(envKey) || IgnoredComposeOnlyKeys.Contains(envKey))
{
yield break;
}
if (envKey.Contains("__", StringComparison.Ordinal))
{
yield return new KeyValuePair<string, string?>(envKey.Replace("__", ":"), envValue);
yield break;
}
if (SharedBackendKeyMappings.TryGetValue(envKey, out var sharedKeys))
{
foreach (var sharedKey in sharedKeys)
{
yield return new KeyValuePair<string, string?>(sharedKey, envValue);
}
yield break;
}
if (ExactKeyMappings.TryGetValue(envKey, out var configKeys))
{
foreach (var configKey in configKeys)
{
yield return new KeyValuePair<string, string?>(configKey, envValue);
}
}
}
private static string StripQuotes(string? value)
{
if (string.IsNullOrEmpty(value))
{
return value ?? string.Empty;
}
if (value.StartsWith('"') && value.EndsWith('"') && value.Length >= 2)
{
return value[1..^1];
}
return value;
}
}
@@ -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,
+29 -4
View File
@@ -12,7 +12,7 @@
<!-- Restart Required Banner -->
<div class="restart-banner" id="restart-banner">
⚠️ Configuration changed. Restart required to apply changes.
<button onclick="restartContainer()">Restart Now</button>
<button onclick="restartContainer()">Restart Allstarr</button>
<button onclick="dismissRestartBanner()"
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
</div>
@@ -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>
@@ -833,7 +858,7 @@
</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="danger" onclick="clearCache()">Clear All Cache</button>
<button class="danger" onclick="restartContainer()">Restart Container</button>
<button class="danger" onclick="restartContainer()">Restart Allstarr</button>
</div>
</div>
</div>
+1 -1
View File
@@ -274,7 +274,7 @@ export async function restartContainer() {
return requestJson(
"/api/admin/restart",
{ method: "POST" },
"Failed to restart container",
"Failed to restart Allstarr",
);
}
+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) {
+4 -4
View File
@@ -270,7 +270,7 @@ async function importEnv(event) {
const result = await runAction({
confirmMessage:
"Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.",
"Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart Allstarr for changes to take effect.",
task: () => API.importEnv(file),
success: (data) => data.message,
error: (err) => err.message || "Failed to import .env file",
@@ -283,7 +283,7 @@ async function importEnv(event) {
async function restartContainer() {
if (
!confirm(
"Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.",
"Restart Allstarr to reload /app/.env and apply configuration changes?\n\nThe dashboard will be temporarily unavailable.",
)
) {
return;
@@ -291,7 +291,7 @@ async function restartContainer() {
const result = await runAction({
task: () => API.restartContainer(),
error: "Failed to restart container",
error: "Failed to restart Allstarr",
});
if (!result) {
@@ -301,7 +301,7 @@ async function restartContainer() {
document.getElementById("restart-overlay")?.classList.add("active");
const statusEl = document.getElementById("restart-status");
if (statusEl) {
statusEl.textContent = "Stopping container...";
statusEl.textContent = "Restarting Allstarr...";
}
setTimeout(() => {
+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:-}