mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-23 10:42:37 -04:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
3c291d5fac
|
|||
|
2a430a1c38
|
|||
|
1a0f7c0282
|
|||
|
6b89fe548f
|
|||
|
233af5dc8f
|
|||
|
4c1e6979b3
|
|||
|
0738e2d588
|
|||
|
0a5b383526
|
|||
|
5e8cb13d1a
|
|||
|
efdeef927a
|
|||
|
5c184d38c8
|
|||
|
30f68729fc
|
|||
|
53f7b5e8b3
|
|||
|
4b423eecb2
|
|||
|
da33ba9fbd
|
|||
|
6c95cfd2d6
|
|||
|
d4230a2f79
|
|||
|
50157db484
|
|||
|
2d11d913e8
|
|||
|
299cb025f1
|
|||
|
b737db93be
|
|||
|
953719e796
|
|||
|
ecdd514579
|
+19
-6
@@ -32,6 +32,7 @@ CORS_ALLOW_CREDENTIALS=false
|
|||||||
|
|
||||||
# Redis data persistence directory (default: ./redis-data)
|
# Redis data persistence directory (default: ./redis-data)
|
||||||
# Contains Redis RDB snapshots and AOF logs for crash recovery
|
# 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
|
REDIS_DATA_PATH=./redis-data
|
||||||
|
|
||||||
# ===== CACHE TTL SETTINGS =====
|
# ===== CACHE TTL SETTINGS =====
|
||||||
@@ -68,6 +69,11 @@ CACHE_ODESLI_LOOKUP_DAYS=60
|
|||||||
# Jellyfin proxy images cache duration in days (default: 14 = 2 weeks)
|
# Jellyfin proxy images cache duration in days (default: 14 = 2 weeks)
|
||||||
CACHE_PROXY_IMAGES_DAYS=14
|
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 =====
|
# ===== SUBSONIC/NAVIDROME CONFIGURATION =====
|
||||||
# Server URL (required if using Subsonic backend)
|
# 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 to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF)
|
||||||
MUSIC_SERVICE=SquidWTF
|
MUSIC_SERVICE=SquidWTF
|
||||||
|
|
||||||
# Base directory for all downloads (default: ./downloads)
|
# Base directory for permanently downloaded tracks (default: ./downloads)
|
||||||
# This creates three subdirectories:
|
# Note: Temporarily cached tracks are stored in {DOWNLOAD_PATH}/cache. Favorited
|
||||||
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
# tracks are stored separately in KEPT_PATH (default: ./kept)
|
||||||
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
DOWNLOAD_PATH=./downloads
|
||||||
# - downloads/kept/ - Favorited external tracks (always permanent)
|
|
||||||
Library__DownloadPath=./downloads
|
|
||||||
|
|
||||||
# ===== SQUIDWTF CONFIGURATION =====
|
# ===== SQUIDWTF CONFIGURATION =====
|
||||||
# Preferred audio quality (optional, default: LOSSLESS)
|
# Preferred audio quality (optional, default: LOSSLESS)
|
||||||
@@ -110,6 +114,9 @@ Library__DownloadPath=./downloads
|
|||||||
# If not specified, LOSSLESS (16-bit FLAC) will be used
|
# If not specified, LOSSLESS (16-bit FLAC) will be used
|
||||||
SQUIDWTF_QUALITY=LOSSLESS
|
SQUIDWTF_QUALITY=LOSSLESS
|
||||||
|
|
||||||
|
# Minimum interval between requests in milliseconds (default: 200)
|
||||||
|
SQUIDWTF_MIN_REQUEST_INTERVAL_MS=200
|
||||||
|
|
||||||
# ===== DEEZER CONFIGURATION =====
|
# ===== DEEZER CONFIGURATION =====
|
||||||
# Deezer ARL token (required if using Deezer)
|
# Deezer ARL token (required if using Deezer)
|
||||||
# See README.md for instructions on how to get this token
|
# 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
|
# If not specified, the highest available quality for your account will be used
|
||||||
DEEZER_QUALITY=
|
DEEZER_QUALITY=
|
||||||
|
|
||||||
|
# Minimum interval between requests in milliseconds (default: 200)
|
||||||
|
DEEZER_MIN_REQUEST_INTERVAL_MS=200
|
||||||
|
|
||||||
# ===== QOBUZ CONFIGURATION =====
|
# ===== QOBUZ CONFIGURATION =====
|
||||||
# Qobuz user authentication token (required if using Qobuz)
|
# Qobuz user authentication token (required if using Qobuz)
|
||||||
# Get this from your browser after logging into play.qobuz.com
|
# 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
|
# If not specified, the highest available quality will be used
|
||||||
QOBUZ_QUALITY=
|
QOBUZ_QUALITY=
|
||||||
|
|
||||||
|
# Minimum interval between requests in milliseconds (default: 200)
|
||||||
|
QOBUZ_MIN_REQUEST_INTERVAL_MS=200
|
||||||
|
|
||||||
# ===== MUSICBRAINZ CONFIGURATION =====
|
# ===== MUSICBRAINZ CONFIGURATION =====
|
||||||
# Enable MusicBrainz metadata lookups (optional, default: true)
|
# Enable MusicBrainz metadata lookups (optional, default: true)
|
||||||
MUSICBRAINZ_ENABLED=true
|
MUSICBRAINZ_ENABLED=true
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# These are supported funding model platforms
|
# 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
|
patreon: # Replace with a single Patreon username
|
||||||
open_collective: # Replace with a single Open Collective username
|
open_collective: # Replace with a single Open Collective username
|
||||||
ko_fi: joshpatra
|
ko_fi: joshpatra
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=ref,event=branch
|
||||||
type=sha,prefix=
|
type=ref,event=tag
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ COPY allstarr/ allstarr/
|
|||||||
COPY allstarr.Tests/ allstarr.Tests/
|
COPY allstarr.Tests/ allstarr.Tests/
|
||||||
|
|
||||||
RUN dotnet publish allstarr/allstarr.csproj -c Release -o /app/publish
|
RUN dotnet publish allstarr/allstarr.csproj -c Release -o /app/publish
|
||||||
|
COPY .env.example /app/publish/
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ Allstarr includes a web UI for easy configuration and playlist management, acces
|
|||||||
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
|
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
|
||||||
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
|
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
|
||||||
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
|
- `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.
|
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
|
### 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.
|
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,64 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class ImageConditionalRequestHelperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ComputeStrongETag_SamePayload_ReturnsStableQuotedHash()
|
||||||
|
{
|
||||||
|
var payload = new byte[] { 1, 2, 3, 4 };
|
||||||
|
|
||||||
|
var first = ImageConditionalRequestHelper.ComputeStrongETag(payload);
|
||||||
|
var second = ImageConditionalRequestHelper.ComputeStrongETag(payload);
|
||||||
|
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
Assert.StartsWith("\"", first);
|
||||||
|
Assert.EndsWith("\"", first);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MatchesIfNoneMatch_WithExactMatch_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["If-None-Match"] = "\"ABC123\""
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"ABC123\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MatchesIfNoneMatch_WithMultipleValues_ReturnsTrueForMatchingEntry()
|
||||||
|
{
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["If-None-Match"] = "\"stale\", \"fresh\""
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"fresh\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MatchesIfNoneMatch_WithWildcard_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["If-None-Match"] = "*"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"anything\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MatchesIfNoneMatch_WithoutMatch_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["If-None-Match"] = "\"ABC123\""
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.False(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"XYZ789\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,43 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using allstarr.Controllers;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class JellyfinControllerSearchLimitTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, 20, true, 20, 20, 20)]
|
||||||
|
[InlineData("MusicAlbum", 20, true, 0, 20, 0)]
|
||||||
|
[InlineData("Audio", 20, true, 20, 0, 0)]
|
||||||
|
[InlineData("MusicArtist", 20, true, 0, 0, 20)]
|
||||||
|
[InlineData("Playlist", 20, true, 0, 20, 0)]
|
||||||
|
[InlineData("Playlist", 20, false, 0, 0, 0)]
|
||||||
|
[InlineData("Audio,MusicArtist", 15, true, 15, 0, 15)]
|
||||||
|
[InlineData("BoxSet", 10, true, 0, 0, 0)]
|
||||||
|
public void GetExternalSearchLimits_UsesRequestedItemTypes(
|
||||||
|
string? includeItemTypes,
|
||||||
|
int limit,
|
||||||
|
bool includePlaylistsAsAlbums,
|
||||||
|
int expectedSongLimit,
|
||||||
|
int expectedAlbumLimit,
|
||||||
|
int expectedArtistLimit)
|
||||||
|
{
|
||||||
|
var requestedTypes = string.IsNullOrWhiteSpace(includeItemTypes)
|
||||||
|
? null
|
||||||
|
: includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
var method = typeof(JellyfinController).GetMethod(
|
||||||
|
"GetExternalSearchLimits",
|
||||||
|
BindingFlags.Static | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
|
||||||
|
var result = ((int SongLimit, int AlbumLimit, int ArtistLimit))method!.Invoke(
|
||||||
|
null,
|
||||||
|
new object?[] { requestedTypes, limit, includePlaylistsAsAlbums })!;
|
||||||
|
|
||||||
|
Assert.Equal(expectedSongLimit, result.SongLimit);
|
||||||
|
Assert.Equal(expectedAlbumLimit, result.AlbumLimit);
|
||||||
|
Assert.Equal(expectedArtistLimit, result.ArtistLimit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using allstarr.Controllers;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class JellyfinImageTagExtractionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ExtractImageTag_WithMatchingImageTagsObject_ReturnsRequestedTag()
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse("""
|
||||||
|
{
|
||||||
|
"ImageTags": {
|
||||||
|
"Primary": "playlist-primary-tag",
|
||||||
|
"Backdrop": "playlist-backdrop-tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
|
||||||
|
|
||||||
|
Assert.Equal("playlist-primary-tag", imageTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractImageTag_WithPrimaryImageTagFallback_ReturnsFallbackTag()
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse("""
|
||||||
|
{
|
||||||
|
"PrimaryImageTag": "primary-fallback-tag"
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
|
||||||
|
|
||||||
|
Assert.Equal("primary-fallback-tag", imageTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExtractImageTag_WithoutMatchingTag_ReturnsNull()
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse("""
|
||||||
|
{
|
||||||
|
"ImageTags": {
|
||||||
|
"Backdrop": "playlist-backdrop-tag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
|
||||||
|
|
||||||
|
Assert.Null(imageTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? InvokeExtractImageTag(JsonElement item, string imageType)
|
||||||
|
{
|
||||||
|
var method = typeof(JellyfinController).GetMethod(
|
||||||
|
"ExtractImageTag",
|
||||||
|
BindingFlags.Static | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
return (string?)method!.Invoke(null, new object?[] { item, imageType });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Domain;
|
||||||
using allstarr.Models.Search;
|
using allstarr.Models.Search;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
|
using allstarr.Services.Common;
|
||||||
using allstarr.Services.Jellyfin;
|
using allstarr.Services.Jellyfin;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -220,6 +221,35 @@ public class JellyfinModelMapperTests
|
|||||||
Assert.Equal("Main Artist", song.Artist);
|
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]
|
[Fact]
|
||||||
public void ParseAlbum_ExtractsArtistId_FromAlbumArtists()
|
public void ParseAlbum_ExtractsArtistId_FromAlbumArtists()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using allstarr.Controllers;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class JellyfinPlaylistRouteMatchingTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("playlists/abc123/items", "abc123")]
|
||||||
|
[InlineData("Playlists/abc123/Items", "abc123")]
|
||||||
|
[InlineData("/playlists/abc123/items/", "abc123")]
|
||||||
|
public void GetExactPlaylistItemsRequestId_ExactPlaylistItemsRoute_ReturnsPlaylistId(string path, string expectedPlaylistId)
|
||||||
|
{
|
||||||
|
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
|
||||||
|
|
||||||
|
Assert.Equal(expectedPlaylistId, playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("playlists/abc123/items/extra")]
|
||||||
|
[InlineData("users/user-1/playlists/abc123/items")]
|
||||||
|
[InlineData("items/abc123")]
|
||||||
|
[InlineData("playlists")]
|
||||||
|
public void GetExactPlaylistItemsRequestId_NonExactRoute_ReturnsNull(string path)
|
||||||
|
{
|
||||||
|
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
|
||||||
|
|
||||||
|
Assert.Null(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T InvokePrivateStatic<T>(string methodName, params object?[] args)
|
||||||
|
{
|
||||||
|
var method = typeof(JellyfinController).GetMethod(
|
||||||
|
methodName,
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
var result = method!.Invoke(null, args);
|
||||||
|
return (T)result!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -283,6 +283,197 @@ public class JellyfinProxyServiceTests
|
|||||||
Assert.Equal("DateCreated,PremiereDate,ProductionYear", query.Get("Fields"));
|
Assert.Equal("DateCreated,PremiereDate,ProductionYear", query.Get("Fields"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetJsonAsync_WithRepeatedFields_PreservesAllFieldParameters()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
HttpRequestMessage? captured = null;
|
||||||
|
_mockHandler.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||||
|
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("{\"Items\":[]}")
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _service.GetJsonAsync(
|
||||||
|
"Playlists/playlist-123/Items?Fields=Genres&Fields=DateCreated&Fields=MediaSources&UserId=user-abc");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
var query = captured!.RequestUri!.Query;
|
||||||
|
Assert.Contains("Fields=Genres", query);
|
||||||
|
Assert.Contains("Fields=DateCreated", query);
|
||||||
|
Assert.Contains("Fields=MediaSources", query);
|
||||||
|
Assert.Contains("UserId=user-abc", query);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPassthroughResponseAsync_WithRepeatedFields_PreservesAllFieldParameters()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
HttpRequestMessage? captured = null;
|
||||||
|
_mockHandler.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||||
|
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("{\"Items\":[]}")
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _service.GetPassthroughResponseAsync(
|
||||||
|
"Playlists/playlist-123/Items?Fields=Genres&Fields=DateCreated&Fields=MediaSources&UserId=user-abc");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
var query = captured!.RequestUri!.Query;
|
||||||
|
Assert.Contains("Fields=Genres", query);
|
||||||
|
Assert.Contains("Fields=DateCreated", query);
|
||||||
|
Assert.Contains("Fields=MediaSources", query);
|
||||||
|
Assert.Contains("UserId=user-abc", query);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPassthroughResponseAsync_WithClientAuth_ForwardsAuthHeader()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
HttpRequestMessage? captured = null;
|
||||||
|
_mockHandler.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||||
|
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("{\"Items\":[]}")
|
||||||
|
});
|
||||||
|
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _service.GetPassthroughResponseAsync(
|
||||||
|
"Playlists/playlist-123/Items?Fields=Genres",
|
||||||
|
headers);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.True(captured!.Headers.TryGetValues("X-Emby-Authorization", out var values));
|
||||||
|
Assert.Contains("MediaBrowser Token=\"abc\"", values);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendAsync_WithNoBody_PreservesEmptyRequestBody()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
HttpRequestMessage? captured = null;
|
||||||
|
_mockHandler.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
|
||||||
|
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||||
|
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var (_, statusCode) = await _service.SendAsync(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"Sessions/session-123/Playing/Pause?controllingUserId=user-123",
|
||||||
|
null,
|
||||||
|
headers);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(204, statusCode);
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal(HttpMethod.Post, captured!.Method);
|
||||||
|
Assert.Null(captured.Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendAsync_WithCustomContentType_PreservesOriginalType()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
HttpRequestMessage? captured = null;
|
||||||
|
_mockHandler.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
|
||||||
|
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||||
|
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _service.SendAsync(
|
||||||
|
HttpMethod.Put,
|
||||||
|
"Sessions/session-123/Command/DisplayMessage",
|
||||||
|
"{\"Text\":\"hello\"}",
|
||||||
|
headers,
|
||||||
|
"application/json; charset=utf-8");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.Equal(HttpMethod.Put, captured!.Method);
|
||||||
|
Assert.NotNull(captured.Content);
|
||||||
|
Assert.Equal("application/json; charset=utf-8", captured.Content!.Headers.ContentType!.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPassthroughResponseAsync_WithAcceptEncoding_ForwardsCompressionHeaders()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
HttpRequestMessage? captured = null;
|
||||||
|
_mockHandler.Protected()
|
||||||
|
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||||
|
ItExpr.IsAny<HttpRequestMessage>(),
|
||||||
|
ItExpr.IsAny<CancellationToken>())
|
||||||
|
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||||
|
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("{\"Items\":[]}")
|
||||||
|
});
|
||||||
|
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["Accept-Encoding"] = "gzip, br",
|
||||||
|
["User-Agent"] = "Finamp/1.0",
|
||||||
|
["Accept-Language"] = "en-US"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var response = await _service.GetPassthroughResponseAsync(
|
||||||
|
"Playlists/playlist-123/Items?Fields=Genres",
|
||||||
|
headers);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.True(captured!.Headers.TryGetValues("Accept-Encoding", out var encodings));
|
||||||
|
Assert.Contains("gzip", encodings);
|
||||||
|
Assert.Contains("br", encodings);
|
||||||
|
Assert.True(captured.Headers.TryGetValues("User-Agent", out var userAgents));
|
||||||
|
Assert.Contains("Finamp/1.0", userAgents);
|
||||||
|
Assert.True(captured.Headers.TryGetValues("Accept-Language", out var languages));
|
||||||
|
Assert.Contains("en-US", languages);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetJsonAsync_WithEndpointAndExplicitQuery_MergesWithExplicitPrecedence()
|
public async Task GetJsonAsync_WithEndpointAndExplicitQuery_MergesWithExplicitPrecedence()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ public class JellyfinResponseBuilderTests
|
|||||||
Assert.Equal(1, result["ParentIndexNumber"]);
|
Assert.Equal(1, result["ParentIndexNumber"]);
|
||||||
Assert.Equal(2023, result["ProductionYear"]);
|
Assert.Equal(2023, result["ProductionYear"]);
|
||||||
Assert.Equal(245 * TimeSpan.TicksPerSecond, result["RunTimeTicks"]);
|
Assert.Equal(245 * TimeSpan.TicksPerSecond, result["RunTimeTicks"]);
|
||||||
|
Assert.NotNull(result["AudioInfo"]);
|
||||||
|
Assert.Equal(false, result["CanDelete"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -192,6 +194,9 @@ public class JellyfinResponseBuilderTests
|
|||||||
Assert.Equal("Famous Band", result["AlbumArtist"]);
|
Assert.Equal("Famous Band", result["AlbumArtist"]);
|
||||||
Assert.Equal(2020, result["ProductionYear"]);
|
Assert.Equal(2020, result["ProductionYear"]);
|
||||||
Assert.Equal(12, result["ChildCount"]);
|
Assert.Equal(12, result["ChildCount"]);
|
||||||
|
Assert.Equal("Greatest Hits", result["SortName"]);
|
||||||
|
Assert.NotNull(result["DateCreated"]);
|
||||||
|
Assert.NotNull(result["BasicSyncInfo"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -215,6 +220,9 @@ public class JellyfinResponseBuilderTests
|
|||||||
Assert.Equal("MusicArtist", result["Type"]);
|
Assert.Equal("MusicArtist", result["Type"]);
|
||||||
Assert.Equal(true, result["IsFolder"]);
|
Assert.Equal(true, result["IsFolder"]);
|
||||||
Assert.Equal(5, result["AlbumCount"]);
|
Assert.Equal(5, result["AlbumCount"]);
|
||||||
|
Assert.Equal("The Rockers", result["SortName"]);
|
||||||
|
Assert.Equal(1.0, result["PrimaryImageAspectRatio"]);
|
||||||
|
Assert.NotNull(result["BasicSyncInfo"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -243,6 +251,9 @@ public class JellyfinResponseBuilderTests
|
|||||||
Assert.Equal("DJ Cool", result["AlbumArtist"]);
|
Assert.Equal("DJ Cool", result["AlbumArtist"]);
|
||||||
Assert.Equal(50, result["ChildCount"]);
|
Assert.Equal(50, result["ChildCount"]);
|
||||||
Assert.Equal(2023, result["ProductionYear"]);
|
Assert.Equal(2023, result["ProductionYear"]);
|
||||||
|
Assert.Equal("Summer Vibes [S/P]", result["SortName"]);
|
||||||
|
Assert.NotNull(result["DateCreated"]);
|
||||||
|
Assert.NotNull(result["BasicSyncInfo"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using allstarr.Controllers;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class JellyfinSearchInterleaveTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void InterleaveByScore_PrimaryOnly_PreservesOriginalOrder()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var primary = new List<Dictionary<string, object?>>
|
||||||
|
{
|
||||||
|
CreateItem("zzz filler"),
|
||||||
|
CreateItem("BTS Anthem")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = InvokeInterleaveByScore(controller, primary, [], "bts", 5.0);
|
||||||
|
|
||||||
|
Assert.Equal(["zzz filler", "BTS Anthem"], result.Select(GetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InterleaveByScore_SecondaryOnly_PreservesOriginalOrder()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var secondary = new List<Dictionary<string, object?>>
|
||||||
|
{
|
||||||
|
CreateItem("zzz filler"),
|
||||||
|
CreateItem("BTS Anthem")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = InvokeInterleaveByScore(controller, [], secondary, "bts", 5.0);
|
||||||
|
|
||||||
|
Assert.Equal(["zzz filler", "BTS Anthem"], result.Select(GetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InterleaveByScore_StrongerHeadMatch_LeadsWithoutReorderingSource()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var primary = new List<Dictionary<string, object?>>
|
||||||
|
{
|
||||||
|
CreateItem("luther remastered"),
|
||||||
|
CreateItem("zzz filler")
|
||||||
|
};
|
||||||
|
var secondary = new List<Dictionary<string, object?>>
|
||||||
|
{
|
||||||
|
CreateItem("luther"),
|
||||||
|
CreateItem("yyy filler")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = InvokeInterleaveByScore(controller, primary, secondary, "luther", 0.0);
|
||||||
|
|
||||||
|
Assert.Equal(["luther", "luther remastered", "zzz filler", "yyy filler"], result.Select(GetName));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InterleaveByScore_TiedScores_PreferPrimaryQueueHead()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var primary = new List<Dictionary<string, object?>>
|
||||||
|
{
|
||||||
|
CreateItem("bts", "p1"),
|
||||||
|
CreateItem("bts", "p2")
|
||||||
|
};
|
||||||
|
var secondary = new List<Dictionary<string, object?>>
|
||||||
|
{
|
||||||
|
CreateItem("bts", "s1"),
|
||||||
|
CreateItem("bts", "s2")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = InvokeInterleaveByScore(controller, primary, secondary, "bts", 0.0);
|
||||||
|
|
||||||
|
Assert.Equal(["p1", "p2", "s1", "s2"], result.Select(GetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InterleaveByScore_StrongerLaterPrimaryHead_DoesNotBypassCurrentQueueHead()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var primary = new List<Dictionary<string, object?>>
|
||||||
|
{
|
||||||
|
CreateItem("zzz filler", "p1"),
|
||||||
|
CreateItem("bts local later", "p2")
|
||||||
|
};
|
||||||
|
var secondary = new List<Dictionary<string, object?>>
|
||||||
|
{
|
||||||
|
CreateItem("bts", "s1"),
|
||||||
|
CreateItem("bts live", "s2")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = InvokeInterleaveByScore(controller, primary, secondary, "bts", 0.0);
|
||||||
|
|
||||||
|
Assert.Equal(["s1", "s2", "p1", "p2"], result.Select(GetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InterleaveByScore_JellyfinBoost_CanWinCloseHeadToHead()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var primary = new List<Dictionary<string, object?>>
|
||||||
|
{
|
||||||
|
CreateItem("luther remastered", "p1")
|
||||||
|
};
|
||||||
|
var secondary = new List<Dictionary<string, object?>>
|
||||||
|
{
|
||||||
|
CreateItem("luther", "s1")
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = InvokeInterleaveByScore(controller, primary, secondary, "luther", 5.0);
|
||||||
|
|
||||||
|
Assert.Equal(["p1", "s1"], result.Select(GetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CalculateItemRelevanceScore_SongUsesArtistContext()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var withArtist = CreateTypedItem("Audio", "cardigan", "song-with-artist");
|
||||||
|
withArtist["Artists"] = new[] { "Taylor Swift" };
|
||||||
|
|
||||||
|
var withoutArtist = CreateTypedItem("Audio", "cardigan", "song-without-artist");
|
||||||
|
|
||||||
|
var withArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withArtist);
|
||||||
|
var withoutArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withoutArtist);
|
||||||
|
|
||||||
|
Assert.True(withArtistScore > withoutArtistScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CalculateItemRelevanceScore_AlbumUsesArtistContext()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var withArtist = CreateTypedItem("MusicAlbum", "folklore", "album-with-artist");
|
||||||
|
withArtist["AlbumArtist"] = "Taylor Swift";
|
||||||
|
|
||||||
|
var withoutArtist = CreateTypedItem("MusicAlbum", "folklore", "album-without-artist");
|
||||||
|
|
||||||
|
var withArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withArtist);
|
||||||
|
var withoutArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withoutArtist);
|
||||||
|
|
||||||
|
Assert.True(withArtistScore > withoutArtistScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CalculateItemRelevanceScore_ArtistIgnoresNonNameMetadata()
|
||||||
|
{
|
||||||
|
var controller = CreateController();
|
||||||
|
var plainArtist = CreateTypedItem("MusicArtist", "Taylor Swift", "artist-plain");
|
||||||
|
var noisyArtist = CreateTypedItem("MusicArtist", "Taylor Swift", "artist-noisy");
|
||||||
|
noisyArtist["AlbumArtist"] = "Completely Different";
|
||||||
|
noisyArtist["Artists"] = new[] { "Someone Else" };
|
||||||
|
|
||||||
|
var plainScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", plainArtist);
|
||||||
|
var noisyScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", noisyArtist);
|
||||||
|
|
||||||
|
Assert.Equal(plainScore, noisyScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JellyfinController CreateController()
|
||||||
|
{
|
||||||
|
return (JellyfinController)RuntimeHelpers.GetUninitializedObject(typeof(JellyfinController));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Dictionary<string, object?>> InvokeInterleaveByScore(
|
||||||
|
JellyfinController controller,
|
||||||
|
List<Dictionary<string, object?>> primary,
|
||||||
|
List<Dictionary<string, object?>> secondary,
|
||||||
|
string query,
|
||||||
|
double primaryBoost)
|
||||||
|
{
|
||||||
|
var method = typeof(JellyfinController).GetMethod(
|
||||||
|
"InterleaveByScore",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
|
||||||
|
return (List<Dictionary<string, object?>>)method!.Invoke(
|
||||||
|
controller,
|
||||||
|
[primary, secondary, query, primaryBoost])!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double InvokeCalculateItemRelevanceScore(
|
||||||
|
JellyfinController controller,
|
||||||
|
string query,
|
||||||
|
Dictionary<string, object?> item)
|
||||||
|
{
|
||||||
|
var method = typeof(JellyfinController).GetMethod(
|
||||||
|
"CalculateItemRelevanceScore",
|
||||||
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
|
||||||
|
return (double)method!.Invoke(controller, [query, item])!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> CreateItem(string name, string? id = null)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Name"] = name,
|
||||||
|
["Id"] = id ?? name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> CreateTypedItem(string type, string name, string id)
|
||||||
|
{
|
||||||
|
var item = CreateItem(name, id);
|
||||||
|
item["Type"] = type;
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetName(Dictionary<string, object?> item)
|
||||||
|
{
|
||||||
|
return item["Name"]?.ToString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetId(Dictionary<string, object?> item)
|
||||||
|
{
|
||||||
|
return item["Id"]?.ToString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using allstarr.Controllers;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class JellyfinSearchResponseSerializationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SerializeSearchResponseJson_PreservesPascalCaseShape()
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
Items = new[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Name"] = "BTS",
|
||||||
|
["Type"] = "MusicAlbum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TotalRecordCount = 1,
|
||||||
|
StartIndex = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
var method = typeof(JellyfinController).GetMethod(
|
||||||
|
"SerializeSearchResponseJson",
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
|
||||||
|
var closedMethod = method!.MakeGenericMethod(payload.GetType());
|
||||||
|
var json = (string)closedMethod.Invoke(null, new object?[] { payload })!;
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}",
|
||||||
|
json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,87 @@ public class JellyfinSessionManagerTests
|
|||||||
Assert.DoesNotContain("/Sessions/Logout", requestedPaths);
|
Assert.DoesNotContain("/Sessions/Logout", requestedPaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetActivePlaybackStates_ReturnsTrackedPlayingItems()
|
||||||
|
{
|
||||||
|
var handler = new DelegateHttpMessageHandler((_, _) =>
|
||||||
|
Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)));
|
||||||
|
|
||||||
|
var settings = new JellyfinSettings
|
||||||
|
{
|
||||||
|
Url = "http://127.0.0.1:1",
|
||||||
|
ApiKey = "server-api-key",
|
||||||
|
ClientName = "Allstarr",
|
||||||
|
DeviceName = "Allstarr",
|
||||||
|
DeviceId = "allstarr",
|
||||||
|
ClientVersion = "1.0"
|
||||||
|
};
|
||||||
|
|
||||||
|
var proxyService = CreateProxyService(handler, settings);
|
||||||
|
using var manager = new JellyfinSessionManager(
|
||||||
|
proxyService,
|
||||||
|
Options.Create(settings),
|
||||||
|
NullLogger<JellyfinSessionManager>.Instance);
|
||||||
|
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["X-Emby-Authorization"] =
|
||||||
|
"MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
|
||||||
|
};
|
||||||
|
|
||||||
|
var ensured = await manager.EnsureSessionAsync("dev-123", "Feishin", "Desktop", "1.0", headers);
|
||||||
|
Assert.True(ensured);
|
||||||
|
|
||||||
|
manager.UpdatePlayingItem("dev-123", "ext-squidwtf-song-35734823", 45 * TimeSpan.TicksPerSecond);
|
||||||
|
|
||||||
|
var states = manager.GetActivePlaybackStates(TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
|
var state = Assert.Single(states);
|
||||||
|
Assert.Equal("dev-123", state.DeviceId);
|
||||||
|
Assert.Equal("ext-squidwtf-song-35734823", state.ItemId);
|
||||||
|
Assert.Equal(45 * TimeSpan.TicksPerSecond, state.PositionTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureSessionAsync_WithProxiedWebSocket_DoesNotPostSyntheticCapabilities()
|
||||||
|
{
|
||||||
|
var requestedPaths = new ConcurrentBag<string>();
|
||||||
|
var handler = new DelegateHttpMessageHandler((request, _) =>
|
||||||
|
{
|
||||||
|
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||||
|
});
|
||||||
|
|
||||||
|
var settings = new JellyfinSettings
|
||||||
|
{
|
||||||
|
Url = "http://127.0.0.1:1",
|
||||||
|
ApiKey = "server-api-key",
|
||||||
|
ClientName = "Allstarr",
|
||||||
|
DeviceName = "Allstarr",
|
||||||
|
DeviceId = "allstarr",
|
||||||
|
ClientVersion = "1.0"
|
||||||
|
};
|
||||||
|
|
||||||
|
var proxyService = CreateProxyService(handler, settings);
|
||||||
|
using var manager = new JellyfinSessionManager(
|
||||||
|
proxyService,
|
||||||
|
Options.Create(settings),
|
||||||
|
NullLogger<JellyfinSessionManager>.Instance);
|
||||||
|
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["X-Emby-Authorization"] =
|
||||||
|
"MediaBrowser Client=\"Finamp\", Device=\"Android Auto\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
|
||||||
|
};
|
||||||
|
|
||||||
|
await manager.RegisterProxiedWebSocketAsync("dev-123");
|
||||||
|
|
||||||
|
var ensured = await manager.EnsureSessionAsync("dev-123", "Finamp", "Android Auto", "1.0", headers);
|
||||||
|
|
||||||
|
Assert.True(ensured);
|
||||||
|
Assert.DoesNotContain("/Sessions/Capabilities/Full", requestedPaths);
|
||||||
|
}
|
||||||
|
|
||||||
private static JellyfinProxyService CreateProxyService(HttpMessageHandler handler, JellyfinSettings settings)
|
private static JellyfinProxyService CreateProxyService(HttpMessageHandler handler, JellyfinSettings settings)
|
||||||
{
|
{
|
||||||
var httpClientFactory = new TestHttpClientFactory(handler);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,6 +157,31 @@ public class SpotifyApiClientTests
|
|||||||
Assert.Equal(new DateTime(2026, 2, 16, 5, 0, 0, DateTimeKind.Utc), track.AddedAt);
|
Assert.Equal(new DateTime(2026, 2, 16, 5, 0, 0, DateTimeKind.Utc), track.AddedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetSpotifyPlaylistItemCount_ParsesAttributesArrayEntries()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var doc = JsonDocument.Parse("""
|
||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{ "key": "core:item_count", "value": "42" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var method = typeof(SpotifyApiClient).GetMethod(
|
||||||
|
"TryGetSpotifyPlaylistItemCount",
|
||||||
|
BindingFlags.Static | BindingFlags.NonPublic);
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = (int)method!.Invoke(null, new object?[] { doc.RootElement })!;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(42, result);
|
||||||
|
}
|
||||||
|
|
||||||
private static T InvokePrivateMethod<T>(object instance, string methodName, params object?[] args)
|
private static T InvokePrivateMethod<T>(object instance, string methodName, params object?[] args)
|
||||||
{
|
{
|
||||||
var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
|
var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
|||||||
@@ -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);
|
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)
|
private SquidWTFDownloadService CreateService(HttpMessageHandler handler, string quality)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -299,6 +299,65 @@ public class SquidWTFMetadataServiceTests
|
|||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SearchAllAsync_WithZeroLimits_SkipsUnusedBuckets()
|
||||||
|
{
|
||||||
|
var requestKinds = new List<string>();
|
||||||
|
var handler = new StubHttpMessageHandler(request =>
|
||||||
|
{
|
||||||
|
var trackQuery = GetQueryParameter(request.RequestUri!, "s");
|
||||||
|
var albumQuery = GetQueryParameter(request.RequestUri!, "al");
|
||||||
|
var artistQuery = GetQueryParameter(request.RequestUri!, "a");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(trackQuery))
|
||||||
|
{
|
||||||
|
requestKinds.Add("song");
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(1, "Song", "USRC12345678")))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(albumQuery))
|
||||||
|
{
|
||||||
|
requestKinds.Add("album");
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(CreateAlbumSearchResponse())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(artistQuery))
|
||||||
|
{
|
||||||
|
requestKinds.Add("artist");
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(CreateArtistSearchResponse())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}");
|
||||||
|
});
|
||||||
|
|
||||||
|
var httpClient = new HttpClient(handler);
|
||||||
|
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||||
|
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
new List<string> { "https://test1.example.com" });
|
||||||
|
|
||||||
|
var result = await service.SearchAllAsync("OK Computer", 0, 5, 0);
|
||||||
|
|
||||||
|
Assert.Empty(result.Songs);
|
||||||
|
Assert.Single(result.Albums);
|
||||||
|
Assert.Empty(result.Artists);
|
||||||
|
Assert.Equal(new[] { "album" }, requestKinds);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ExplicitFilter_RespectsSettings()
|
public void ExplicitFilter_RespectsSettings()
|
||||||
{
|
{
|
||||||
@@ -508,6 +567,278 @@ public class SquidWTFMetadataServiceTests
|
|||||||
Assert.Equal(1, song.ExplicitContentLyrics);
|
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]
|
[Fact]
|
||||||
public void BuildSearchQueryVariants_WithAmpersand_AddsAndVariant()
|
public void BuildSearchQueryVariants_WithAmpersand_AddsAndVariant()
|
||||||
{
|
{
|
||||||
@@ -727,6 +1058,242 @@ public class SquidWTFMetadataServiceTests
|
|||||||
return (T)result!;
|
return (T)result!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string CreateTrackSearchResponse(object trackPayload)
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["version"] = "2.6",
|
||||||
|
["data"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["limit"] = 25,
|
||||||
|
["offset"] = 0,
|
||||||
|
["totalNumberOfItems"] = 1,
|
||||||
|
["items"] = new[] { trackPayload }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateAlbumSearchResponse()
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["version"] = "2.6",
|
||||||
|
["data"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["albums"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["limit"] = 25,
|
||||||
|
["offset"] = 0,
|
||||||
|
["totalNumberOfItems"] = 1,
|
||||||
|
["items"] = new[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = 58990510,
|
||||||
|
["title"] = "OK Computer",
|
||||||
|
["numberOfTracks"] = 12,
|
||||||
|
["cover"] = "e77e4cc0-6cd0-4522-807d-88aeac488065",
|
||||||
|
["artist"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = 64518,
|
||||||
|
["name"] = "Radiohead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateArtistSearchResponse()
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["version"] = "2.6",
|
||||||
|
["data"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["artists"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["limit"] = 25,
|
||||||
|
["offset"] = 0,
|
||||||
|
["totalNumberOfItems"] = 1,
|
||||||
|
["items"] = new[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = 8812,
|
||||||
|
["name"] = "Coldplay",
|
||||||
|
["picture"] = "b4579672-5b91-4679-a27a-288f097a4da5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreatePlaylistSearchResponse()
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["version"] = "2.6",
|
||||||
|
["data"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["playlists"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["limit"] = 25,
|
||||||
|
["offset"] = 0,
|
||||||
|
["totalNumberOfItems"] = 1,
|
||||||
|
["items"] = new[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["uuid"] = "playlist123",
|
||||||
|
["title"] = "Jazz Essentials",
|
||||||
|
["creator"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = 0
|
||||||
|
},
|
||||||
|
["numberOfTracks"] = 1,
|
||||||
|
["duration"] = 180,
|
||||||
|
["squareImage"] = "b15bb487-dd6e-45ff-9e50-ee5083f20669"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateAlbumPageResponse(int offset, int count, int totalTracks)
|
||||||
|
{
|
||||||
|
var items = Enumerable.Range(offset + 1, count)
|
||||||
|
.Select(index => (object)new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["item"] = CreateTrackPayload(
|
||||||
|
index,
|
||||||
|
$"Album Track {index}",
|
||||||
|
$"USRC{index:00000000}",
|
||||||
|
albumTitle: "Paginated Album",
|
||||||
|
albumId: 58990510)
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["version"] = "2.6",
|
||||||
|
["data"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = 58990510,
|
||||||
|
["title"] = "Paginated Album",
|
||||||
|
["numberOfTracks"] = totalTracks,
|
||||||
|
["cover"] = "e77e4cc0-6cd0-4522-807d-88aeac488065",
|
||||||
|
["artist"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = 64518,
|
||||||
|
["name"] = "Radiohead"
|
||||||
|
},
|
||||||
|
["items"] = items
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreatePlaylistPageResponse(int offset, int count, int totalTracks)
|
||||||
|
{
|
||||||
|
var items = Enumerable.Range(offset + 1, count)
|
||||||
|
.Select(index => (object)new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["item"] = CreateTrackPayload(
|
||||||
|
index,
|
||||||
|
$"Playlist Track {index}",
|
||||||
|
$"GBARL{index:0000000}",
|
||||||
|
artistName: "Mark Ronson",
|
||||||
|
artistId: 8722,
|
||||||
|
albumTitle: "Uptown Special",
|
||||||
|
albumId: 39249709)
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["version"] = "2.6",
|
||||||
|
["playlist"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["uuid"] = "playlist123",
|
||||||
|
["title"] = "Big Playlist",
|
||||||
|
["creator"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = 0
|
||||||
|
},
|
||||||
|
["numberOfTracks"] = totalTracks,
|
||||||
|
["duration"] = totalTracks * 180,
|
||||||
|
["squareImage"] = "b15bb487-dd6e-45ff-9e50-ee5083f20669"
|
||||||
|
},
|
||||||
|
["items"] = items
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> CreateTrackPayload(
|
||||||
|
int id,
|
||||||
|
string title,
|
||||||
|
string isrc,
|
||||||
|
string artistName = "Artist",
|
||||||
|
int artistId = 1,
|
||||||
|
string albumTitle = "Album",
|
||||||
|
int albumId = 10)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = id,
|
||||||
|
["title"] = title,
|
||||||
|
["duration"] = 180,
|
||||||
|
["trackNumber"] = (id % 12) + 1,
|
||||||
|
["volumeNumber"] = 1,
|
||||||
|
["explicit"] = false,
|
||||||
|
["isrc"] = isrc,
|
||||||
|
["artist"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = artistId,
|
||||||
|
["name"] = artistName
|
||||||
|
},
|
||||||
|
["artists"] = new object[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = artistId,
|
||||||
|
["name"] = artistName
|
||||||
|
}
|
||||||
|
},
|
||||||
|
["album"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["id"] = albumId,
|
||||||
|
["title"] = albumTitle,
|
||||||
|
["cover"] = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetQueryParameter(Uri uri, string name)
|
||||||
|
{
|
||||||
|
var query = uri.Query.TrimStart('?');
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var parts = pair.Split('=', 2);
|
||||||
|
var key = Uri.UnescapeDataString(parts[0]);
|
||||||
|
if (!key.Equals(name, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
|
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Current application version.
|
/// Current application version.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string Version = "1.3.3";
|
public const string Version = "1.5.0";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,17 +198,20 @@ public class ConfigController : ControllerBase
|
|||||||
{
|
{
|
||||||
arl = AdminHelperService.MaskValue(GetEnvString(envVars, "DEEZER_ARL", _deezerSettings.Arl ?? string.Empty), showLast: 8),
|
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),
|
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
|
qobuz = new
|
||||||
{
|
{
|
||||||
userAuthToken = AdminHelperService.MaskValue(GetEnvString(envVars, "QOBUZ_USER_AUTH_TOKEN", _qobuzSettings.UserAuthToken ?? string.Empty), showLast: 8),
|
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),
|
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
|
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
|
musicBrainz = new
|
||||||
{
|
{
|
||||||
@@ -228,7 +231,8 @@ public class ConfigController : ControllerBase
|
|||||||
genreDays = GetEnvInt(envVars, "CACHE_GENRE_DAYS", _configuration.GetValue<int>("Cache:GenreDays", 30)),
|
genreDays = GetEnvInt(envVars, "CACHE_GENRE_DAYS", _configuration.GetValue<int>("Cache:GenreDays", 30)),
|
||||||
metadataDays = GetEnvInt(envVars, "CACHE_METADATA_DAYS", _configuration.GetValue<int>("Cache:MetadataDays", 7)),
|
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)),
|
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()
|
scrobbling = await GetScrobblingSettingsFromEnvAsync()
|
||||||
});
|
});
|
||||||
@@ -470,70 +474,101 @@ public class ConfigController : ControllerBase
|
|||||||
_logger.LogWarning(".env file not found at {Path}, creating new file", _helperService.GetEnvFilePath());
|
_logger.LogWarning(".env file not found at {Path}, creating new file", _helperService.GetEnvFilePath());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read current .env file or create new one
|
var envFilePath = _helperService.GetEnvFilePath();
|
||||||
var envContent = new Dictionary<string, string>();
|
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());
|
envLines = (await System.IO.File.ReadAllLinesAsync(envFilePath)).ToList();
|
||||||
foreach (var line in lines)
|
}
|
||||||
|
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('#'))
|
examplePath = Path.Combine(Directory.GetParent(Directory.GetCurrentDirectory())?.FullName ?? "", ".env.example");
|
||||||
continue;
|
}
|
||||||
|
|
||||||
var eqIndex = line.IndexOf('=');
|
if (System.IO.File.Exists(examplePath))
|
||||||
if (eqIndex > 0)
|
{
|
||||||
{
|
_logger.LogInformation("Creating new .env from .env.example to preserve formatting");
|
||||||
var key = line[..eqIndex].Trim();
|
envLines = (await System.IO.File.ReadAllLinesAsync(examplePath)).ToList();
|
||||||
var value = line[(eqIndex + 1)..].Trim();
|
|
||||||
|
|
||||||
// Remove surrounding quotes if present (for proper re-quoting)
|
|
||||||
if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2)
|
|
||||||
{
|
|
||||||
value = value[1..^1];
|
|
||||||
}
|
|
||||||
|
|
||||||
envContent[key] = value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_logger.LogDebug("Loaded {Count} existing env vars from {Path}", envContent.Count, _helperService.GetEnvFilePath());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply updates with validation
|
// Apply updates with validation
|
||||||
var appliedUpdates = new List<string>();
|
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))
|
if (!AdminHelperService.IsValidEnvKey(key))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Invalid env key rejected: {Key}", key);
|
_logger.LogWarning("Invalid env key rejected: {Key}", key);
|
||||||
return BadRequest(new { error = $"Invalid environment variable 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);
|
appliedUpdates.Add(key);
|
||||||
_logger.LogInformation(" Setting {Key} = {Value}", key,
|
|
||||||
key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") || key.Contains("PASSWORD")
|
var maskedValue = key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") || key.Contains("PASSWORD")
|
||||||
? "***" + (value.Length > 8 ? value[^8..] : "")
|
? "***" + (value.Length > 8 ? value[^8..] : "")
|
||||||
: value);
|
: value;
|
||||||
|
_logger.LogInformation(" Setting {Key} = {Value}", key, maskedValue);
|
||||||
|
|
||||||
// Auto-set cookie date when Spotify session cookie is updated
|
var keyPrefix = $"{key}=";
|
||||||
if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value))
|
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 trimmedLine = envLines[i].TrimStart();
|
||||||
var dateValue = DateTime.UtcNow.ToString("o"); // ISO 8601 format
|
if (trimmedLine.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
envContent[dateKey] = dateValue;
|
{
|
||||||
appliedUpdates.Add(dateKey);
|
envLines[i] = $"{key}={value}";
|
||||||
_logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue);
|
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)
|
await System.IO.File.WriteAllLinesAsync(envFilePath, envLines);
|
||||||
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
|
|
||||||
await System.IO.File.WriteAllTextAsync(_helperService.GetEnvFilePath(), newContent + "\n");
|
|
||||||
|
|
||||||
_logger.LogDebug("Config file updated successfully at {Path}", _helperService.GetEnvFilePath());
|
_logger.LogDebug("Config file updated successfully at {Path}", _helperService.GetEnvFilePath());
|
||||||
|
|
||||||
@@ -545,7 +580,7 @@ public class ConfigController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
message = "Configuration updated. Restart container to apply changes.",
|
message = "Configuration updated. Restart Allstarr to apply changes.",
|
||||||
updatedKeys = appliedUpdates,
|
updatedKeys = appliedUpdates,
|
||||||
requiresRestart = true,
|
requiresRestart = true,
|
||||||
envFilePath = _helperService.GetEnvFilePath()
|
envFilePath = _helperService.GetEnvFilePath()
|
||||||
@@ -661,7 +696,7 @@ public class ConfigController : ControllerBase
|
|||||||
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
|
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
|
||||||
return StatusCode(503, new {
|
return StatusCode(503, new {
|
||||||
error = "Docker socket not available",
|
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);
|
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||||
return StatusCode((int)response.StatusCode, new {
|
return StatusCode((int)response.StatusCode, new {
|
||||||
error = "Failed to restart container",
|
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");
|
_logger.LogError(ex, "Error restarting container");
|
||||||
return StatusCode(500, new {
|
return StatusCode(500, new {
|
||||||
error = "Failed to restart container",
|
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
|
return Ok(new
|
||||||
{
|
{
|
||||||
success = true,
|
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)
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,56 @@ public class DownloadsController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DELETE /api/admin/downloads/all
|
||||||
|
/// Deletes all kept audio files and removes empty folders
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("downloads/all")]
|
||||||
|
public IActionResult DeleteAllDownloads()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var keptPath = Path.GetFullPath(Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"));
|
||||||
|
if (!Directory.Exists(keptPath))
|
||||||
|
{
|
||||||
|
return Ok(new { success = true, deletedCount = 0, message = "No kept downloads found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
||||||
|
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
||||||
|
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var filePath in allFiles)
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty directories under kept root (deepest first)
|
||||||
|
var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories)
|
||||||
|
.OrderByDescending(d => d.Length);
|
||||||
|
foreach (var directory in allDirectories)
|
||||||
|
{
|
||||||
|
if (!Directory.EnumerateFileSystemEntries(directory).Any())
|
||||||
|
{
|
||||||
|
Directory.Delete(directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
deletedCount = allFiles.Count,
|
||||||
|
message = $"Deleted {allFiles.Count} kept download(s)"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to delete all kept downloads");
|
||||||
|
return StatusCode(500, new { error = "Failed to delete all kept downloads" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// GET /api/admin/downloads/file
|
/// GET /api/admin/downloads/file
|
||||||
/// Downloads a specific file from the kept folder
|
/// Downloads a specific file from the kept folder
|
||||||
|
|||||||
+170
-48
@@ -1,9 +1,11 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Net.Http;
|
||||||
using allstarr.Models.Domain;
|
using allstarr.Models.Domain;
|
||||||
using allstarr.Models.Spotify;
|
using allstarr.Models.Spotify;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
|
||||||
namespace allstarr.Controllers;
|
namespace allstarr.Controllers;
|
||||||
|
|
||||||
@@ -11,6 +13,20 @@ public partial class JellyfinController
|
|||||||
{
|
{
|
||||||
#region Helpers
|
#region Helpers
|
||||||
|
|
||||||
|
private static readonly HashSet<string> PassthroughResponseHeadersToSkip = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"Connection",
|
||||||
|
"Keep-Alive",
|
||||||
|
"Proxy-Authenticate",
|
||||||
|
"Proxy-Authorization",
|
||||||
|
"TE",
|
||||||
|
"Trailer",
|
||||||
|
"Transfer-Encoding",
|
||||||
|
"Upgrade",
|
||||||
|
"Content-Type",
|
||||||
|
"Content-Length"
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper to handle proxy responses with proper status code handling.
|
/// Helper to handle proxy responses with proper status code handling.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -48,6 +64,60 @@ public partial class JellyfinController
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> ProxyJsonPassthroughAsync(string endpoint)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Match the previous proxy semantics for client compatibility.
|
||||||
|
// Some Jellyfin clients/proxies cancel the ASP.NET request token aggressively
|
||||||
|
// even though the upstream request would still complete successfully.
|
||||||
|
var upstreamResponse = await _proxyService.GetPassthroughResponseAsync(
|
||||||
|
endpoint,
|
||||||
|
Request.Headers);
|
||||||
|
|
||||||
|
HttpContext.Response.RegisterForDispose(upstreamResponse);
|
||||||
|
HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
|
||||||
|
Response.StatusCode = (int)upstreamResponse.StatusCode;
|
||||||
|
Response.Headers["X-Accel-Buffering"] = "no";
|
||||||
|
|
||||||
|
CopyPassthroughResponseHeaders(upstreamResponse);
|
||||||
|
|
||||||
|
if (upstreamResponse.Content.Headers.ContentLength.HasValue)
|
||||||
|
{
|
||||||
|
Response.ContentLength = upstreamResponse.Content.Headers.ContentLength.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentType = upstreamResponse.Content.Headers.ContentType?.ToString() ?? "application/json";
|
||||||
|
var stream = await upstreamResponse.Content.ReadAsStreamAsync();
|
||||||
|
|
||||||
|
return new FileStreamResult(stream, contentType);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to transparently proxy Jellyfin request for {Endpoint}", endpoint);
|
||||||
|
return StatusCode(502, new { error = "Failed to connect to Jellyfin server" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CopyPassthroughResponseHeaders(HttpResponseMessage upstreamResponse)
|
||||||
|
{
|
||||||
|
foreach (var header in upstreamResponse.Headers)
|
||||||
|
{
|
||||||
|
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
|
||||||
|
{
|
||||||
|
Response.Headers[header.Key] = header.Value.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var header in upstreamResponse.Content.Headers)
|
||||||
|
{
|
||||||
|
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
|
||||||
|
{
|
||||||
|
Response.Headers[header.Key] = header.Value.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
|
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -129,35 +199,46 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try loading from file cache if Redis is empty
|
// Prefer the currently served playlist items cache when available.
|
||||||
if (matchedTracks == null || matchedTracks.Count == 0)
|
// 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);
|
exactServedCount = cachedPlaylistItems.Count;
|
||||||
if (fileItems != null && fileItems.Count > 0)
|
exactServedRunTimeTicks =
|
||||||
{
|
SpotifyPlaylistCountHelper.SumCachedPlaylistRunTimeTicks(cachedPlaylistItems);
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"💿 Loaded {Count} playlist items from file cache for count update",
|
"Using Redis playlist items cache metrics for {Playlist}: count={Count}, runtimeTicks={RunTimeTicks}",
|
||||||
fileItems.Count);
|
playlistName, exactServedCount, exactServedRunTimeTicks);
|
||||||
// Use file cache count directly
|
|
||||||
itemDict["ChildCount"] = fileItems.Count;
|
|
||||||
modified = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only fetch from Jellyfin if we didn't get count from file cache
|
if (exactServedCount > 0)
|
||||||
if (!itemDict.ContainsKey("ChildCount") ||
|
|
||||||
(itemDict["ChildCount"] is JsonElement childCountElement &&
|
|
||||||
childCountElement.GetInt32() == 0) ||
|
|
||||||
(itemDict["ChildCount"] is int childCountInt && childCountInt == 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 localTracksCount = 0;
|
||||||
|
var localRunTimeTicks = 0L;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Include UserId parameter to avoid 401 Unauthorized
|
|
||||||
var userId = _settings.UserId;
|
var userId = _settings.UserId;
|
||||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
|
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))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
queryParams["UserId"] = userId;
|
queryParams["UserId"] = userId;
|
||||||
@@ -170,8 +251,16 @@ public partial class JellyfinController
|
|||||||
if (localTracksResponse != null &&
|
if (localTracksResponse != null &&
|
||||||
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
||||||
{
|
{
|
||||||
localTracksCount = localItems.GetArrayLength();
|
foreach (var localItem in localItems.EnumerateArray())
|
||||||
_logger.LogDebug("Found {Count} total items in Jellyfin playlist {Name}",
|
{
|
||||||
|
localTracksCount++;
|
||||||
|
localRunTimeTicks += SpotifyPlaylistCountHelper.ExtractRunTimeTicks(
|
||||||
|
localItem.TryGetProperty("RunTimeTicks", out var runTimeTicks)
|
||||||
|
? runTimeTicks
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Found {Count} local Jellyfin items in playlist {Name}",
|
||||||
localTracksCount, playlistName);
|
localTracksCount, playlistName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,33 +269,25 @@ public partial class JellyfinController
|
|||||||
_logger.LogError(ex, "Failed to get local tracks count for {Name}", playlistName);
|
_logger.LogError(ex, "Failed to get local tracks count for {Name}", playlistName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count external matched tracks (not local)
|
var totalAvailableCount = SpotifyPlaylistCountHelper.ComputeServedItemCount(
|
||||||
var externalMatchedCount = 0;
|
exactServedCount > 0 ? exactServedCount : null,
|
||||||
if (matchedTracks != null)
|
localTracksCount,
|
||||||
{
|
matchedTracks);
|
||||||
externalMatchedCount = matchedTracks.Count(t =>
|
var totalRunTimeTicks = SpotifyPlaylistCountHelper.ComputeServedRunTimeTicks(
|
||||||
t.MatchedSong != null && !t.MatchedSong.IsLocal);
|
exactServedCount > 0 ? exactServedRunTimeTicks : null,
|
||||||
}
|
localRunTimeTicks,
|
||||||
|
matchedTracks);
|
||||||
|
|
||||||
// Total available tracks = local tracks in Jellyfin + external matched tracks
|
itemDict["ChildCount"] = totalAvailableCount;
|
||||||
// This represents what users will actually hear when playing the playlist
|
itemDict["RunTimeTicks"] = totalRunTimeTicks;
|
||||||
var totalAvailableCount = localTracksCount + externalMatchedCount;
|
modified = true;
|
||||||
|
_logger.LogDebug(
|
||||||
if (totalAvailableCount > 0)
|
"✓ Updated Spotify playlist metrics for {Name}: count={Total} ({Local} local + {External} external), runtimeTicks={RunTimeTicks}",
|
||||||
{
|
playlistName,
|
||||||
// Update ChildCount to show actual available tracks
|
totalAvailableCount,
|
||||||
itemDict["ChildCount"] = totalAvailableCount;
|
localTracksCount,
|
||||||
modified = true;
|
SpotifyPlaylistCountHelper.CountExternalMatchedTracks(matchedTracks),
|
||||||
_logger.LogDebug(
|
totalRunTimeTicks);
|
||||||
"✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
|
|
||||||
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
"No tracks found for {Name} ({Local} local + {External} external = {Total} total)",
|
|
||||||
playlistName, localTracksCount, externalMatchedCount, totalAvailableCount);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -396,6 +477,47 @@ public partial class JellyfinController
|
|||||||
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? GetExactPlaylistItemsRequestId(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length != 3 ||
|
||||||
|
!parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!parts[2].Equals("items", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractImageTag(JsonElement item, string imageType)
|
||||||
|
{
|
||||||
|
if (item.TryGetProperty("ImageTags", out var imageTags) &&
|
||||||
|
imageTags.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var imageTag in imageTags.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(imageTag.Name, imageType, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return imageTag.Value.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(imageType, "Primary", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
item.TryGetProperty("PrimaryImageTag", out var primaryImageTag))
|
||||||
|
{
|
||||||
|
return primaryImageTag.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Determines whether Spotify playlist count enrichment should run for a response.
|
/// Determines whether Spotify playlist count enrichment should run for a response.
|
||||||
/// We only run enrichment for playlist-oriented payloads to avoid mutating unrelated item lists
|
/// We only run enrichment for playlist-oriented payloads to avoid mutating unrelated item lists
|
||||||
|
|||||||
@@ -245,7 +245,9 @@ public class JellyfinAdminController : ControllerBase
|
|||||||
/// Get all playlists from the user's Spotify account
|
/// Get all playlists from the user's Spotify account
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("jellyfin/playlists")]
|
[HttpGet("jellyfin/playlists")]
|
||||||
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
|
public async Task<IActionResult> GetJellyfinPlaylists(
|
||||||
|
[FromQuery] string? userId = null,
|
||||||
|
[FromQuery] bool includeStats = true)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||||
{
|
{
|
||||||
@@ -330,13 +332,13 @@ public class JellyfinAdminController : ControllerBase
|
|||||||
|
|
||||||
var statsUserId = requestedUserId;
|
var statsUserId = requestedUserId;
|
||||||
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
||||||
if (isConfigured)
|
if (isConfigured && includeStats)
|
||||||
{
|
{
|
||||||
trackStats = await GetPlaylistTrackStats(id!, session, statsUserId);
|
trackStats = await GetPlaylistTrackStats(id!, session, statsUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var actualTrackCount = isConfigured
|
var actualTrackCount = isConfigured
|
||||||
? trackStats.LocalTracks + trackStats.ExternalTracks
|
? (includeStats ? trackStats.LocalTracks + trackStats.ExternalTracks : childCount)
|
||||||
: childCount;
|
: childCount;
|
||||||
|
|
||||||
playlists.Add(new
|
playlists.Add(new
|
||||||
@@ -349,6 +351,7 @@ public class JellyfinAdminController : ControllerBase
|
|||||||
isLinkedByAnotherUser,
|
isLinkedByAnotherUser,
|
||||||
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
|
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
|
||||||
allLinkedForPlaylist.FirstOrDefault()?.UserId,
|
allLinkedForPlaylist.FirstOrDefault()?.UserId,
|
||||||
|
statsPending = isConfigured && !includeStats,
|
||||||
localTracks = trackStats.LocalTracks,
|
localTracks = trackStats.LocalTracks,
|
||||||
externalTracks = trackStats.ExternalTracks,
|
externalTracks = trackStats.ExternalTracks,
|
||||||
externalAvailable = trackStats.ExternalAvailable
|
externalAvailable = trackStats.ExternalAvailable
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ public partial class JellyfinController
|
|||||||
return await ProxyJellyfinStream(fullPath, itemId);
|
return await ProxyJellyfinStream(fullPath, itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle external content
|
// Handle external content with quality override from client transcoding params
|
||||||
return await StreamExternalContent(provider!, externalId!);
|
var quality = StreamQualityHelper.ParseFromQueryString(Request.Query);
|
||||||
|
return await StreamExternalContent(provider!, externalId!, quality);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -150,8 +151,9 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Streams external content, using cache if available or downloading on-demand.
|
/// Streams external content, using cache if available or downloading on-demand.
|
||||||
|
/// Supports quality override for client-requested "transcoding" of external tracks.
|
||||||
/// </summary>
|
/// </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
|
// Check for locally cached file
|
||||||
var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider, externalId);
|
var localPath = await _localLibraryService.GetLocalPathForExternalSongAsync(provider, externalId);
|
||||||
@@ -178,9 +180,16 @@ public partial class JellyfinController
|
|||||||
var downloadStream = await _downloadService.DownloadAndStreamAsync(
|
var downloadStream = await _downloadService.DownloadAndStreamAsync(
|
||||||
provider,
|
provider,
|
||||||
externalId,
|
externalId,
|
||||||
|
quality != StreamQuality.Original ? quality : null,
|
||||||
HttpContext.RequestAborted);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -228,8 +237,9 @@ public partial class JellyfinController
|
|||||||
return await ProxyJellyfinStream(fullPath, itemId);
|
return await ProxyJellyfinStream(fullPath, itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For external content, use simple streaming (no transcoding support yet)
|
// For external content, parse quality override from client transcoding params
|
||||||
return await StreamExternalContent(provider!, externalId!);
|
var quality = StreamQualityHelper.ParseFromQueryString(Request.Query);
|
||||||
|
return await StreamExternalContent(provider!, externalId!, quality);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -39,69 +39,9 @@ public partial class JellyfinController
|
|||||||
{
|
{
|
||||||
var responseJson = result.RootElement.GetRawText();
|
var responseJson = result.RootElement.GetRawText();
|
||||||
|
|
||||||
// On successful auth, extract access token and post session capabilities in background
|
|
||||||
if (statusCode == 200)
|
if (statusCode == 200)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Authentication successful");
|
_logger.LogInformation("Authentication successful");
|
||||||
|
|
||||||
// Extract access token from response for session capabilities
|
|
||||||
string? accessToken = null;
|
|
||||||
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
|
|
||||||
{
|
|
||||||
accessToken = tokenEl.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Post session capabilities in background if we have a token
|
|
||||||
if (!string.IsNullOrEmpty(accessToken))
|
|
||||||
{
|
|
||||||
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
|
|
||||||
// Capture token in closure - don't use Request.Headers (will be disposed)
|
|
||||||
var token = accessToken;
|
|
||||||
var authHeader = AuthHeaderHelper.CreateAuthHeader(token, client, device, deviceId, version);
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogDebug("🔧 Posting session capabilities after authentication");
|
|
||||||
|
|
||||||
// Build auth header with the new token
|
|
||||||
var authHeaders = new HeaderDictionary
|
|
||||||
{
|
|
||||||
["X-Emby-Authorization"] = authHeader,
|
|
||||||
["X-Emby-Token"] = token
|
|
||||||
};
|
|
||||||
|
|
||||||
var capabilities = new
|
|
||||||
{
|
|
||||||
PlayableMediaTypes = new[] { "Audio" },
|
|
||||||
SupportedCommands = Array.Empty<string>(),
|
|
||||||
SupportsMediaControl = false,
|
|
||||||
SupportsPersistentIdentifier = true,
|
|
||||||
SupportsSync = false
|
|
||||||
};
|
|
||||||
|
|
||||||
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
|
|
||||||
var (capResult, capStatus) =
|
|
||||||
await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson,
|
|
||||||
authHeaders);
|
|
||||||
|
|
||||||
if (capStatus == 204 || capStatus == 200)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})",
|
|
||||||
capStatus);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth",
|
|
||||||
capStatus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to post session capabilities after auth");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1558,9 +1558,14 @@ public partial class JellyfinController
|
|||||||
string.Join(", ", Request.Headers.Keys.Where(h =>
|
string.Join(", ", Request.Headers.Keys.Where(h =>
|
||||||
h.Contains("Auth", StringComparison.OrdinalIgnoreCase))));
|
h.Contains("Auth", StringComparison.OrdinalIgnoreCase))));
|
||||||
|
|
||||||
// Read body if present
|
// Read body if present. Preserve true empty-body requests because Jellyfin
|
||||||
string body = "{}";
|
// uses several POST session-control endpoints with query params only.
|
||||||
if ((method == "POST" || method == "PUT") && Request.ContentLength > 0)
|
string? body = null;
|
||||||
|
var hasRequestBody = !HttpMethods.IsGet(method) &&
|
||||||
|
(Request.ContentLength.GetValueOrDefault() > 0 ||
|
||||||
|
Request.Headers.ContainsKey("Transfer-Encoding"));
|
||||||
|
|
||||||
|
if (hasRequestBody)
|
||||||
{
|
{
|
||||||
Request.EnableBuffering();
|
Request.EnableBuffering();
|
||||||
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8,
|
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8,
|
||||||
@@ -1577,9 +1582,9 @@ public partial class JellyfinController
|
|||||||
var (result, statusCode) = method switch
|
var (result, statusCode) = method switch
|
||||||
{
|
{
|
||||||
"GET" => await _proxyService.GetJsonAsync(endpoint, null, Request.Headers),
|
"GET" => await _proxyService.GetJsonAsync(endpoint, null, Request.Headers),
|
||||||
"POST" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers),
|
"POST" => await _proxyService.SendAsync(HttpMethod.Post, endpoint, body, Request.Headers, Request.ContentType),
|
||||||
"PUT" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for PUT
|
"PUT" => await _proxyService.SendAsync(HttpMethod.Put, endpoint, body, Request.Headers, Request.ContentType),
|
||||||
"DELETE" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for DELETE
|
"DELETE" => await _proxyService.SendAsync(HttpMethod.Delete, endpoint, body, Request.Headers, Request.ContentType),
|
||||||
_ => (null, 405)
|
_ => (null, 405)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using allstarr.Models.Domain;
|
||||||
using allstarr.Models.Search;
|
using allstarr.Models.Search;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
@@ -32,6 +33,7 @@ public partial class JellyfinController
|
|||||||
{
|
{
|
||||||
var boundSearchTerm = searchTerm;
|
var boundSearchTerm = searchTerm;
|
||||||
searchTerm = GetEffectiveSearchTerm(searchTerm, Request.QueryString.Value);
|
searchTerm = GetEffectiveSearchTerm(searchTerm, Request.QueryString.Value);
|
||||||
|
string? searchCacheKey = null;
|
||||||
|
|
||||||
// AlbumArtistIds takes precedence over ArtistIds if both are provided
|
// AlbumArtistIds takes precedence over ArtistIds if both are provided
|
||||||
var effectiveArtistIds = albumArtistIds ?? artistIds;
|
var effectiveArtistIds = albumArtistIds ?? artistIds;
|
||||||
@@ -181,7 +183,7 @@ public partial class JellyfinController
|
|||||||
// Check cache for search results (only cache pure searches, not filtered searches)
|
// Check cache for search results (only cache pure searches, not filtered searches)
|
||||||
if (string.IsNullOrWhiteSpace(effectiveArtistIds) && string.IsNullOrWhiteSpace(albumIds))
|
if (string.IsNullOrWhiteSpace(effectiveArtistIds) && string.IsNullOrWhiteSpace(albumIds))
|
||||||
{
|
{
|
||||||
var cacheKey = CacheKeyBuilder.BuildSearchKey(
|
searchCacheKey = CacheKeyBuilder.BuildSearchKey(
|
||||||
searchTerm,
|
searchTerm,
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
limit,
|
limit,
|
||||||
@@ -192,12 +194,12 @@ public partial class JellyfinController
|
|||||||
recursive,
|
recursive,
|
||||||
userId,
|
userId,
|
||||||
Request.Query["IsFavorite"].ToString());
|
Request.Query["IsFavorite"].ToString());
|
||||||
var cachedResult = await _cache.GetAsync<object>(cacheKey);
|
var cachedResult = await _cache.GetStringAsync(searchCacheKey);
|
||||||
|
|
||||||
if (cachedResult != null)
|
if (!string.IsNullOrWhiteSpace(cachedResult))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", cacheKey);
|
_logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", searchCacheKey);
|
||||||
return new JsonResult(cachedResult);
|
return Content(cachedResult, "application/json");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,6 +305,7 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
// Run local and external searches in parallel
|
// Run local and external searches in parallel
|
||||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||||
|
var externalSearchLimits = GetExternalSearchLimits(itemTypes, limit, includePlaylistsAsAlbums: true);
|
||||||
var jellyfinTask = GetLocalSearchResultForCurrentRequest(
|
var jellyfinTask = GetLocalSearchResultForCurrentRequest(
|
||||||
cleanQuery,
|
cleanQuery,
|
||||||
includeItemTypes,
|
includeItemTypes,
|
||||||
@@ -311,12 +314,29 @@ public partial class JellyfinController
|
|||||||
recursive,
|
recursive,
|
||||||
userId);
|
userId);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SEARCH TRACE: external limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
|
||||||
|
cleanQuery,
|
||||||
|
externalSearchLimits.SongLimit,
|
||||||
|
externalSearchLimits.AlbumLimit,
|
||||||
|
externalSearchLimits.ArtistLimit);
|
||||||
|
|
||||||
// Use parallel metadata service if available (races providers), otherwise use primary
|
// Use parallel metadata service if available (races providers), otherwise use primary
|
||||||
var externalTask = favoritesOnlyRequest
|
var externalTask = favoritesOnlyRequest
|
||||||
? Task.FromResult(new SearchResult())
|
? Task.FromResult(new SearchResult())
|
||||||
: _parallelMetadataService != null
|
: _parallelMetadataService != null
|
||||||
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
|
? _parallelMetadataService.SearchAllAsync(
|
||||||
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
|
cleanQuery,
|
||||||
|
externalSearchLimits.SongLimit,
|
||||||
|
externalSearchLimits.AlbumLimit,
|
||||||
|
externalSearchLimits.ArtistLimit,
|
||||||
|
HttpContext.RequestAborted)
|
||||||
|
: _metadataService.SearchAllAsync(
|
||||||
|
cleanQuery,
|
||||||
|
externalSearchLimits.SongLimit,
|
||||||
|
externalSearchLimits.AlbumLimit,
|
||||||
|
externalSearchLimits.ArtistLimit,
|
||||||
|
HttpContext.RequestAborted);
|
||||||
|
|
||||||
var playlistTask = favoritesOnlyRequest || !_settings.EnableExternalPlaylists
|
var playlistTask = favoritesOnlyRequest || !_settings.EnableExternalPlaylists
|
||||||
? Task.FromResult(new List<ExternalPlaylist>())
|
? Task.FromResult(new List<ExternalPlaylist>())
|
||||||
@@ -384,11 +404,11 @@ public partial class JellyfinController
|
|||||||
var externalAlbumItems = externalResult.Albums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
|
var externalAlbumItems = externalResult.Albums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
|
||||||
var externalArtistItems = externalResult.Artists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
var externalArtistItems = externalResult.Artists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
||||||
|
|
||||||
// Score-sort each source, then interleave by highest remaining score.
|
// Keep Jellyfin/provider ordering intact.
|
||||||
// Keep only a small source preference for already-relevant primary results.
|
// Scores only decide which source leads each interleaving round.
|
||||||
var allSongs = InterleaveByScore(jellyfinSongItems, externalSongItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 72);
|
var allSongs = InterleaveByScore(jellyfinSongItems, externalSongItems, cleanQuery, primaryBoost: 5.0);
|
||||||
var allAlbums = InterleaveByScore(jellyfinAlbumItems, externalAlbumItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 78);
|
var allAlbums = InterleaveByScore(jellyfinAlbumItems, externalAlbumItems, cleanQuery, primaryBoost: 5.0);
|
||||||
var allArtists = InterleaveByScore(jellyfinArtistItems, externalArtistItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 75);
|
var allArtists = InterleaveByScore(jellyfinArtistItems, externalArtistItems, cleanQuery, primaryBoost: 5.0);
|
||||||
|
|
||||||
// Log top results for debugging
|
// Log top results for debugging
|
||||||
if (_logger.IsEnabled(LogLevel.Debug))
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
@@ -437,13 +457,8 @@ public partial class JellyfinController
|
|||||||
_logger.LogDebug("No playlists found to merge with albums");
|
_logger.LogDebug("No playlists found to merge with albums");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge albums and playlists using score-based interleaving (albums keep a light priority over playlists).
|
// Keep album/playlist source ordering intact and only let scores decide who leads each round.
|
||||||
var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 2.0, boostMinScore: 70);
|
var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 0.0);
|
||||||
mergedAlbumsAndPlaylists = ApplyRequestedAlbumOrderingIfApplicable(
|
|
||||||
mergedAlbumsAndPlaylists,
|
|
||||||
itemTypes,
|
|
||||||
Request.Query["SortBy"].ToString(),
|
|
||||||
Request.Query["SortOrder"].ToString());
|
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Merged results: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}",
|
"Merged results: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}",
|
||||||
@@ -538,24 +553,16 @@ public partial class JellyfinController
|
|||||||
TotalRecordCount = items.Count,
|
TotalRecordCount = items.Count,
|
||||||
StartIndex = startIndex
|
StartIndex = startIndex
|
||||||
};
|
};
|
||||||
|
var json = SerializeSearchResponseJson(response);
|
||||||
|
|
||||||
// Cache search results in Redis using the configured search TTL.
|
// Cache search results in Redis using the configured search TTL.
|
||||||
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds))
|
if (!string.IsNullOrWhiteSpace(searchTerm) &&
|
||||||
|
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
|
||||||
|
!string.IsNullOrWhiteSpace(searchCacheKey))
|
||||||
{
|
{
|
||||||
if (externalHasRequestedTypeResults)
|
if (externalHasRequestedTypeResults)
|
||||||
{
|
{
|
||||||
var cacheKey = CacheKeyBuilder.BuildSearchKey(
|
await _cache.SetStringAsync(searchCacheKey, json, CacheExtensions.SearchResultsTTL);
|
||||||
searchTerm,
|
|
||||||
includeItemTypes,
|
|
||||||
limit,
|
|
||||||
startIndex,
|
|
||||||
parentId,
|
|
||||||
sortBy,
|
|
||||||
Request.Query["SortOrder"].ToString(),
|
|
||||||
recursive,
|
|
||||||
userId,
|
|
||||||
Request.Query["IsFavorite"].ToString());
|
|
||||||
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
|
|
||||||
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
|
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
|
||||||
CacheExtensions.SearchResultsTTL.TotalMinutes);
|
CacheExtensions.SearchResultsTTL.TotalMinutes);
|
||||||
}
|
}
|
||||||
@@ -570,12 +577,6 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
_logger.LogDebug("About to serialize response...");
|
_logger.LogDebug("About to serialize response...");
|
||||||
|
|
||||||
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = null,
|
|
||||||
DictionaryKeyPolicy = null
|
|
||||||
});
|
|
||||||
|
|
||||||
if (_logger.IsEnabled(LogLevel.Debug))
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
{
|
{
|
||||||
var preview = json.Length > 200 ? json[..200] : json;
|
var preview = json.Length > 200 ? json[..200] : json;
|
||||||
@@ -591,6 +592,15 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string SerializeSearchResponseJson<T>(T response) where T : class
|
||||||
|
{
|
||||||
|
return JsonSerializer.Serialize(response, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = null,
|
||||||
|
DictionaryKeyPolicy = null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets child items of a parent (tracks in album, albums for artist).
|
/// Gets child items of a parent (tracks in album, albums for artist).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -681,11 +691,36 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
var cleanQuery = searchTerm.Trim().Trim('"');
|
var cleanQuery = searchTerm.Trim().Trim('"');
|
||||||
|
var requestedTypes = ParseItemTypes(includeItemTypes);
|
||||||
|
var externalSearchLimits = GetExternalSearchLimits(requestedTypes, limit, includePlaylistsAsAlbums: false);
|
||||||
|
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 ||
|
||||||
|
requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
||||||
|
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 ||
|
||||||
|
requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
|
||||||
|
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 ||
|
||||||
|
requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"SEARCH TRACE: hint limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
|
||||||
|
cleanQuery,
|
||||||
|
externalSearchLimits.SongLimit,
|
||||||
|
externalSearchLimits.AlbumLimit,
|
||||||
|
externalSearchLimits.ArtistLimit);
|
||||||
|
|
||||||
// Use parallel metadata service if available (races providers), otherwise use primary
|
// Use parallel metadata service if available (races providers), otherwise use primary
|
||||||
var externalTask = _parallelMetadataService != null
|
var externalTask = _parallelMetadataService != null
|
||||||
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
|
? _parallelMetadataService.SearchAllAsync(
|
||||||
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
|
cleanQuery,
|
||||||
|
externalSearchLimits.SongLimit,
|
||||||
|
externalSearchLimits.AlbumLimit,
|
||||||
|
externalSearchLimits.ArtistLimit,
|
||||||
|
HttpContext.RequestAborted)
|
||||||
|
: _metadataService.SearchAllAsync(
|
||||||
|
cleanQuery,
|
||||||
|
externalSearchLimits.SongLimit,
|
||||||
|
externalSearchLimits.AlbumLimit,
|
||||||
|
externalSearchLimits.ArtistLimit,
|
||||||
|
HttpContext.RequestAborted);
|
||||||
|
|
||||||
// Run searches in parallel (local Jellyfin hints + external providers)
|
// Run searches in parallel (local Jellyfin hints + external providers)
|
||||||
var jellyfinTask = GetLocalSearchHintsResultForCurrentRequest(cleanQuery, userId);
|
var jellyfinTask = GetLocalSearchHintsResultForCurrentRequest(cleanQuery, userId);
|
||||||
@@ -698,9 +733,15 @@ public partial class JellyfinController
|
|||||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseSearchHintsResponse(jellyfinResult);
|
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseSearchHintsResponse(jellyfinResult);
|
||||||
|
|
||||||
// NO deduplication - merge all results and take top matches
|
// NO deduplication - merge all results and take top matches
|
||||||
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
|
var allSongs = includesSongs
|
||||||
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
|
? localSongs.Concat(externalResult.Songs).Take(limit).ToList()
|
||||||
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList();
|
: new List<Song>();
|
||||||
|
var allAlbums = includesAlbums
|
||||||
|
? localAlbums.Concat(externalResult.Albums).Take(limit).ToList()
|
||||||
|
: new List<Album>();
|
||||||
|
var allArtists = includesArtists
|
||||||
|
? localArtists.Concat(externalResult.Artists).Take(limit).ToList()
|
||||||
|
: new List<Artist>();
|
||||||
|
|
||||||
return _responseBuilder.CreateSearchHintsResponse(
|
return _responseBuilder.CreateSearchHintsResponse(
|
||||||
allSongs.Take(limit).ToList(),
|
allSongs.Take(limit).ToList(),
|
||||||
@@ -751,6 +792,33 @@ public partial class JellyfinController
|
|||||||
return string.Equals(Request.Query["IsFavorite"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
|
return string.Equals(Request.Query["IsFavorite"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static (int SongLimit, int AlbumLimit, int ArtistLimit) GetExternalSearchLimits(
|
||||||
|
string[]? requestedTypes,
|
||||||
|
int limit,
|
||||||
|
bool includePlaylistsAsAlbums)
|
||||||
|
{
|
||||||
|
if (limit <= 0)
|
||||||
|
{
|
||||||
|
return (0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedTypes == null || requestedTypes.Length == 0)
|
||||||
|
{
|
||||||
|
return (limit, limit, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
var includeSongs = requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
||||||
|
var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) ||
|
||||||
|
(includePlaylistsAsAlbums &&
|
||||||
|
requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
|
||||||
|
var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return (
|
||||||
|
includeSongs ? limit : 0,
|
||||||
|
includeAlbums ? limit : 0,
|
||||||
|
includeArtists ? limit : 0);
|
||||||
|
}
|
||||||
|
|
||||||
private static IActionResult CreateEmptyItemsResponse(int startIndex)
|
private static IActionResult CreateEmptyItemsResponse(int startIndex)
|
||||||
{
|
{
|
||||||
return new JsonResult(new
|
return new JsonResult(new
|
||||||
@@ -761,227 +829,45 @@ public partial class JellyfinController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Dictionary<string, object?>> ApplyRequestedAlbumOrderingIfApplicable(
|
|
||||||
List<Dictionary<string, object?>> items,
|
|
||||||
string[]? requestedTypes,
|
|
||||||
string? sortBy,
|
|
||||||
string? sortOrder)
|
|
||||||
{
|
|
||||||
if (items.Count <= 1 || string.IsNullOrWhiteSpace(sortBy))
|
|
||||||
{
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestedTypes == null || requestedTypes.Length == 0)
|
|
||||||
{
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
var isAlbumOnlyRequest = requestedTypes.All(type =>
|
|
||||||
string.Equals(type, "MusicAlbum", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(type, "Playlist", StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (!isAlbumOnlyRequest)
|
|
||||||
{
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sortFields = sortBy
|
|
||||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
.Where(field => !string.IsNullOrWhiteSpace(field))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (sortFields.Count == 0)
|
|
||||||
{
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
var descending = string.Equals(sortOrder, "Descending", StringComparison.OrdinalIgnoreCase);
|
|
||||||
var sorted = items.ToList();
|
|
||||||
sorted.Sort((left, right) => CompareAlbumItemsByRequestedSort(left, right, sortFields, descending));
|
|
||||||
return sorted;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int CompareAlbumItemsByRequestedSort(
|
|
||||||
Dictionary<string, object?> left,
|
|
||||||
Dictionary<string, object?> right,
|
|
||||||
IReadOnlyList<string> sortFields,
|
|
||||||
bool descending)
|
|
||||||
{
|
|
||||||
foreach (var field in sortFields)
|
|
||||||
{
|
|
||||||
var comparison = CompareAlbumItemsByField(left, right, field);
|
|
||||||
if (comparison == 0)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return descending ? -comparison : comparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int CompareAlbumItemsByField(Dictionary<string, object?> left, Dictionary<string, object?> right, string field)
|
|
||||||
{
|
|
||||||
return field.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"sortname" => string.Compare(GetItemStringValue(left, "SortName"), GetItemStringValue(right, "SortName"), StringComparison.OrdinalIgnoreCase),
|
|
||||||
"name" => string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase),
|
|
||||||
"datecreated" => DateTime.Compare(GetItemDateValue(left, "DateCreated"), GetItemDateValue(right, "DateCreated")),
|
|
||||||
"premieredate" => DateTime.Compare(GetItemDateValue(left, "PremiereDate"), GetItemDateValue(right, "PremiereDate")),
|
|
||||||
"productionyear" => CompareIntValues(GetItemIntValue(left, "ProductionYear"), GetItemIntValue(right, "ProductionYear")),
|
|
||||||
_ => 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int CompareIntValues(int? left, int? right)
|
|
||||||
{
|
|
||||||
if (left.HasValue && right.HasValue)
|
|
||||||
{
|
|
||||||
return left.Value.CompareTo(right.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (left.HasValue)
|
|
||||||
{
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (right.HasValue)
|
|
||||||
{
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DateTime GetItemDateValue(Dictionary<string, object?> item, string key)
|
|
||||||
{
|
|
||||||
if (!item.TryGetValue(key, out var value) || value == null)
|
|
||||||
{
|
|
||||||
return DateTime.MinValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value is JsonElement jsonElement)
|
|
||||||
{
|
|
||||||
if (jsonElement.ValueKind == JsonValueKind.String &&
|
|
||||||
DateTime.TryParse(jsonElement.GetString(), out var parsedDate))
|
|
||||||
{
|
|
||||||
return parsedDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DateTime.MinValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DateTime.TryParse(value.ToString(), out var parsed))
|
|
||||||
{
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DateTime.MinValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int? GetItemIntValue(Dictionary<string, object?> item, string key)
|
|
||||||
{
|
|
||||||
if (!item.TryGetValue(key, out var value) || value == null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value is JsonElement jsonElement)
|
|
||||||
{
|
|
||||||
if (jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt32(out var intValue))
|
|
||||||
{
|
|
||||||
return intValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jsonElement.ValueKind == JsonValueKind.String &&
|
|
||||||
int.TryParse(jsonElement.GetString(), out var parsedInt))
|
|
||||||
{
|
|
||||||
return parsedInt;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return int.TryParse(value.ToString(), out var parsed) ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Score-sorts each source and then interleaves by highest remaining score.
|
/// Merges two source queues without reordering either queue.
|
||||||
/// This avoids weak head results in one source blocking stronger results later in that same source.
|
/// At each step, compare only the current head from each source and dequeue the winner.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private List<Dictionary<string, object?>> InterleaveByScore(
|
private List<Dictionary<string, object?>> InterleaveByScore(
|
||||||
List<Dictionary<string, object?>> primaryItems,
|
List<Dictionary<string, object?>> primaryItems,
|
||||||
List<Dictionary<string, object?>> secondaryItems,
|
List<Dictionary<string, object?>> secondaryItems,
|
||||||
string query,
|
string query,
|
||||||
double primaryBoost,
|
double primaryBoost)
|
||||||
double boostMinScore = 70)
|
|
||||||
{
|
{
|
||||||
var primaryScored = primaryItems.Select((item, index) =>
|
var primaryScored = primaryItems.Select(item =>
|
||||||
{
|
{
|
||||||
var baseScore = CalculateItemRelevanceScore(query, item);
|
|
||||||
var finalScore = baseScore >= boostMinScore
|
|
||||||
? Math.Min(100.0, baseScore + primaryBoost)
|
|
||||||
: baseScore;
|
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
Item = item,
|
Item = item,
|
||||||
BaseScore = baseScore,
|
Score = Math.Min(100.0, CalculateItemRelevanceScore(query, item) + primaryBoost)
|
||||||
Score = finalScore,
|
|
||||||
SourceIndex = index
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.OrderByDescending(x => x.Score)
|
|
||||||
.ThenByDescending(x => x.BaseScore)
|
|
||||||
.ThenBy(x => x.SourceIndex)
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var secondaryScored = secondaryItems.Select((item, index) =>
|
var secondaryScored = secondaryItems.Select(item =>
|
||||||
{
|
{
|
||||||
var baseScore = CalculateItemRelevanceScore(query, item);
|
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
Item = item,
|
Item = item,
|
||||||
BaseScore = baseScore,
|
Score = CalculateItemRelevanceScore(query, item)
|
||||||
Score = baseScore,
|
|
||||||
SourceIndex = index
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.OrderByDescending(x => x.Score)
|
|
||||||
.ThenByDescending(x => x.BaseScore)
|
|
||||||
.ThenBy(x => x.SourceIndex)
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var result = new List<Dictionary<string, object?>>(primaryScored.Count + secondaryScored.Count);
|
var result = new List<Dictionary<string, object?>>(primaryScored.Count + secondaryScored.Count);
|
||||||
int primaryIdx = 0, secondaryIdx = 0;
|
int primaryIdx = 0, secondaryIdx = 0;
|
||||||
|
|
||||||
while (primaryIdx < primaryScored.Count || secondaryIdx < secondaryScored.Count)
|
while (primaryIdx < primaryScored.Count && secondaryIdx < secondaryScored.Count)
|
||||||
{
|
{
|
||||||
if (primaryIdx >= primaryScored.Count)
|
|
||||||
{
|
|
||||||
result.Add(secondaryScored[secondaryIdx++].Item);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (secondaryIdx >= secondaryScored.Count)
|
|
||||||
{
|
|
||||||
result.Add(primaryScored[primaryIdx++].Item);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var primaryCandidate = primaryScored[primaryIdx];
|
var primaryCandidate = primaryScored[primaryIdx];
|
||||||
var secondaryCandidate = secondaryScored[secondaryIdx];
|
var secondaryCandidate = secondaryScored[secondaryIdx];
|
||||||
|
|
||||||
if (primaryCandidate.Score > secondaryCandidate.Score)
|
if (primaryCandidate.Score >= secondaryCandidate.Score)
|
||||||
{
|
|
||||||
result.Add(primaryScored[primaryIdx++].Item);
|
|
||||||
}
|
|
||||||
else if (secondaryCandidate.Score > primaryCandidate.Score)
|
|
||||||
{
|
|
||||||
result.Add(secondaryScored[secondaryIdx++].Item);
|
|
||||||
}
|
|
||||||
else if (primaryCandidate.BaseScore >= secondaryCandidate.BaseScore)
|
|
||||||
{
|
{
|
||||||
result.Add(primaryScored[primaryIdx++].Item);
|
result.Add(primaryScored[primaryIdx++].Item);
|
||||||
}
|
}
|
||||||
@@ -991,146 +877,31 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
while (primaryIdx < primaryScored.Count)
|
||||||
|
{
|
||||||
|
result.Add(primaryScored[primaryIdx++].Item);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (secondaryIdx < secondaryScored.Count)
|
||||||
|
{
|
||||||
|
result.Add(secondaryScored[secondaryIdx++].Item);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates query relevance for a search item.
|
/// Calculates query relevance using the product's per-type rules.
|
||||||
/// Title is primary; metadata context is secondary and down-weighted.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private double CalculateItemRelevanceScore(string query, Dictionary<string, object?> item)
|
private double CalculateItemRelevanceScore(string query, Dictionary<string, object?> item)
|
||||||
{
|
{
|
||||||
var title = GetItemName(item);
|
return GetItemType(item) switch
|
||||||
if (string.IsNullOrWhiteSpace(title))
|
|
||||||
{
|
{
|
||||||
return 0;
|
"Audio" => CalculateSongRelevanceScore(query, item),
|
||||||
}
|
"MusicAlbum" => CalculateAlbumRelevanceScore(query, item),
|
||||||
|
"MusicArtist" => CalculateArtistRelevanceScore(query, item),
|
||||||
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(query, title);
|
_ => CalculateArtistRelevanceScore(query, item)
|
||||||
var searchText = BuildItemSearchText(item, title);
|
};
|
||||||
|
|
||||||
if (string.Equals(searchText, title, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return titleScore;
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadataScore = FuzzyMatcher.CalculateSimilarityAggressive(query, searchText);
|
|
||||||
var weightedMetadataScore = metadataScore * 0.85;
|
|
||||||
|
|
||||||
var baseScore = Math.Max(titleScore, weightedMetadataScore);
|
|
||||||
return ApplyQueryCoverageAdjustment(query, title, searchText, baseScore);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double ApplyQueryCoverageAdjustment(string query, string title, string searchText, double baseScore)
|
|
||||||
{
|
|
||||||
var queryTokens = TokenizeForCoverage(query);
|
|
||||||
if (queryTokens.Count < 2)
|
|
||||||
{
|
|
||||||
return baseScore;
|
|
||||||
}
|
|
||||||
|
|
||||||
var titleCoverage = CalculateTokenCoverage(queryTokens, title);
|
|
||||||
var searchCoverage = string.Equals(searchText, title, StringComparison.OrdinalIgnoreCase)
|
|
||||||
? titleCoverage
|
|
||||||
: CalculateTokenCoverage(queryTokens, searchText);
|
|
||||||
|
|
||||||
var coverage = Math.Max(titleCoverage, searchCoverage);
|
|
||||||
|
|
||||||
if (coverage >= 0.999)
|
|
||||||
{
|
|
||||||
return Math.Min(100.0, baseScore + 3.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (coverage >= 0.8)
|
|
||||||
{
|
|
||||||
return baseScore * 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (coverage >= 0.6)
|
|
||||||
{
|
|
||||||
return baseScore * 0.72;
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseScore * 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static double CalculateTokenCoverage(IReadOnlyList<string> queryTokens, string target)
|
|
||||||
{
|
|
||||||
var targetTokens = TokenizeForCoverage(target);
|
|
||||||
if (queryTokens.Count == 0 || targetTokens.Count == 0)
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
var matched = 0;
|
|
||||||
foreach (var queryToken in queryTokens)
|
|
||||||
{
|
|
||||||
if (targetTokens.Any(targetToken => IsTokenMatch(queryToken, targetToken)))
|
|
||||||
{
|
|
||||||
matched++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (double)matched / queryTokens.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsTokenMatch(string queryToken, string targetToken)
|
|
||||||
{
|
|
||||||
return queryToken.Equals(targetToken, StringComparison.Ordinal) ||
|
|
||||||
queryToken.StartsWith(targetToken, StringComparison.Ordinal) ||
|
|
||||||
targetToken.StartsWith(queryToken, StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<string> TokenizeForCoverage(string text)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(text))
|
|
||||||
{
|
|
||||||
return Array.Empty<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var normalized = NormalizeForCoverage(text);
|
|
||||||
var allTokens = normalized
|
|
||||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
|
||||||
.Distinct(StringComparer.Ordinal)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (allTokens.Count == 0)
|
|
||||||
{
|
|
||||||
return Array.Empty<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var significant = allTokens
|
|
||||||
.Where(token => token.Length >= 2 && !SearchStopWords.Contains(token))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return significant.Count > 0
|
|
||||||
? significant
|
|
||||||
: allTokens.Where(token => token.Length >= 2).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizeForCoverage(string text)
|
|
||||||
{
|
|
||||||
var normalized = RemoveDiacritics(text).ToLowerInvariant();
|
|
||||||
normalized = normalized.Replace('&', ' ');
|
|
||||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", " ");
|
|
||||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string RemoveDiacritics(string text)
|
|
||||||
{
|
|
||||||
var normalized = text.Normalize(NormalizationForm.FormD);
|
|
||||||
var chars = new List<char>(normalized.Length);
|
|
||||||
|
|
||||||
foreach (var c in normalized)
|
|
||||||
{
|
|
||||||
if (System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c) != System.Globalization.UnicodeCategory.NonSpacingMark)
|
|
||||||
{
|
|
||||||
chars.Add(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new string(chars.ToArray()).Normalize(NormalizationForm.FormC);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1141,52 +912,90 @@ public partial class JellyfinController
|
|||||||
return GetItemStringValue(item, "Name");
|
return GetItemStringValue(item, "Name");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string BuildItemSearchText(Dictionary<string, object?> item, string title)
|
private double CalculateSongRelevanceScore(string query, Dictionary<string, object?> item)
|
||||||
{
|
{
|
||||||
var parts = new List<string>();
|
var title = GetItemName(item);
|
||||||
|
var artistText = GetSongArtistText(item);
|
||||||
AddDistinct(parts, title);
|
return CalculateBestFuzzyScore(query, title, CombineSearchFields(title, artistText));
|
||||||
AddDistinct(parts, GetItemStringValue(item, "SortName"));
|
|
||||||
AddDistinct(parts, GetItemStringValue(item, "AlbumArtist"));
|
|
||||||
AddDistinct(parts, GetItemStringValue(item, "Artist"));
|
|
||||||
AddDistinct(parts, GetItemStringValue(item, "Album"));
|
|
||||||
|
|
||||||
foreach (var artist in GetItemStringList(item, "Artists").Take(3))
|
|
||||||
{
|
|
||||||
AddDistinct(parts, artist);
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Join(" ", parts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly HashSet<string> SearchStopWords = new(StringComparer.Ordinal)
|
private double CalculateAlbumRelevanceScore(string query, Dictionary<string, object?> item)
|
||||||
{
|
{
|
||||||
"a",
|
var albumName = GetItemName(item);
|
||||||
"an",
|
var artistText = GetAlbumArtistText(item);
|
||||||
"and",
|
return CalculateBestFuzzyScore(query, albumName, CombineSearchFields(albumName, artistText));
|
||||||
"at",
|
}
|
||||||
"for",
|
|
||||||
"in",
|
|
||||||
"of",
|
|
||||||
"on",
|
|
||||||
"the",
|
|
||||||
"to",
|
|
||||||
"with",
|
|
||||||
"feat",
|
|
||||||
"ft"
|
|
||||||
};
|
|
||||||
|
|
||||||
private static void AddDistinct(List<string> values, string? value)
|
private double CalculateArtistRelevanceScore(string query, Dictionary<string, object?> item)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
var artistName = GetItemName(item);
|
||||||
|
if (string.IsNullOrWhiteSpace(artistName))
|
||||||
{
|
{
|
||||||
return;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!values.Contains(value, StringComparer.OrdinalIgnoreCase))
|
return FuzzyMatcher.CalculateSimilarityAggressive(query, artistName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private double CalculateBestFuzzyScore(string query, params string?[] candidates)
|
||||||
|
{
|
||||||
|
var best = 0;
|
||||||
|
|
||||||
|
foreach (var candidate in candidates)
|
||||||
{
|
{
|
||||||
values.Add(value);
|
if (string.IsNullOrWhiteSpace(candidate))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
best = Math.Max(best, FuzzyMatcher.CalculateSimilarityAggressive(query, candidate));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CombineSearchFields(params string?[] fields)
|
||||||
|
{
|
||||||
|
return string.Join(" ", fields.Where(field => !string.IsNullOrWhiteSpace(field)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetItemType(Dictionary<string, object?> item)
|
||||||
|
{
|
||||||
|
return GetItemStringValue(item, "Type");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetSongArtistText(Dictionary<string, object?> item)
|
||||||
|
{
|
||||||
|
var artists = GetItemStringList(item, "Artists").Take(3).ToList();
|
||||||
|
if (artists.Count > 0)
|
||||||
|
{
|
||||||
|
return string.Join(" ", artists);
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumArtist = GetItemStringValue(item, "AlbumArtist");
|
||||||
|
if (!string.IsNullOrWhiteSpace(albumArtist))
|
||||||
|
{
|
||||||
|
return albumArtist;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetItemStringValue(item, "Artist");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetAlbumArtistText(Dictionary<string, object?> item)
|
||||||
|
{
|
||||||
|
var albumArtist = GetItemStringValue(item, "AlbumArtist");
|
||||||
|
if (!string.IsNullOrWhiteSpace(albumArtist))
|
||||||
|
{
|
||||||
|
return albumArtist;
|
||||||
|
}
|
||||||
|
|
||||||
|
var artists = GetItemStringList(item, "Artists").Take(3).ToList();
|
||||||
|
if (artists.Count > 0)
|
||||||
|
{
|
||||||
|
return string.Join(" ", artists);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetItemStringValue(item, "Artist");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetItemStringValue(Dictionary<string, object?> item, string key)
|
private string GetItemStringValue(Dictionary<string, object?> item, string key)
|
||||||
|
|||||||
@@ -63,11 +63,33 @@ public partial class JellyfinController
|
|||||||
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
|
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
|
||||||
|
|
||||||
var jellyfinPlaylistChanged = cachedJellyfinSignature != currentJellyfinSignature;
|
var jellyfinPlaylistChanged = cachedJellyfinSignature != currentJellyfinSignature;
|
||||||
|
var requestNeedsGenreMetadata = RequestIncludesField("Genres");
|
||||||
|
|
||||||
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
|
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
|
||||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
|
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
|
||||||
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
|
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)
|
if (cachedItems != null && cachedItems.Count > 0 && !jellyfinPlaylistChanged)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("✅ Loaded {Count} playlist items from Redis cache for {Playlist} (Jellyfin unchanged)",
|
_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
|
// Check file cache as fallback
|
||||||
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
|
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}",
|
_logger.LogDebug("✅ Loaded {Count} playlist items from file cache for {Playlist}",
|
||||||
fileItems.Count, spotifyPlaylistName);
|
fileItems.Count, spotifyPlaylistName);
|
||||||
@@ -208,6 +249,7 @@ public partial class JellyfinController
|
|||||||
var usedJellyfinItems = new HashSet<string>();
|
var usedJellyfinItems = new HashSet<string>();
|
||||||
var localUsedCount = 0;
|
var localUsedCount = 0;
|
||||||
var externalUsedCount = 0;
|
var externalUsedCount = 0;
|
||||||
|
var unresolvedLocalCount = 0;
|
||||||
|
|
||||||
_logger.LogDebug("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
|
_logger.LogDebug("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
|
||||||
|
|
||||||
@@ -283,9 +325,26 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
else
|
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(
|
_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);
|
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)",
|
_logger.LogDebug("🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||||
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
|
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
|
// Save to file cache for persistence across restarts
|
||||||
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
||||||
|
|
||||||
@@ -347,6 +424,30 @@ public partial class JellyfinController
|
|||||||
item["DateCreated"] = addedAt.Value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ");
|
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>
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Copies an external track to the kept folder when favorited.
|
/// Copies an external track to the kept folder when favorited.
|
||||||
@@ -623,8 +724,18 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
#region Persistent Favorites Tracking
|
#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>
|
/// <summary>
|
||||||
/// Checks if a track is already favorited (persistent across restarts).
|
/// Checks if a track is already favorited (persistent across restarts).
|
||||||
@@ -633,13 +744,7 @@ public partial class JellyfinController
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!System.IO.File.Exists(_favoritesFilePath))
|
return await _cache.ExistsAsync($"favorites:{itemId}");
|
||||||
return false;
|
|
||||||
|
|
||||||
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
|
||||||
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
|
||||||
|
|
||||||
return favorites.ContainsKey(itemId);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -655,29 +760,16 @@ public partial class JellyfinController
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var favorites = new Dictionary<string, FavoriteTrackInfo>();
|
var info = new FavoriteTrackInfo
|
||||||
|
|
||||||
if (System.IO.File.Exists(_favoritesFilePath))
|
|
||||||
{
|
|
||||||
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
|
||||||
favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
|
||||||
}
|
|
||||||
|
|
||||||
favorites[itemId] = new FavoriteTrackInfo
|
|
||||||
{
|
{
|
||||||
ItemId = itemId,
|
ItemId = itemId,
|
||||||
Title = song.Title,
|
Title = song.Title ?? "Unknown Title",
|
||||||
Artist = song.Artist,
|
Artist = song.Artist ?? "Unknown Artist",
|
||||||
Album = song.Album,
|
Album = song.Album ?? "Unknown Album",
|
||||||
FavoritedAt = DateTime.UtcNow
|
FavoritedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure cache directory exists
|
await _cache.SetAsync($"favorites:{itemId}", info);
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(_favoritesFilePath)!);
|
|
||||||
|
|
||||||
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
|
|
||||||
|
|
||||||
_logger.LogDebug("Marked track as favorited: {ItemId}", itemId);
|
_logger.LogDebug("Marked track as favorited: {ItemId}", itemId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -693,17 +785,9 @@ public partial class JellyfinController
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!System.IO.File.Exists(_favoritesFilePath))
|
if (await _cache.ExistsAsync($"favorites:{itemId}"))
|
||||||
return;
|
|
||||||
|
|
||||||
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
|
||||||
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
|
||||||
|
|
||||||
if (favorites.Remove(itemId))
|
|
||||||
{
|
{
|
||||||
var updatedJson =
|
await _cache.DeleteAsync($"favorites:{itemId}");
|
||||||
JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
|
|
||||||
_logger.LogDebug("Removed track from favorites: {ItemId}", itemId);
|
_logger.LogDebug("Removed track from favorites: {ItemId}", itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -720,24 +804,8 @@ public partial class JellyfinController
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var deletionFilePath = "/app/cache/pending_deletions.json";
|
var deletionTime = DateTime.UtcNow.AddHours(24);
|
||||||
var pendingDeletions = new Dictionary<string, DateTime>();
|
await _cache.SetStringAsync($"pending_deletion:{itemId}", deletionTime.ToString("O"));
|
||||||
|
|
||||||
if (System.IO.File.Exists(deletionFilePath))
|
|
||||||
{
|
|
||||||
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
|
|
||||||
pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark for deletion 24 hours from now
|
|
||||||
pendingDeletions[itemId] = DateTime.UtcNow.AddHours(24);
|
|
||||||
|
|
||||||
// Ensure cache directory exists
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(deletionFilePath)!);
|
|
||||||
|
|
||||||
var updatedJson =
|
|
||||||
JsonSerializer.Serialize(pendingDeletions, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
|
||||||
|
|
||||||
// Also remove from favorites immediately
|
// Also remove from favorites immediately
|
||||||
await UnmarkTrackAsFavoritedAsync(itemId);
|
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>
|
/// <summary>
|
||||||
/// Processes pending deletions (called by cleanup service).
|
/// Processes pending deletions (called by cleanup service).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -769,31 +825,29 @@ public partial class JellyfinController
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var deletionFilePath = "/app/cache/pending_deletions.json";
|
var deletionKeys = _cache.GetKeysByPattern("pending_deletion:*").ToList();
|
||||||
if (!System.IO.File.Exists(deletionFilePath))
|
if (deletionKeys.Count == 0) return;
|
||||||
return;
|
|
||||||
|
|
||||||
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
|
|
||||||
var pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
|
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var toDelete = pendingDeletions.Where(kvp => kvp.Value <= now).ToList();
|
int deletedCount = 0;
|
||||||
var remaining = pendingDeletions.Where(kvp => kvp.Value > now)
|
|
||||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
|
||||||
|
|
||||||
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
|
_logger.LogDebug("Processed {Count} pending deletions", deletedCount);
|
||||||
var updatedJson =
|
|
||||||
JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
|
|
||||||
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
|
||||||
|
|
||||||
_logger.LogDebug("Processed {Count} pending deletions", toDelete.Count);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -628,13 +628,20 @@ public partial class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (!isExternal)
|
if (!isExternal)
|
||||||
{
|
{
|
||||||
|
var effectiveImageTag = tag;
|
||||||
|
if (string.IsNullOrWhiteSpace(effectiveImageTag) &&
|
||||||
|
_spotifySettings.IsSpotifyPlaylist(itemId))
|
||||||
|
{
|
||||||
|
effectiveImageTag = await ResolveCurrentSpotifyPlaylistImageTagAsync(itemId, imageType);
|
||||||
|
}
|
||||||
|
|
||||||
// Proxy image from Jellyfin for local content
|
// Proxy image from Jellyfin for local content
|
||||||
var (imageBytes, contentType) = await _proxyService.GetImageAsync(
|
var (imageBytes, contentType) = await _proxyService.GetImageAsync(
|
||||||
itemId,
|
itemId,
|
||||||
imageType,
|
imageType,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
tag);
|
effectiveImageTag);
|
||||||
|
|
||||||
if (imageBytes == null || contentType == null)
|
if (imageBytes == null || contentType == null)
|
||||||
{
|
{
|
||||||
@@ -671,7 +678,7 @@ public partial class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (fallbackBytes != null && fallbackContentType != null)
|
if (fallbackBytes != null && fallbackContentType != null)
|
||||||
{
|
{
|
||||||
return File(fallbackBytes, fallbackContentType);
|
return CreateConditionalImageResponse(fallbackBytes, fallbackContentType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -680,7 +687,16 @@ public partial class JellyfinController : ControllerBase
|
|||||||
return await GetPlaceholderImageAsync();
|
return await GetPlaceholderImageAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return File(imageBytes, contentType);
|
return CreateConditionalImageResponse(imageBytes, contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Redis cache for previously fetched external image
|
||||||
|
var imageCacheKey = CacheKeyBuilder.BuildExternalImageKey(provider!, type!, externalId!);
|
||||||
|
var cachedImageBytes = await _cache.GetAsync<byte[]>(imageCacheKey);
|
||||||
|
if (cachedImageBytes != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Cache hit for external {Type} image: {Provider}/{ExternalId}", type, provider, externalId);
|
||||||
|
return CreateConditionalImageResponse(cachedImageBytes, "image/jpeg");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get external cover art URL
|
// Get external cover art URL
|
||||||
@@ -746,9 +762,12 @@ public partial class JellyfinController : ControllerBase
|
|||||||
return await GetPlaceholderImageAsync();
|
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);
|
safeCoverUri.Host, imageBytes.Length);
|
||||||
return File(imageBytes, "image/jpeg");
|
return CreateConditionalImageResponse(imageBytes, "image/jpeg");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -770,7 +789,7 @@ public partial class JellyfinController : ControllerBase
|
|||||||
if (System.IO.File.Exists(placeholderPath))
|
if (System.IO.File.Exists(placeholderPath))
|
||||||
{
|
{
|
||||||
var imageBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath);
|
var imageBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath);
|
||||||
return File(imageBytes, "image/png");
|
return CreateConditionalImageResponse(imageBytes, "image/png");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Return a 1x1 transparent PNG as minimal placeholder
|
// Fallback: Return a 1x1 transparent PNG as minimal placeholder
|
||||||
@@ -778,7 +797,54 @@ public partial class JellyfinController : ControllerBase
|
|||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||||
);
|
);
|
||||||
|
|
||||||
return File(transparentPng, "image/png");
|
return CreateConditionalImageResponse(transparentPng, "image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult CreateConditionalImageResponse(byte[] imageBytes, string contentType)
|
||||||
|
{
|
||||||
|
var etag = ImageConditionalRequestHelper.ComputeStrongETag(imageBytes);
|
||||||
|
Response.Headers["ETag"] = etag;
|
||||||
|
|
||||||
|
if (ImageConditionalRequestHelper.MatchesIfNoneMatch(Request.Headers, etag))
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status304NotModified);
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(imageBytes, contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ResolveCurrentSpotifyPlaylistImageTagAsync(string itemId, string imageType)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (itemResult, statusCode) = await _proxyService.GetJsonAsyncInternal($"Items/{itemId}");
|
||||||
|
if (itemResult == null || statusCode != 200)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var itemDocument = itemResult;
|
||||||
|
var imageTag = ExtractImageTag(itemDocument.RootElement, imageType);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(imageTag))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Resolved current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}: {ImageTag}",
|
||||||
|
imageType,
|
||||||
|
itemId,
|
||||||
|
imageTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageTag;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex,
|
||||||
|
"Failed to resolve current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}",
|
||||||
|
imageType,
|
||||||
|
itemId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -1280,33 +1346,37 @@ public partial class JellyfinController : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intercept Spotify playlist requests by ID
|
var playlistItemsRequestId = GetExactPlaylistItemsRequestId(path);
|
||||||
if (_spotifySettings.Enabled &&
|
if (!string.IsNullOrEmpty(playlistItemsRequestId))
|
||||||
path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
path.Contains("/items", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
// Extract playlist ID from path: playlists/{id}/items
|
if (_spotifySettings.Enabled)
|
||||||
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
if (parts.Length >= 2 && parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
var playlistId = parts[1];
|
|
||||||
|
|
||||||
_logger.LogDebug("=== PLAYLIST REQUEST ===");
|
_logger.LogDebug("=== PLAYLIST REQUEST ===");
|
||||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
|
||||||
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
||||||
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
|
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
|
||||||
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistId));
|
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId));
|
||||||
|
|
||||||
// Check if this playlist ID is configured for Spotify injection
|
// Check if this playlist ID is configured for Spotify injection
|
||||||
if (_spotifySettings.IsSpotifyPlaylist(playlistId))
|
if (_spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("========================================");
|
_logger.LogInformation("========================================");
|
||||||
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
|
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
|
||||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
|
||||||
_logger.LogInformation("========================================");
|
_logger.LogInformation("========================================");
|
||||||
return await GetPlaylistTracks(playlistId);
|
return await GetPlaylistTracks(playlistItemsRequestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var playlistItemsPath = path;
|
||||||
|
if (Request.QueryString.HasValue)
|
||||||
|
{
|
||||||
|
playlistItemsPath = $"{playlistItemsPath}{Request.QueryString.Value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Using transparent Jellyfin passthrough for non-injected playlist {PlaylistId}",
|
||||||
|
playlistItemsRequestId);
|
||||||
|
return await ProxyJsonPassthroughAsync(playlistItemsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle non-JSON responses (images, robots.txt, etc.)
|
// Handle non-JSON responses (images, robots.txt, etc.)
|
||||||
|
|||||||
@@ -161,8 +161,15 @@ public class SubsonicController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var downloadStream = await _downloadService.DownloadAndStreamAsync(provider!, externalId!, HttpContext.RequestAborted);
|
var downloadStream = await _downloadService.DownloadAndStreamAsync(provider!, externalId!, cancellationToken: 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ public class WebSocketProxyMiddleware
|
|||||||
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
|
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
|
||||||
_logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted");
|
_logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(deviceId))
|
||||||
|
{
|
||||||
|
await _sessionManager.RegisterProxiedWebSocketAsync(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
// Start bidirectional proxying
|
// Start bidirectional proxying
|
||||||
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
||||||
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
|
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
|
||||||
@@ -194,6 +199,11 @@ public class WebSocketProxyMiddleware
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
if (!string.IsNullOrEmpty(deviceId))
|
||||||
|
{
|
||||||
|
_sessionManager.UnregisterProxiedWebSocket(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up connections
|
// Clean up connections
|
||||||
if (clientWebSocket?.State == WebSocketState.Open)
|
if (clientWebSocket?.State == WebSocketState.Open)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ public class Song
|
|||||||
public int? ExplicitContentLyrics { get; set; }
|
public int? ExplicitContentLyrics { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raw Jellyfin metadata (MediaSources, etc.) for local tracks
|
/// Raw Jellyfin metadata for local tracks, including MediaSources and cached item snapshots
|
||||||
/// Preserved to maintain bitrate and other technical details
|
/// Preserved to maintain full Jellyfin object fidelity across cache round-trips
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, object?>? JellyfinMetadata { get; set; }
|
public Dictionary<string, object?>? JellyfinMetadata { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ public class DownloadInfo
|
|||||||
public string SongId { get; set; } = string.Empty;
|
public string SongId { get; set; } = string.Empty;
|
||||||
public string ExternalId { get; set; } = string.Empty;
|
public string ExternalId { get; set; } = string.Empty;
|
||||||
public string ExternalProvider { 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 DownloadStatus Status { get; set; }
|
||||||
public double Progress { get; set; } // 0.0 to 1.0
|
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? LocalPath { get; set; }
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
public DateTime StartedAt { get; set; }
|
public DateTime StartedAt { get; set; }
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ public class CacheSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int ProxyImagesDays { get; set; } = 14;
|
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
|
// Helper methods to get TimeSpan values
|
||||||
public TimeSpan SearchResultsTTL => TimeSpan.FromMinutes(SearchResultsMinutes);
|
public TimeSpan SearchResultsTTL => TimeSpan.FromMinutes(SearchResultsMinutes);
|
||||||
public TimeSpan PlaylistImagesTTL => TimeSpan.FromHours(PlaylistImagesHours);
|
public TimeSpan PlaylistImagesTTL => TimeSpan.FromHours(PlaylistImagesHours);
|
||||||
@@ -71,4 +79,5 @@ public class CacheSettings
|
|||||||
public TimeSpan MetadataTTL => TimeSpan.FromDays(MetadataDays);
|
public TimeSpan MetadataTTL => TimeSpan.FromDays(MetadataDays);
|
||||||
public TimeSpan OdesliLookupTTL => TimeSpan.FromDays(OdesliLookupDays);
|
public TimeSpan OdesliLookupTTL => TimeSpan.FromDays(OdesliLookupDays);
|
||||||
public TimeSpan ProxyImagesTTL => TimeSpan.FromDays(ProxyImagesDays);
|
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.
|
/// If not specified or unavailable, the highest available quality will be used.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Quality { get; set; }
|
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.
|
/// If not specified or unavailable, the highest available quality will be used.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Quality { get; set; }
|
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.
|
/// If not specified or unavailable, LOSSLESS will be used.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Quality { get; set; }
|
public string? Quality { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum interval between requests in milliseconds.
|
||||||
|
/// Default: 200ms
|
||||||
|
/// </summary>
|
||||||
|
public int MinRequestIntervalMs { get; set; } = 200;
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-1
@@ -16,6 +16,7 @@ using Microsoft.Extensions.Http;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
|
||||||
|
|
||||||
// Discover SquidWTF API and streaming endpoints from uptime feeds.
|
// Discover SquidWTF API and streaming endpoints from uptime feeds.
|
||||||
var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync();
|
var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync();
|
||||||
@@ -175,6 +176,25 @@ builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
|
|||||||
// but we want to reduce noise in production logs
|
// but we want to reduce noise in production logs
|
||||||
options.SuppressHandlerScope = true;
|
options.SuppressHandlerScope = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register a dedicated named HttpClient for Jellyfin backend with connection pooling.
|
||||||
|
// SocketsHttpHandler reuses TCP connections across the scoped JellyfinProxyService
|
||||||
|
// instances, eliminating per-request TCP/TLS handshake overhead.
|
||||||
|
builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName)
|
||||||
|
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
|
||||||
|
{
|
||||||
|
// Keep up to 20 idle connections to Jellyfin alive at any time
|
||||||
|
MaxConnectionsPerServer = 20,
|
||||||
|
// Recycle pooled connections every 5 minutes to pick up DNS changes
|
||||||
|
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||||
|
// Close idle connections after 90 seconds to avoid stale sockets
|
||||||
|
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
|
||||||
|
// Allow HTTP/2 multiplexing when Jellyfin supports it
|
||||||
|
EnableMultipleHttp2Connections = true,
|
||||||
|
// Follow redirects within Jellyfin
|
||||||
|
AllowAutoRedirect = true,
|
||||||
|
MaxAutomaticRedirections = 5
|
||||||
|
});
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
@@ -509,6 +529,7 @@ else
|
|||||||
// Business services - shared across backends
|
// Business services - shared across backends
|
||||||
builder.Services.AddSingleton(squidWtfEndpointCatalog);
|
builder.Services.AddSingleton(squidWtfEndpointCatalog);
|
||||||
builder.Services.AddSingleton<RedisCacheService>();
|
builder.Services.AddSingleton<RedisCacheService>();
|
||||||
|
builder.Services.AddSingleton<FavoritesMigrationService>();
|
||||||
builder.Services.AddSingleton<OdesliService>();
|
builder.Services.AddSingleton<OdesliService>();
|
||||||
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
||||||
builder.Services.AddSingleton<LrclibService>();
|
builder.Services.AddSingleton<LrclibService>();
|
||||||
@@ -891,6 +912,13 @@ builder.Services.AddCors(options =>
|
|||||||
|
|
||||||
var app = builder.Build();
|
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
|
// Initialize cache settings for static access
|
||||||
CacheExtensions.InitializeCacheSettings(app.Services);
|
CacheExtensions.InitializeCacheSettings(app.Services);
|
||||||
|
|
||||||
@@ -937,7 +965,11 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
// The admin UI is documented and intended to be reachable directly over HTTP on port 5275.
|
||||||
|
// Keep HTTPS redirection for non-admin traffic only.
|
||||||
|
app.UseWhen(
|
||||||
|
context => context.Connection.LocalPort != 5275,
|
||||||
|
branch => branch.UseHttpsRedirection());
|
||||||
|
|
||||||
// Serve static files only on admin port (5275)
|
// Serve static files only on admin port (5275)
|
||||||
app.UseMiddleware<allstarr.Middleware.AdminNetworkAllowlistMiddleware>();
|
app.UseMiddleware<allstarr.Middleware.AdminNetworkAllowlistMiddleware>();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Models.Spotify;
|
using allstarr.Models.Spotify;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
|
||||||
namespace allstarr.Services.Admin;
|
namespace allstarr.Services.Admin;
|
||||||
|
|
||||||
@@ -20,9 +21,7 @@ public class AdminHelperService
|
|||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_jellyfinSettings = jellyfinSettings.Value;
|
_jellyfinSettings = jellyfinSettings.Value;
|
||||||
_envFilePath = environment.IsDevelopment()
|
_envFilePath = RuntimeEnvConfiguration.ResolveEnvFilePath(environment);
|
||||||
? Path.Combine(environment.ContentRootPath, "..", ".env")
|
|
||||||
: "/app/.env";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetJellyfinAuthHeader()
|
public string GetJellyfinAuthHeader()
|
||||||
|
|||||||
@@ -29,13 +29,40 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
protected readonly string CachePath;
|
protected readonly string CachePath;
|
||||||
|
|
||||||
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
|
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
|
// Rate limiting fields
|
||||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
||||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
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>
|
/// <summary>
|
||||||
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
|
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -84,6 +111,13 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
{
|
{
|
||||||
Directory.CreateDirectory(CachePath);
|
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
|
#region IDownloadService Implementation
|
||||||
@@ -95,12 +129,25 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
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;
|
var startTime = DateTime.UtcNow;
|
||||||
|
|
||||||
// Check if already downloaded locally
|
// 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);
|
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
|
||||||
|
|
||||||
// Update write time for cache cleanup (extends cache lifetime)
|
// Update write time for cache cleanup (extends cache lifetime)
|
||||||
if (SubsonicSettings.StorageMode == StorageMode.Cache)
|
if (CurrentStorageMode == StorageMode.Cache)
|
||||||
{
|
{
|
||||||
IOFile.SetLastWriteTime(localPath, DateTime.UtcNow);
|
IOFile.SetLastWriteTime(localPath, DateTime.UtcNow);
|
||||||
}
|
}
|
||||||
@@ -134,7 +181,12 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
// IMPORTANT: Use CancellationToken.None for the actual download
|
// IMPORTANT: Use CancellationToken.None for the actual download
|
||||||
// This ensures downloads complete server-side even if the client cancels the request
|
// 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
|
// 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;
|
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||||
Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath);
|
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>
|
/// <summary>
|
||||||
/// Starts background Odesli conversion for lyrics support.
|
/// Starts background Odesli conversion for lyrics support.
|
||||||
@@ -194,6 +305,11 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
ActiveDownloads.TryGetValue(songId, out var info);
|
ActiveDownloads.TryGetValue(songId, out var info);
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<DownloadInfo> GetActiveDownloads()
|
||||||
|
{
|
||||||
|
return ActiveDownloads.Values.ToList().AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string?> GetLocalPathIfExistsAsync(string externalProvider, string externalId)
|
public async Task<string?> GetLocalPathIfExistsAsync(string externalProvider, string externalId)
|
||||||
{
|
{
|
||||||
@@ -213,6 +329,24 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
|
|
||||||
public abstract Task<bool> IsAvailableAsync();
|
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)
|
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>
|
/// <returns>Local file path where the track was saved</returns>
|
||||||
protected abstract Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken);
|
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>
|
/// <summary>
|
||||||
/// Extracts the external album ID from the internal album ID format.
|
/// Extracts the external album ID from the internal album ID format.
|
||||||
/// Example: "ext-deezer-album-123456" -> "123456"
|
/// Example: "ext-deezer-album-123456" -> "123456"
|
||||||
@@ -272,20 +423,25 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Internal method for downloading a song with control over album download triggering
|
/// Internal method for downloading a song with control over album download triggering
|
||||||
/// </summary>
|
/// </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)
|
if (externalProvider != ProviderName)
|
||||||
{
|
{
|
||||||
throw new NotSupportedException($"Provider '{externalProvider}' is not supported");
|
throw new NotSupportedException($"Provider '{externalProvider}' is not supported");
|
||||||
}
|
}
|
||||||
|
|
||||||
var songId = $"ext-{externalProvider}-{externalId}";
|
var songId = BuildTrackedSongId(externalProvider, externalId);
|
||||||
var isCache = SubsonicSettings.StorageMode == StorageMode.Cache;
|
var isCache = CurrentStorageMode == StorageMode.Cache;
|
||||||
|
|
||||||
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
|
bool isInitiator = false;
|
||||||
await DownloadLock.WaitAsync(cancellationToken);
|
|
||||||
var lockHeld = true;
|
|
||||||
|
|
||||||
|
// 1. Synchronous state check to prevent race conditions on checking existence or ActiveDownloads
|
||||||
|
await _stateSemaphore.WaitAsync(cancellationToken);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Check if already downloaded (works for both cache and permanent modes)
|
// 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
|
// Check if download in progress
|
||||||
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
||||||
{
|
{
|
||||||
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
|
if (requestedForStreaming)
|
||||||
// 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 client cancels, throw but let the download continue in background
|
activeDownload.RequestedForStreaming = true;
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
// Get metadata
|
||||||
// In Album mode, fetch the full album first to ensure AlbumArtist is correctly set
|
// In Album mode, fetch the full album first to ensure AlbumArtist is correctly set
|
||||||
Song? song = null;
|
Song? song = null;
|
||||||
|
|
||||||
if (SubsonicSettings.DownloadMode == DownloadMode.Album)
|
if (CurrentDownloadMode == DownloadMode.Album)
|
||||||
{
|
{
|
||||||
// First try to get the song to extract album ID
|
// First try to get the song to extract album ID
|
||||||
var tempSong = await MetadataService.GetSongAsync(externalProvider, externalId);
|
var tempSong = await MetadataService.GetSongAsync(externalProvider, externalId);
|
||||||
@@ -370,21 +559,23 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
throw new Exception("Song not found");
|
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,
|
info.Title = song.Title ?? "Unknown Title";
|
||||||
ExternalId = externalId,
|
info.Artist = song.Artist ?? "Unknown Artist";
|
||||||
ExternalProvider = externalProvider,
|
info.DurationSeconds = song.Duration;
|
||||||
Status = DownloadStatus.InProgress,
|
}
|
||||||
StartedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
ActiveDownloads[songId] = downloadInfo;
|
|
||||||
|
|
||||||
var localPath = await DownloadTrackAsync(externalId, song, cancellationToken);
|
var localPath = await DownloadTrackAsync(externalId, song, cancellationToken);
|
||||||
|
|
||||||
downloadInfo.Status = DownloadStatus.Completed;
|
if (ActiveDownloads.TryGetValue(songId, out var successInfo))
|
||||||
downloadInfo.LocalPath = localPath;
|
{
|
||||||
downloadInfo.CompletedAt = DateTime.UtcNow;
|
successInfo.Status = DownloadStatus.Completed;
|
||||||
|
successInfo.Progress = 1.0;
|
||||||
|
successInfo.LocalPath = localPath;
|
||||||
|
successInfo.CompletedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
song.LocalPath = localPath;
|
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 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);
|
var albumExternalId = ExtractExternalIdFromAlbumId(song.AlbumId);
|
||||||
if (!string.IsNullOrEmpty(albumExternalId))
|
if (!string.IsNullOrEmpty(albumExternalId))
|
||||||
@@ -467,12 +658,11 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
Logger.LogDebug("Cleaned up failed download tracking for {SongId}", songId);
|
Logger.LogDebug("Cleaned up failed download tracking for {SongId}", songId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
|
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
|
||||||
{
|
{
|
||||||
Logger.LogError("Download failed for {SongId}: {StatusCode}: {ReasonPhrase}",
|
Logger.LogError("Download failed for {SongId}: {StatusCode}: {ReasonPhrase}",
|
||||||
songId,
|
songId, (int)httpRequestException.StatusCode.Value, httpRequestException.StatusCode.Value);
|
||||||
(int)httpRequestException.StatusCode.Value,
|
|
||||||
httpRequestException.StatusCode.Value);
|
|
||||||
Logger.LogDebug(ex, "Detailed download failure for {SongId}", songId);
|
Logger.LogDebug(ex, "Detailed download failure for {SongId}", songId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -483,10 +673,7 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (lockHeld)
|
_concurrencySemaphore.Release();
|
||||||
{
|
|
||||||
DownloadLock.Release();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,7 +708,7 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if download is already in progress or recently completed
|
// 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 (ActiveDownloads.TryGetValue(songId, out var activeDownload))
|
||||||
{
|
{
|
||||||
if (activeDownload.Status == DownloadStatus.InProgress)
|
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);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public class CacheCleanupService : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await CleanupOldCachedFilesAsync(stoppingToken);
|
await CleanupOldCachedFilesAsync(stoppingToken);
|
||||||
|
await CleanupTranscodedCacheAsync(stoppingToken);
|
||||||
await ProcessPendingDeletionsAsync(stoppingToken);
|
await ProcessPendingDeletionsAsync(stoppingToken);
|
||||||
await Task.Delay(_cleanupInterval, 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)
|
private async Task CleanupEmptyDirectoriesAsync(string rootPath, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -49,4 +49,5 @@ public static class CacheExtensions
|
|||||||
public static TimeSpan MetadataTTL => GetCacheSettings().MetadataTTL;
|
public static TimeSpan MetadataTTL => GetCacheSettings().MetadataTTL;
|
||||||
public static TimeSpan OdesliLookupTTL => GetCacheSettings().OdesliLookupTTL;
|
public static TimeSpan OdesliLookupTTL => GetCacheSettings().OdesliLookupTTL;
|
||||||
public static TimeSpan ProxyImagesTTL => GetCacheSettings().ProxyImagesTTL;
|
public static TimeSpan ProxyImagesTTL => GetCacheSettings().ProxyImagesTTL;
|
||||||
|
public static TimeSpan TranscodeCacheTTL => GetCacheSettings().TranscodeCacheTTL;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,13 +153,22 @@ public static class CacheKeyBuilder
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Playlist Keys
|
#region Image Keys
|
||||||
|
|
||||||
public static string BuildPlaylistImageKey(string playlistId)
|
public static string BuildPlaylistImageKey(string playlistId)
|
||||||
{
|
{
|
||||||
return $"playlist:image:{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
|
#endregion
|
||||||
|
|
||||||
#region Genre Keys
|
#region Genre Keys
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ public class EnvMigrationService
|
|||||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Migrate DOWNLOAD_PATH to Library__DownloadPath
|
// Migrate Library__DownloadPath to DOWNLOAD_PATH (inverse migration)
|
||||||
if (line.StartsWith("DOWNLOAD_PATH="))
|
if (line.StartsWith("Library__DownloadPath="))
|
||||||
{
|
{
|
||||||
var value = line.Substring("DOWNLOAD_PATH=".Length);
|
var value = line.Substring("Library__DownloadPath=".Length);
|
||||||
lines[i] = $"Library__DownloadPath={value}";
|
lines[i] = $"DOWNLOAD_PATH={value}";
|
||||||
modified = true;
|
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
|
// Migrate old SquidWTF quality values to new format
|
||||||
@@ -104,10 +104,107 @@ public class EnvMigrationService
|
|||||||
File.WriteAllLines(_envFilePath, lines);
|
File.WriteAllLines(_envFilePath, lines);
|
||||||
_logger.LogInformation("✅ .env file migration completed successfully");
|
_logger.LogInformation("✅ .env file migration completed successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ReformatEnvFileIfSquashed();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to migrate .env file");
|
_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,39 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
|
public static class ImageConditionalRequestHelper
|
||||||
|
{
|
||||||
|
public static string ComputeStrongETag(byte[] payload)
|
||||||
|
{
|
||||||
|
var hash = SHA256.HashData(payload);
|
||||||
|
return $"\"{Convert.ToHexString(hash)}\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool MatchesIfNoneMatch(IHeaderDictionary headers, string etag)
|
||||||
|
{
|
||||||
|
if (!headers.TryGetValue("If-None-Match", out var headerValues))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var headerValue in headerValues)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(headerValue))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var candidate in headerValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
if (candidate == "*" || string.Equals(candidate, etag, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
/// <summary>
|
||||||
/// Deletes all keys matching a pattern (e.g., "search:*").
|
/// Deletes all keys matching a pattern (e.g., "search:*").
|
||||||
/// WARNING: Use with caution as this scans all keys.
|
/// 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)
|
||||||
|
/// < 192kbps → Low (covers 128K, 64K selections)
|
||||||
|
/// </summary>
|
||||||
|
private static StreamQuality MapBitRateToQuality(int bitRate)
|
||||||
|
{
|
||||||
|
// >= 192kbps → High (320kbps tier)
|
||||||
|
// Covers client selections: 320K, 256K, 192K
|
||||||
|
if (bitRate >= 192_000)
|
||||||
|
{
|
||||||
|
return StreamQuality.High;
|
||||||
|
}
|
||||||
|
|
||||||
|
// < 192kbps → Low (96-128kbps tier)
|
||||||
|
// Covers client selections: 128K, 64K
|
||||||
|
return StreamQuality.Low;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ public class VersionUpgradeRebuildService : IHostedService
|
|||||||
private readonly SpotifyTrackMatchingService _matchingService;
|
private readonly SpotifyTrackMatchingService _matchingService;
|
||||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||||
private readonly ILogger<VersionUpgradeRebuildService> _logger;
|
private readonly ILogger<VersionUpgradeRebuildService> _logger;
|
||||||
|
private CancellationTokenSource? _backgroundRebuildCts;
|
||||||
|
private Task? _backgroundRebuildTask;
|
||||||
|
|
||||||
public VersionUpgradeRebuildService(
|
public VersionUpgradeRebuildService(
|
||||||
SpotifyTrackMatchingService matchingService,
|
SpotifyTrackMatchingService matchingService,
|
||||||
@@ -53,15 +55,12 @@ public class VersionUpgradeRebuildService : IHostedService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Triggering full rebuild for all playlists after version upgrade");
|
_logger.LogInformation(
|
||||||
try
|
"Scheduling full rebuild for all playlists in background after version upgrade");
|
||||||
{
|
|
||||||
await _matchingService.TriggerRebuildAllAsync();
|
_backgroundRebuildCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
}
|
_backgroundRebuildTask = RunBackgroundRebuildAsync(currentVersion, _backgroundRebuildCts.Token);
|
||||||
catch (Exception ex)
|
return;
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -76,7 +75,51 @@ public class VersionUpgradeRebuildService : IHostedService
|
|||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return StopBackgroundRebuildAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunBackgroundRebuildAsync(string currentVersion, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting background full rebuild for all playlists after version upgrade");
|
||||||
|
await _matchingService.TriggerRebuildAllAsync(cancellationToken);
|
||||||
|
_logger.LogInformation("Background full rebuild after version upgrade completed");
|
||||||
|
await WriteCurrentVersionAsync(currentVersion, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Background full rebuild after version upgrade was cancelled before completion");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade");
|
||||||
|
await WriteCurrentVersionAsync(currentVersion, CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopBackgroundRebuildAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_backgroundRebuildTask == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_backgroundRebuildCts?.Cancel();
|
||||||
|
await _backgroundRebuildTask.WaitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Host shutdown is in progress or the background task observed cancellation.
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_backgroundRebuildCts?.Dispose();
|
||||||
|
_backgroundRebuildCts = null;
|
||||||
|
_backgroundRebuildTask = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> ReadPreviousVersionAsync(CancellationToken cancellationToken)
|
private async Task<string?> ReadPreviousVersionAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
_arl = deezer.Arl;
|
_arl = deezer.Arl;
|
||||||
_arlFallback = deezer.ArlFallback;
|
_arlFallback = deezer.ArlFallback;
|
||||||
_preferredQuality = deezer.Quality;
|
_preferredQuality = deezer.Quality;
|
||||||
|
_minRequestIntervalMs = deezer.MinRequestIntervalMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
#region BaseDownloadService Implementation
|
#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)
|
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
||||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||||
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
|
var basePath = CurrentStorageMode == StorageMode.Cache
|
||||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
? Path.Combine(DownloadPath, "cache")
|
||||||
? Path.Combine("downloads", "cache")
|
: Path.Combine(DownloadPath, "permanent");
|
||||||
: Path.Combine("downloads", "permanent");
|
|
||||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "deezer", trackId);
|
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "deezer", trackId);
|
||||||
|
|
||||||
// Create directories if they don't exist
|
// 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("User-Agent", "Mozilla/5.0");
|
||||||
request.Headers.Add("Accept", "*/*");
|
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);
|
}, Logger);
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
// Download and decrypt
|
// Download and decrypt
|
||||||
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
await using var outputFile = IOFile.Create(outputPath);
|
await using var outputFile = IOFile.Create(outputPath);
|
||||||
@@ -140,6 +140,140 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
#endregion
|
#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
|
#region Deezer API Methods
|
||||||
|
|
||||||
private async Task InitializeAsync(string? arlOverride = null)
|
private async Task InitializeAsync(string? arlOverride = null)
|
||||||
@@ -185,7 +319,7 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
}, Logger);
|
}, 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) =>
|
var tryDownload = async (string arl) =>
|
||||||
{
|
{
|
||||||
@@ -213,8 +347,8 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
// Get download URL via media API
|
// Get download URL via media API
|
||||||
// Build format list based on preferred quality
|
// Build format list based on preferred quality (or overridden quality for transcoding)
|
||||||
var formatsList = BuildFormatsList(_preferredQuality);
|
var formatsList = BuildFormatsList(qualityOverride ?? _preferredQuality);
|
||||||
|
|
||||||
var mediaRequest = new
|
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)
|
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -122,10 +135,15 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Execute searches in parallel
|
var songsTask = songLimit > 0
|
||||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
? SearchSongsAsync(query, songLimit, cancellationToken)
|
||||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
: Task.FromResult(new List<Song>());
|
||||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
var albumsTask = albumLimit > 0
|
||||||
|
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
|
||||||
|
: Task.FromResult(new List<Album>());
|
||||||
|
var artistsTask = artistLimit > 0
|
||||||
|
? SearchArtistsAsync(query, artistLimit, cancellationToken)
|
||||||
|
: Task.FromResult(new List<Artist>());
|
||||||
|
|
||||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||||
|
|
||||||
|
|||||||
@@ -21,13 +21,17 @@ public interface IDownloadService
|
|||||||
Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
|
Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="externalProvider">The provider (deezer, spotify)</param>
|
/// <param name="externalProvider">The provider (deezer, spotify)</param>
|
||||||
/// <param name="externalId">The ID on the external provider</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>
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
/// <returns>A stream of the audio file</returns>
|
/// <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>
|
/// <summary>
|
||||||
/// Downloads remaining tracks from an album in background (excluding the specified track)
|
/// Downloads remaining tracks from an album in background (excluding the specified track)
|
||||||
@@ -42,6 +46,11 @@ public interface IDownloadService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
DownloadInfo? GetDownloadStatus(string songId);
|
DownloadInfo? GetDownloadStatus(string songId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a snapshot of all active/recent downloads for the activity feed
|
||||||
|
/// </summary>
|
||||||
|
IReadOnlyList<DownloadInfo> GetActiveDownloads();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the local path for a song if it has been downloaded already
|
/// Gets the local path for a song if it has been downloaded already
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ public interface IMusicMetadataService
|
|||||||
/// Gets details of an external song
|
/// Gets details of an external song
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Song?> GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
|
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>
|
/// <summary>
|
||||||
/// Gets details of an external album with its songs
|
/// Gets details of an external album with its songs
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using allstarr.Models.Domain;
|
using allstarr.Models.Domain;
|
||||||
using allstarr.Models.Search;
|
using allstarr.Models.Search;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
|
||||||
namespace allstarr.Services.Jellyfin;
|
namespace allstarr.Services.Jellyfin;
|
||||||
|
|
||||||
@@ -186,10 +187,13 @@ public class JellyfinModelMapper
|
|||||||
|
|
||||||
// Cover art URL construction
|
// Cover art URL construction
|
||||||
song.CoverArtUrl = $"/Items/{id}/Images/Primary";
|
song.CoverArtUrl = $"/Items/{id}/Images/Primary";
|
||||||
|
|
||||||
// Preserve Jellyfin metadata (MediaSources, etc.) for local tracks
|
// Preserve the full raw item so cached local matches can be replayed without losing fields.
|
||||||
// This ensures bitrate and other technical details are maintained
|
JellyfinItemSnapshotHelper.StoreRawItemSnapshot(song, item);
|
||||||
song.JellyfinMetadata = new Dictionary<string, object?>();
|
|
||||||
|
// Preserve Jellyfin metadata (MediaSources, etc.) for local tracks.
|
||||||
|
// This ensures bitrate and other technical details are maintained.
|
||||||
|
song.JellyfinMetadata ??= new Dictionary<string, object?>();
|
||||||
if (item.TryGetProperty("MediaSources", out var mediaSources))
|
if (item.TryGetProperty("MediaSources", out var mediaSources))
|
||||||
{
|
{
|
||||||
song.JellyfinMetadata["MediaSources"] = JsonSerializer.Deserialize<object>(mediaSources.GetRawText());
|
song.JellyfinMetadata["MediaSources"] = JsonSerializer.Deserialize<object>(mediaSources.GetRawText());
|
||||||
|
|||||||
@@ -10,9 +10,17 @@ namespace allstarr.Services.Jellyfin;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles proxying requests to the Jellyfin server and authentication.
|
/// Handles proxying requests to the Jellyfin server and authentication.
|
||||||
|
/// Uses a named HttpClient ("JellyfinBackend") with SocketsHttpHandler for
|
||||||
|
/// TCP connection pooling across scoped instances.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class JellyfinProxyService
|
public class JellyfinProxyService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The IHttpClientFactory registration name for the Jellyfin backend client.
|
||||||
|
/// Configured with SocketsHttpHandler for connection pooling in Program.cs.
|
||||||
|
/// </summary>
|
||||||
|
public const string HttpClientName = "JellyfinBackend";
|
||||||
|
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly JellyfinSettings _settings;
|
private readonly JellyfinSettings _settings;
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
@@ -31,7 +39,7 @@ public class JellyfinProxyService
|
|||||||
ILogger<JellyfinProxyService> logger,
|
ILogger<JellyfinProxyService> logger,
|
||||||
RedisCacheService cache)
|
RedisCacheService cache)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_httpContextAccessor = httpContextAccessor;
|
_httpContextAccessor = httpContextAccessor;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -115,27 +123,37 @@ public class JellyfinProxyService
|
|||||||
var baseEndpoint = parts[0];
|
var baseEndpoint = parts[0];
|
||||||
var existingQuery = parts[1];
|
var existingQuery = parts[1];
|
||||||
|
|
||||||
// Parse existing query string
|
// Fast path: preserve the caller's raw query string exactly as provided.
|
||||||
var mergedParams = new Dictionary<string, string>();
|
// This is required for endpoints that legitimately repeat keys like Fields=...
|
||||||
foreach (var param in existingQuery.Split('&'))
|
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);
|
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)
|
var explicitParams = queryParams.Select(kv =>
|
||||||
if (queryParams != null)
|
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}");
|
||||||
{
|
|
||||||
foreach (var kv in queryParams)
|
var mergedQuery = string.Join("&", preservedParams.Concat(explicitParams));
|
||||||
{
|
var url = string.IsNullOrEmpty(mergedQuery)
|
||||||
mergedParams[kv.Key] = kv.Value;
|
? BuildUrl(baseEndpoint)
|
||||||
}
|
: $"{BuildUrl(baseEndpoint)}?{mergedQuery}";
|
||||||
}
|
|
||||||
|
|
||||||
var url = BuildUrl(baseEndpoint, mergedParams);
|
|
||||||
return await GetJsonAsyncInternal(url, clientHeaders);
|
return await GetJsonAsyncInternal(url, clientHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,62 +161,35 @@ public class JellyfinProxyService
|
|||||||
return await GetJsonAsyncInternal(finalUrl, clientHeaders);
|
return await GetJsonAsyncInternal(finalUrl, clientHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a proxied GET request to Jellyfin and returns the raw upstream response without buffering the body.
|
||||||
|
/// Intended for transparent passthrough of large JSON payloads that Allstarr does not modify.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<HttpResponseMessage> GetPassthroughResponseAsync(
|
||||||
|
string endpoint,
|
||||||
|
IHeaderDictionary? clientHeaders = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var url = BuildUrl(endpoint);
|
||||||
|
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
|
||||||
|
ForwardPassthroughRequestHeaders(clientHeaders, request);
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode && !isBrowserStaticRequest && !isPublicEndpoint)
|
||||||
|
{
|
||||||
|
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders)
|
private async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders)
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
|
||||||
|
|
||||||
// Forward client IP address to Jellyfin so it can identify the real client
|
|
||||||
if (_httpContextAccessor.HttpContext != null)
|
|
||||||
{
|
|
||||||
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
|
|
||||||
if (!string.IsNullOrEmpty(clientIp))
|
|
||||||
{
|
|
||||||
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
|
|
||||||
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool authHeaderAdded = false;
|
|
||||||
|
|
||||||
// Check if this is a browser request for static assets (favicon, etc.)
|
|
||||||
bool isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
url.Contains("/web/", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
(clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
h.Value.ToString().Contains("Mozilla", StringComparison.OrdinalIgnoreCase)) == true &&
|
|
||||||
clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
|
|
||||||
|
|
||||||
// Check if this is a public endpoint that doesn't require authentication
|
|
||||||
bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
// Forward authentication headers from client if provided
|
|
||||||
if (clientHeaders != null && clientHeaders.Count > 0)
|
|
||||||
{
|
|
||||||
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
|
||||||
|
|
||||||
if (authHeaderAdded)
|
|
||||||
{
|
|
||||||
_logger.LogTrace("Forwarded authentication headers");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for api_key query parameter (some clients use this)
|
|
||||||
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
authHeaderAdded = true; // It's in the URL, no need to add header
|
|
||||||
_logger.LogTrace("Using api_key from query string");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only log warnings for non-public, non-browser requests without auth
|
|
||||||
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url);
|
|
||||||
}
|
|
||||||
|
|
||||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request);
|
var response = await _httpClient.SendAsync(request);
|
||||||
|
|
||||||
@@ -235,16 +226,13 @@ public class JellyfinProxyService
|
|||||||
return (JsonDocument.Parse(content), statusCode);
|
return (JsonDocument.Parse(content), statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private HttpRequestMessage CreateClientGetRequest(
|
||||||
/// Sends a POST request to the Jellyfin server with JSON body.
|
string url,
|
||||||
/// Forwards client headers for authentication passthrough.
|
IHeaderDictionary? clientHeaders,
|
||||||
/// Returns the response body and HTTP status code.
|
out bool isBrowserStaticRequest,
|
||||||
/// </summary>
|
out bool isPublicEndpoint)
|
||||||
public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders)
|
|
||||||
{
|
{
|
||||||
var url = BuildUrl(endpoint, null);
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
|
||||||
|
|
||||||
// Forward client IP address to Jellyfin so it can identify the real client
|
// Forward client IP address to Jellyfin so it can identify the real client
|
||||||
if (_httpContextAccessor.HttpContext != null)
|
if (_httpContextAccessor.HttpContext != null)
|
||||||
@@ -257,58 +245,177 @@ public class JellyfinProxyService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle special case for playback endpoints
|
// Check if this is a browser request for static assets (favicon, etc.)
|
||||||
// NOTE: Jellyfin API expects PlaybackStartInfo/PlaybackProgressInfo/PlaybackStopInfo
|
isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
|
||||||
// DIRECTLY as the body, NOT wrapped in a field. Do NOT wrap the body.
|
url.Contains("/web/", StringComparison.OrdinalIgnoreCase) ||
|
||||||
var bodyToSend = body;
|
(clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) &&
|
||||||
if (string.IsNullOrWhiteSpace(body))
|
h.Value.ToString().Contains("Mozilla", StringComparison.OrdinalIgnoreCase)) == true &&
|
||||||
|
clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
(h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
|
||||||
|
|
||||||
|
// Check if this is a public endpoint that doesn't require authentication
|
||||||
|
isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var authHeaderAdded = false;
|
||||||
|
|
||||||
|
// Forward authentication headers from client if provided
|
||||||
|
if (clientHeaders != null && clientHeaders.Count > 0)
|
||||||
{
|
{
|
||||||
bodyToSend = "{}";
|
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
||||||
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
|
|
||||||
|
if (authHeaderAdded)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("Forwarded authentication headers");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for api_key query parameter (some clients use this)
|
||||||
|
if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
authHeaderAdded = true; // It's in the URL, no need to add header
|
||||||
|
_logger.LogTrace("Using api_key from query string");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
|
// Only log warnings for non-public, non-browser requests without auth
|
||||||
|
if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url);
|
||||||
|
}
|
||||||
|
|
||||||
bool authHeaderAdded = false;
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
// Forward authentication headers from client
|
private static void ForwardPassthroughRequestHeaders(
|
||||||
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
IHeaderDictionary? clientHeaders,
|
||||||
|
HttpRequestMessage request)
|
||||||
|
{
|
||||||
|
if (clientHeaders == null || clientHeaders.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientHeaders.TryGetValue("Accept-Encoding", out var acceptEncoding) &&
|
||||||
|
acceptEncoding.Count > 0)
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("Accept-Encoding", acceptEncoding.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientHeaders.TryGetValue("User-Agent", out var userAgent) &&
|
||||||
|
userAgent.Count > 0)
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("User-Agent", userAgent.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientHeaders.TryGetValue("Accept-Language", out var acceptLanguage) &&
|
||||||
|
acceptLanguage.Count > 0)
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("Accept-Language", acceptLanguage.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a POST request to the Jellyfin server with JSON body.
|
||||||
|
/// Forwards client headers for authentication passthrough.
|
||||||
|
/// Returns the response body and HTTP status code.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders)
|
||||||
|
{
|
||||||
|
var bodyToSend = body;
|
||||||
|
if (string.IsNullOrWhiteSpace(bodyToSend))
|
||||||
|
{
|
||||||
|
bodyToSend = "{}";
|
||||||
|
_logger.LogWarning("POST body was empty for {Endpoint}, sending empty JSON object", endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await SendAsync(HttpMethod.Post, endpoint, bodyToSend, clientHeaders, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an arbitrary HTTP request to Jellyfin while preserving the caller's method and body semantics.
|
||||||
|
/// Intended for transparent proxy scenarios such as session control routes.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(JsonDocument? Body, int StatusCode)> SendAsync(
|
||||||
|
HttpMethod method,
|
||||||
|
string endpoint,
|
||||||
|
string? body,
|
||||||
|
IHeaderDictionary clientHeaders,
|
||||||
|
string? contentType = null)
|
||||||
|
{
|
||||||
|
var url = BuildUrl(endpoint, null);
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(method, url);
|
||||||
|
|
||||||
|
// Forward client IP address to Jellyfin so it can identify the real client
|
||||||
|
if (_httpContextAccessor.HttpContext != null)
|
||||||
|
{
|
||||||
|
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(clientIp))
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body != null)
|
||||||
|
{
|
||||||
|
var requestContent = new StringContent(body, System.Text.Encoding.UTF8);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
requestContent.Headers.ContentType = !string.IsNullOrWhiteSpace(contentType)
|
||||||
|
? MediaTypeHeaderValue.Parse(contentType)
|
||||||
|
: new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Invalid content type '{ContentType}' for {Method} {Endpoint}; falling back to application/json",
|
||||||
|
contentType,
|
||||||
|
method,
|
||||||
|
endpoint);
|
||||||
|
requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
|
||||||
|
}
|
||||||
|
|
||||||
|
request.Content = requestContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
var authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
||||||
|
var isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (authHeaderAdded)
|
if (authHeaderAdded)
|
||||||
{
|
{
|
||||||
_logger.LogTrace("Forwarded authentication headers");
|
_logger.LogTrace("Forwarded authentication headers");
|
||||||
}
|
}
|
||||||
|
else if (!isAuthEndpoint)
|
||||||
// For authentication endpoints, credentials are in the body, not headers
|
|
||||||
// For other endpoints without auth, let Jellyfin reject the request
|
|
||||||
if (!authHeaderAdded && !isAuthEndpoint)
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url);
|
_logger.LogDebug("No client auth provided for {Method} {Url} - Jellyfin will handle authentication", method, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
// DO NOT log the body for auth endpoints - it contains passwords!
|
|
||||||
if (isAuthEndpoint)
|
if (isAuthEndpoint)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
|
_logger.LogDebug("{Method} to Jellyfin: {Url} (auth request - body not logged)", method, url);
|
||||||
|
}
|
||||||
|
else if (body == null)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("{Method} to Jellyfin: {Url} (no request body)", method, url);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
|
_logger.LogTrace("{Method} to Jellyfin: {Url}, body length: {Length} bytes", method, url, body.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request);
|
var response = await _httpClient.SendAsync(request);
|
||||||
|
|
||||||
var statusCode = (int)response.StatusCode;
|
var statusCode = (int)response.StatusCode;
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var errorContent = await response.Content.ReadAsStringAsync();
|
var errorContent = await response.Content.ReadAsStringAsync();
|
||||||
LogUpstreamFailure(HttpMethod.Post, response.StatusCode, url, errorContent);
|
LogUpstreamFailure(method, response.StatusCode, url, errorContent);
|
||||||
|
|
||||||
// Try to parse error response as JSON to pass through to client
|
|
||||||
if (!string.IsNullOrWhiteSpace(errorContent))
|
if (!string.IsNullOrWhiteSpace(errorContent))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -325,21 +432,17 @@ public class JellyfinProxyService
|
|||||||
return (null, statusCode);
|
return (null, statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log successful session-related responses
|
|
||||||
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
|
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogTrace("Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
|
_logger.LogTrace("Jellyfin responded {StatusCode} for {Method} {Endpoint}", statusCode, method, endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
|
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
|
||||||
{
|
{
|
||||||
return (null, statusCode);
|
return (null, statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
var responseContent = await response.Content.ReadAsStringAsync();
|
var responseContent = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
// Handle empty responses
|
|
||||||
if (string.IsNullOrWhiteSpace(responseContent))
|
if (string.IsNullOrWhiteSpace(responseContent))
|
||||||
{
|
{
|
||||||
return (null, statusCode);
|
return (null, statusCode);
|
||||||
@@ -401,65 +504,7 @@ public class JellyfinProxyService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<(JsonDocument? Body, int StatusCode)> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
|
public async Task<(JsonDocument? Body, int StatusCode)> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
|
||||||
{
|
{
|
||||||
var url = BuildUrl(endpoint, null);
|
return await SendAsync(HttpMethod.Delete, endpoint, null, clientHeaders);
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
|
|
||||||
|
|
||||||
// Forward client IP address to Jellyfin so it can identify the real client
|
|
||||||
if (_httpContextAccessor.HttpContext != null)
|
|
||||||
{
|
|
||||||
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
|
|
||||||
if (!string.IsNullOrEmpty(clientIp))
|
|
||||||
{
|
|
||||||
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
|
|
||||||
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool authHeaderAdded = false;
|
|
||||||
|
|
||||||
// Forward authentication headers from client
|
|
||||||
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
|
||||||
|
|
||||||
if (!authHeaderAdded)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogTrace("Forwarded authentication headers");
|
|
||||||
}
|
|
||||||
|
|
||||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
|
||||||
|
|
||||||
_logger.LogDebug("DELETE to Jellyfin: {Url}", url);
|
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request);
|
|
||||||
|
|
||||||
var statusCode = (int)response.StatusCode;
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var errorContent = await response.Content.ReadAsStringAsync();
|
|
||||||
LogUpstreamFailure(HttpMethod.Delete, response.StatusCode, url, errorContent);
|
|
||||||
return (null, statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle 204 No Content responses
|
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
|
||||||
{
|
|
||||||
return (null, statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
var responseContent = await response.Content.ReadAsStringAsync();
|
|
||||||
|
|
||||||
// Handle empty responses
|
|
||||||
if (string.IsNullOrWhiteSpace(responseContent))
|
|
||||||
{
|
|
||||||
return (null, statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (JsonDocument.Parse(responseContent), statusCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ public class JellyfinResponseBuilder
|
|||||||
["Tags"] = new string[0],
|
["Tags"] = new string[0],
|
||||||
["People"] = new object[0],
|
["People"] = new object[0],
|
||||||
["SortName"] = songTitle,
|
["SortName"] = songTitle,
|
||||||
|
["AudioInfo"] = new Dictionary<string, object?>(),
|
||||||
["ParentLogoItemId"] = song.AlbumId,
|
["ParentLogoItemId"] = song.AlbumId,
|
||||||
["ParentBackdropItemId"] = song.AlbumId,
|
["ParentBackdropItemId"] = song.AlbumId,
|
||||||
["ParentBackdropImageTags"] = new string[0],
|
["ParentBackdropImageTags"] = new string[0],
|
||||||
@@ -405,6 +406,7 @@ public class JellyfinResponseBuilder
|
|||||||
["MediaType"] = "Audio",
|
["MediaType"] = "Audio",
|
||||||
["NormalizationGain"] = 0.0,
|
["NormalizationGain"] = 0.0,
|
||||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||||
|
["CanDelete"] = false,
|
||||||
["CanDownload"] = true,
|
["CanDownload"] = true,
|
||||||
["SupportsSync"] = true
|
["SupportsSync"] = true
|
||||||
};
|
};
|
||||||
@@ -539,6 +541,7 @@ public class JellyfinResponseBuilder
|
|||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Id"] = album.Id,
|
["Id"] = album.Id,
|
||||||
["PremiereDate"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : null,
|
["PremiereDate"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : null,
|
||||||
|
["DateCreated"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : "1970-01-01T00:00:00.0000000Z",
|
||||||
["ChannelId"] = (object?)null,
|
["ChannelId"] = (object?)null,
|
||||||
["Genres"] = !string.IsNullOrEmpty(album.Genre)
|
["Genres"] = !string.IsNullOrEmpty(album.Genre)
|
||||||
? new[] { album.Genre }
|
? new[] { album.Genre }
|
||||||
@@ -547,6 +550,8 @@ public class JellyfinResponseBuilder
|
|||||||
["ProductionYear"] = album.Year,
|
["ProductionYear"] = album.Year,
|
||||||
["IsFolder"] = true,
|
["IsFolder"] = true,
|
||||||
["Type"] = "MusicAlbum",
|
["Type"] = "MusicAlbum",
|
||||||
|
["SortName"] = albumName,
|
||||||
|
["BasicSyncInfo"] = new Dictionary<string, object?>(),
|
||||||
["GenreItems"] = !string.IsNullOrEmpty(album.Genre)
|
["GenreItems"] = !string.IsNullOrEmpty(album.Genre)
|
||||||
? new[]
|
? new[]
|
||||||
{
|
{
|
||||||
@@ -633,6 +638,9 @@ public class JellyfinResponseBuilder
|
|||||||
["RunTimeTicks"] = 0,
|
["RunTimeTicks"] = 0,
|
||||||
["IsFolder"] = true,
|
["IsFolder"] = true,
|
||||||
["Type"] = "MusicArtist",
|
["Type"] = "MusicArtist",
|
||||||
|
["SortName"] = artistName,
|
||||||
|
["PrimaryImageAspectRatio"] = 1.0,
|
||||||
|
["BasicSyncInfo"] = new Dictionary<string, object?>(),
|
||||||
["GenreItems"] = new Dictionary<string, object?>[0],
|
["GenreItems"] = new Dictionary<string, object?>[0],
|
||||||
["UserData"] = new Dictionary<string, object>
|
["UserData"] = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
@@ -755,6 +763,11 @@ public class JellyfinResponseBuilder
|
|||||||
["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond,
|
["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond,
|
||||||
["IsFolder"] = true,
|
["IsFolder"] = true,
|
||||||
["Type"] = "MusicAlbum",
|
["Type"] = "MusicAlbum",
|
||||||
|
["SortName"] = $"{playlist.Name} [S/P]",
|
||||||
|
["DateCreated"] = playlist.CreatedDate.HasValue
|
||||||
|
? playlist.CreatedDate.Value.ToString("o")
|
||||||
|
: "1970-01-01T00:00:00.0000000Z",
|
||||||
|
["BasicSyncInfo"] = new Dictionary<string, object?>(),
|
||||||
["GenreItems"] = new Dictionary<string, object?>[0],
|
["GenreItems"] = new Dictionary<string, object?>[0],
|
||||||
["UserData"] = new Dictionary<string, object>
|
["UserData"] = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
private readonly ILogger<JellyfinSessionManager> _logger;
|
private readonly ILogger<JellyfinSessionManager> _logger;
|
||||||
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
|
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
|
||||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _sessionInitLocks = new();
|
private readonly ConcurrentDictionary<string, SemaphoreSlim> _sessionInitLocks = new();
|
||||||
|
private readonly ConcurrentDictionary<string, byte> _proxiedWebSocketConnections = new();
|
||||||
private readonly Timer _keepAliveTimer;
|
private readonly Timer _keepAliveTimer;
|
||||||
|
|
||||||
public JellyfinSessionManager(
|
public JellyfinSessionManager(
|
||||||
@@ -53,21 +54,28 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
await initLock.WaitAsync();
|
await initLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var hasProxiedWebSocket = HasProxiedWebSocket(deviceId);
|
||||||
|
|
||||||
// Check if we already have this session tracked
|
// Check if we already have this session tracked
|
||||||
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
||||||
{
|
{
|
||||||
existingSession.LastActivity = DateTime.UtcNow;
|
existingSession.LastActivity = DateTime.UtcNow;
|
||||||
|
existingSession.HasProxiedWebSocket = hasProxiedWebSocket;
|
||||||
_logger.LogInformation("Session already exists for device {DeviceId}", deviceId);
|
_logger.LogInformation("Session already exists for device {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Refresh capabilities to keep session alive
|
if (!hasProxiedWebSocket)
|
||||||
// If this returns false (401), the token expired and client needs to re-auth
|
|
||||||
var refreshOk = await PostCapabilitiesAsync(headers);
|
|
||||||
if (!refreshOk)
|
|
||||||
{
|
{
|
||||||
// Token expired - remove the stale session
|
// Refresh capabilities to keep session alive only for sessions that Allstarr
|
||||||
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
|
// is synthesizing itself. Native proxied websocket sessions should be left
|
||||||
await RemoveSessionAsync(deviceId);
|
// entirely under Jellyfin's control.
|
||||||
return false;
|
var refreshOk = await PostCapabilitiesAsync(headers);
|
||||||
|
if (!refreshOk)
|
||||||
|
{
|
||||||
|
// Token expired - remove the stale session
|
||||||
|
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
|
||||||
|
await RemoveSessionAsync(deviceId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -75,16 +83,26 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
|
|
||||||
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
|
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
|
||||||
|
|
||||||
// Post session capabilities to Jellyfin - this creates the session
|
if (!hasProxiedWebSocket)
|
||||||
var createOk = await PostCapabilitiesAsync(headers);
|
|
||||||
if (!createOk)
|
|
||||||
{
|
{
|
||||||
// Token expired or invalid - client needs to re-authenticate
|
// Post session capabilities to Jellyfin only when Allstarr is creating a
|
||||||
_logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId);
|
// synthetic session. If the real client already has a proxied websocket,
|
||||||
return false;
|
// re-posting capabilities can overwrite its remote-control state.
|
||||||
}
|
var createOk = await PostCapabilitiesAsync(headers);
|
||||||
|
if (!createOk)
|
||||||
|
{
|
||||||
|
// Token expired or invalid - client needs to re-authenticate
|
||||||
|
_logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Session created for {DeviceId}", deviceId);
|
_logger.LogInformation("Session created for {DeviceId}", deviceId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping synthetic Jellyfin session bootstrap for proxied websocket device {DeviceId}",
|
||||||
|
deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
// Track this session
|
// Track this session
|
||||||
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
||||||
@@ -99,11 +117,16 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
Version = version,
|
Version = version,
|
||||||
LastActivity = DateTime.UtcNow,
|
LastActivity = DateTime.UtcNow,
|
||||||
Headers = CloneHeaders(headers),
|
Headers = CloneHeaders(headers),
|
||||||
ClientIp = clientIp
|
ClientIp = clientIp,
|
||||||
|
HasProxiedWebSocket = hasProxiedWebSocket
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
// Start a synthetic WebSocket connection only when the client itself does not
|
||||||
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
|
// already have a proxied Jellyfin socket through Allstarr.
|
||||||
|
if (!hasProxiedWebSocket)
|
||||||
|
{
|
||||||
|
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -118,6 +141,44 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task RegisterProxiedWebSocketAsync(string deviceId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(deviceId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_proxiedWebSocketConnections[deviceId] = 0;
|
||||||
|
|
||||||
|
if (_sessions.TryGetValue(deviceId, out var session))
|
||||||
|
{
|
||||||
|
session.HasProxiedWebSocket = true;
|
||||||
|
session.LastActivity = DateTime.UtcNow;
|
||||||
|
await CloseSyntheticWebSocketAsync(deviceId, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UnregisterProxiedWebSocket(string deviceId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(deviceId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_proxiedWebSocketConnections.TryRemove(deviceId, out _);
|
||||||
|
|
||||||
|
if (_sessions.TryGetValue(deviceId, out var session))
|
||||||
|
{
|
||||||
|
session.HasProxiedWebSocket = false;
|
||||||
|
session.LastActivity = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasProxiedWebSocket(string deviceId)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(deviceId) && _proxiedWebSocketConnections.ContainsKey(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Posts session capabilities to Jellyfin.
|
/// Posts session capabilities to Jellyfin.
|
||||||
/// Returns true if successful, false if token expired (401).
|
/// Returns true if successful, false if token expired (401).
|
||||||
@@ -296,6 +357,25 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
return (null, null);
|
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>
|
/// <summary>
|
||||||
/// Marks a session as potentially ended (e.g., after playback stops).
|
/// Marks a session as potentially ended (e.g., after playback stops).
|
||||||
/// Jellyfin should decide when the upstream playback session expires.
|
/// Jellyfin should decide when the upstream playback session expires.
|
||||||
@@ -326,8 +406,10 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
ClientIp = s.ClientIp,
|
ClientIp = s.ClientIp,
|
||||||
LastActivity = s.LastActivity,
|
LastActivity = s.LastActivity,
|
||||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||||
HasWebSocket = s.WebSocket != null,
|
HasWebSocket = s.HasProxiedWebSocket || s.WebSocket != null,
|
||||||
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
|
HasProxiedWebSocket = s.HasProxiedWebSocket,
|
||||||
|
HasSyntheticWebSocket = s.WebSocket != null,
|
||||||
|
WebSocketState = s.HasProxiedWebSocket ? "Proxied" : s.WebSocket?.State.ToString() ?? "None"
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return new
|
return new
|
||||||
@@ -344,6 +426,8 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task RemoveSessionAsync(string deviceId)
|
public async Task RemoveSessionAsync(string deviceId)
|
||||||
{
|
{
|
||||||
|
_proxiedWebSocketConnections.TryRemove(deviceId, out _);
|
||||||
|
|
||||||
if (_sessions.TryRemove(deviceId, out var session))
|
if (_sessions.TryRemove(deviceId, out var session))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
|
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
|
||||||
@@ -403,6 +487,12 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session.HasProxiedWebSocket || HasProxiedWebSocket(deviceId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping synthetic Jellyfin websocket for proxied device {DeviceId}", deviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ClientWebSocket? webSocket = null;
|
ClientWebSocket? webSocket = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -506,6 +596,13 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (HasProxiedWebSocket(deviceId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Stopping synthetic Jellyfin websocket because proxied client websocket is active for {DeviceId}",
|
||||||
|
deviceId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// Use a timeout so we can send keep-alive messages periodically
|
// Use a timeout so we can send keep-alive messages periodically
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
|
||||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
|
||||||
@@ -616,6 +713,12 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
session.HasProxiedWebSocket = HasProxiedWebSocket(session.DeviceId);
|
||||||
|
if (session.HasProxiedWebSocket)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Post capabilities again to keep session alive
|
// Post capabilities again to keep session alive
|
||||||
// If this returns false (401), the token has expired
|
// If this returns false (401), the token has expired
|
||||||
var success = await PostCapabilitiesAsync(session.Headers);
|
var success = await PostCapabilitiesAsync(session.Headers);
|
||||||
@@ -676,8 +779,15 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
public string? LastLocalPlayedSignalItemId { get; set; }
|
public string? LastLocalPlayedSignalItemId { get; set; }
|
||||||
public string? LastExplicitStopItemId { get; set; }
|
public string? LastExplicitStopItemId { get; set; }
|
||||||
public DateTime? LastExplicitStopAtUtc { get; set; }
|
public DateTime? LastExplicitStopAtUtc { get; set; }
|
||||||
|
public bool HasProxiedWebSocket { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record ActivePlaybackState(
|
||||||
|
string DeviceId,
|
||||||
|
string ItemId,
|
||||||
|
long PositionTicks,
|
||||||
|
DateTime LastActivity);
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_keepAliveTimer?.Dispose();
|
_keepAliveTimer?.Dispose();
|
||||||
@@ -704,4 +814,31 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task CloseSyntheticWebSocketAsync(string deviceId, SessionInfo session)
|
||||||
|
{
|
||||||
|
var syntheticSocket = session.WebSocket;
|
||||||
|
if (syntheticSocket == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.WebSocket = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (syntheticSocket.State == WebSocketState.Open || syntheticSocket.State == WebSocketState.CloseReceived)
|
||||||
|
{
|
||||||
|
await syntheticSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Native client websocket active", CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to close synthetic Jellyfin websocket for proxied device {DeviceId}", deviceId);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
syntheticSocket.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ public class QobuzDownloadService : BaseDownloadService
|
|||||||
_userAuthToken = qobuzConfig.UserAuthToken;
|
_userAuthToken = qobuzConfig.UserAuthToken;
|
||||||
_userId = qobuzConfig.UserId;
|
_userId = qobuzConfig.UserId;
|
||||||
_preferredQuality = qobuzConfig.Quality;
|
_preferredQuality = qobuzConfig.Quality;
|
||||||
|
_minRequestIntervalMs = qobuzConfig.MinRequestIntervalMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
#region BaseDownloadService Implementation
|
#region BaseDownloadService Implementation
|
||||||
@@ -101,8 +102,7 @@ public class QobuzDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
|
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
|
||||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||||
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/
|
var basePath = CurrentStorageMode == StorageMode.Cache
|
||||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
|
||||||
? Path.Combine(DownloadPath, "cache")
|
? Path.Combine(DownloadPath, "cache")
|
||||||
: Path.Combine(DownloadPath, "permanent");
|
: Path.Combine(DownloadPath, "permanent");
|
||||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "qobuz", trackId);
|
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);
|
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
||||||
|
|
||||||
// Download the file (Qobuz files are NOT encrypted like Deezer)
|
// Download the file (Qobuz files are NOT encrypted like Deezer)
|
||||||
var response = await _httpClient.GetAsync(downloadInfo.Url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
var response = await RetryHelper.RetryWithBackoffAsync(async () =>
|
||||||
response.EnsureSuccessStatusCode();
|
{
|
||||||
|
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 responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
await using var outputFile = IOFile.Create(outputPath);
|
await using var outputFile = IOFile.Create(outputPath);
|
||||||
@@ -130,6 +134,143 @@ public class QobuzDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
#endregion
|
#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
|
#region Qobuz Download Methods
|
||||||
|
|
||||||
/// <summary>
|
/// <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)
|
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -147,9 +160,15 @@ public class QobuzMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
var songsTask = songLimit > 0
|
||||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
? SearchSongsAsync(query, songLimit, cancellationToken)
|
||||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
: Task.FromResult(new List<Song>());
|
||||||
|
var albumsTask = albumLimit > 0
|
||||||
|
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
|
||||||
|
: Task.FromResult(new List<Album>());
|
||||||
|
var artistsTask = artistLimit > 0
|
||||||
|
? SearchArtistsAsync(query, artistLimit, cancellationToken)
|
||||||
|
: Task.FromResult(new List<Artist>());
|
||||||
|
|
||||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||||
|
|
||||||
|
|||||||
@@ -1026,26 +1026,7 @@ public class SpotifyApiClient : IDisposable
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get track count if available - try multiple possible paths
|
var trackCount = TryGetSpotifyPlaylistItemCount(playlist);
|
||||||
var trackCount = 0;
|
|
||||||
if (playlist.TryGetProperty("content", out var content))
|
|
||||||
{
|
|
||||||
if (content.TryGetProperty("totalCount", out var totalTrackCount))
|
|
||||||
{
|
|
||||||
trackCount = totalTrackCount.GetInt32();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: try attributes.itemCount
|
|
||||||
else if (playlist.TryGetProperty("attributes", out var attributes) &&
|
|
||||||
attributes.TryGetProperty("itemCount", out var itemCountProp))
|
|
||||||
{
|
|
||||||
trackCount = itemCountProp.GetInt32();
|
|
||||||
}
|
|
||||||
// Fallback: try totalCount directly
|
|
||||||
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
|
|
||||||
{
|
|
||||||
trackCount = directTotalCount.GetInt32();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log if we couldn't find track count for debugging
|
// Log if we couldn't find track count for debugging
|
||||||
if (trackCount == 0)
|
if (trackCount == 0)
|
||||||
@@ -1057,7 +1038,9 @@ public class SpotifyApiClient : IDisposable
|
|||||||
// Get owner name
|
// Get owner name
|
||||||
string? ownerName = null;
|
string? ownerName = null;
|
||||||
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
||||||
|
ownerV2.ValueKind == JsonValueKind.Object &&
|
||||||
ownerV2.TryGetProperty("data", out var ownerData) &&
|
ownerV2.TryGetProperty("data", out var ownerData) &&
|
||||||
|
ownerData.ValueKind == JsonValueKind.Object &&
|
||||||
ownerData.TryGetProperty("username", out var ownerNameProp))
|
ownerData.TryGetProperty("username", out var ownerNameProp))
|
||||||
{
|
{
|
||||||
ownerName = ownerNameProp.GetString();
|
ownerName = ownerNameProp.GetString();
|
||||||
@@ -1066,11 +1049,14 @@ public class SpotifyApiClient : IDisposable
|
|||||||
// Get image URL
|
// Get image URL
|
||||||
string? imageUrl = null;
|
string? imageUrl = null;
|
||||||
if (playlist.TryGetProperty("images", out var images) &&
|
if (playlist.TryGetProperty("images", out var images) &&
|
||||||
|
images.ValueKind == JsonValueKind.Object &&
|
||||||
images.TryGetProperty("items", out var imageItems) &&
|
images.TryGetProperty("items", out var imageItems) &&
|
||||||
|
imageItems.ValueKind == JsonValueKind.Array &&
|
||||||
imageItems.GetArrayLength() > 0)
|
imageItems.GetArrayLength() > 0)
|
||||||
{
|
{
|
||||||
var firstImage = imageItems[0];
|
var firstImage = imageItems[0];
|
||||||
if (firstImage.TryGetProperty("sources", out var sources) &&
|
if (firstImage.TryGetProperty("sources", out var sources) &&
|
||||||
|
sources.ValueKind == JsonValueKind.Array &&
|
||||||
sources.GetArrayLength() > 0)
|
sources.GetArrayLength() > 0)
|
||||||
{
|
{
|
||||||
var firstSource = sources[0];
|
var firstSource = sources[0];
|
||||||
@@ -1165,6 +1151,68 @@ public class SpotifyApiClient : IDisposable
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int TryGetSpotifyPlaylistItemCount(JsonElement playlistElement)
|
||||||
|
{
|
||||||
|
if (playlistElement.TryGetProperty("content", out var content) &&
|
||||||
|
content.ValueKind == JsonValueKind.Object &&
|
||||||
|
content.TryGetProperty("totalCount", out var totalTrackCount) &&
|
||||||
|
TryParseSpotifyIntegerElement(totalTrackCount, out var contentCount))
|
||||||
|
{
|
||||||
|
return contentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlistElement.TryGetProperty("attributes", out var attributes))
|
||||||
|
{
|
||||||
|
if (attributes.ValueKind == JsonValueKind.Object &&
|
||||||
|
attributes.TryGetProperty("itemCount", out var itemCountProp) &&
|
||||||
|
TryParseSpotifyIntegerElement(itemCountProp, out var directAttributeCount))
|
||||||
|
{
|
||||||
|
return directAttributeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attributes.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
foreach (var attribute in attributes.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (attribute.ValueKind != JsonValueKind.Object ||
|
||||||
|
!attribute.TryGetProperty("key", out var keyProp) ||
|
||||||
|
keyProp.ValueKind != JsonValueKind.String ||
|
||||||
|
!attribute.TryGetProperty("value", out var valueProp))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = keyProp.GetString();
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedKey = key.Replace("_", "", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace(":", "", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!normalizedKey.Contains("itemcount", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!normalizedKey.Contains("trackcount", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryParseSpotifyIntegerElement(valueProp, out var attributeCount))
|
||||||
|
{
|
||||||
|
return attributeCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlistElement.TryGetProperty("totalCount", out var directTotalCount) &&
|
||||||
|
TryParseSpotifyIntegerElement(directTotalCount, out var totalCount))
|
||||||
|
{
|
||||||
|
return totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
private static DateTime? ParseSpotifyDateElement(JsonElement value)
|
private static DateTime? ParseSpotifyDateElement(JsonElement value)
|
||||||
{
|
{
|
||||||
switch (value.ValueKind)
|
switch (value.ValueKind)
|
||||||
@@ -1238,6 +1286,40 @@ public class SpotifyApiClient : IDisposable
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryParseSpotifyIntegerElement(JsonElement value, out int parsed)
|
||||||
|
{
|
||||||
|
switch (value.ValueKind)
|
||||||
|
{
|
||||||
|
case JsonValueKind.Number:
|
||||||
|
return value.TryGetInt32(out parsed);
|
||||||
|
case JsonValueKind.String:
|
||||||
|
return int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||||
|
case JsonValueKind.Object:
|
||||||
|
if (value.TryGetProperty("value", out var nestedValue) &&
|
||||||
|
TryParseSpotifyIntegerElement(nestedValue, out parsed))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.TryGetProperty("itemCount", out var itemCount) &&
|
||||||
|
TryParseSpotifyIntegerElement(itemCount, out parsed))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.TryGetProperty("totalCount", out var totalCount) &&
|
||||||
|
TryParseSpotifyIntegerElement(totalCount, out parsed))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static DateTime? ParseSpotifyUnixTimestamp(long value)
|
private static DateTime? ParseSpotifyUnixTimestamp(long value)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
// Re-fetch
|
// Re-fetch
|
||||||
await GetPlaylistTracksAsync(playlistName);
|
await GetPlaylistTracksAsync(playlistName);
|
||||||
|
await ClearPlaylistImageCacheAsync(playlistName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -262,6 +263,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ClearPlaylistImageCacheAsync(string playlistName)
|
||||||
|
{
|
||||||
|
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||||
|
if (playlistConfig == null || string.IsNullOrWhiteSpace(playlistConfig.JellyfinId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedCount = await _cache.DeleteByPatternAsync($"image:{playlistConfig.JellyfinId}:*");
|
||||||
|
_logger.LogDebug("Cleared {Count} cached local image entries for playlist {Playlist}",
|
||||||
|
deletedCount,
|
||||||
|
playlistName);
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("========================================");
|
_logger.LogInformation("========================================");
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ namespace allstarr.Services.Spotify;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class SpotifyTrackMatchingService : BackgroundService
|
public class SpotifyTrackMatchingService : BackgroundService
|
||||||
{
|
{
|
||||||
|
private const string CachedPlaylistItemFields =
|
||||||
|
"Genres,GenreItems,DateCreated,MediaSources,ParentId,People,Tags,SortName,UserData,ProviderIds";
|
||||||
|
|
||||||
private readonly SpotifyImportSettings _spotifySettings;
|
private readonly SpotifyImportSettings _spotifySettings;
|
||||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
@@ -35,6 +38,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
||||||
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
||||||
|
private static readonly TimeSpan ExternalProviderSearchTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
// Track last run time per playlist to prevent duplicate runs
|
// Track last run time per playlist to prevent duplicate runs
|
||||||
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
|
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
|
||||||
@@ -292,6 +296,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ClearPlaylistImageCacheAsync(playlist);
|
||||||
_logger.LogInformation("✓ Rebuild complete for {Playlist}", playlistName);
|
_logger.LogInformation("✓ Rebuild complete for {Playlist}", playlistName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +339,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
await MatchPlaylistTracksLegacyAsync(
|
await MatchPlaylistTracksLegacyAsync(
|
||||||
playlist.Name, metadataService, cancellationToken);
|
playlist.Name, metadataService, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ClearPlaylistImageCacheAsync(playlist);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -342,14 +349,27 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ClearPlaylistImageCacheAsync(SpotifyPlaylistConfig playlist)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(playlist.JellyfinId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedCount = await _cache.DeleteByPatternAsync($"image:{playlist.JellyfinId}:*");
|
||||||
|
_logger.LogDebug("Cleared {Count} cached local image entries for playlist {Playlist}",
|
||||||
|
deletedCount,
|
||||||
|
playlist.Name);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
|
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
|
||||||
/// This clears caches, fetches fresh data, and re-matches everything immediately.
|
/// This clears caches, fetches fresh data, and re-matches everything immediately.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task TriggerRebuildAllAsync()
|
public async Task TriggerRebuildAllAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Manual full rebuild triggered for all playlists");
|
_logger.LogInformation("Full rebuild triggered for all playlists");
|
||||||
await RebuildAllPlaylistsAsync(CancellationToken.None);
|
await RebuildAllPlaylistsAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -594,6 +614,16 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Only re-match if cache is missing OR if we detect manual mappings that need to be applied
|
// Only re-match if cache is missing OR if we detect manual mappings that need to be applied
|
||||||
if (existingMatched != null && existingMatched.Count > 0)
|
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
|
// Check if we have NEW manual mappings that aren't in the cache
|
||||||
var hasNewManualMappings = false;
|
var hasNewManualMappings = false;
|
||||||
foreach (var track in tracksToMatch)
|
foreach (var track in tracksToMatch)
|
||||||
@@ -616,14 +646,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",
|
_logger.LogWarning("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
|
||||||
playlistName, existingMatched.Count, tracksToMatch.Count);
|
playlistName, existingMatched.Count, tracksToMatch.Count);
|
||||||
return;
|
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)
|
// PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin)
|
||||||
@@ -633,6 +665,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||||
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
|
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
|
||||||
|
var jellyfinModelMapper = scope.ServiceProvider.GetService<JellyfinModelMapper>();
|
||||||
|
|
||||||
if (proxyService != null && jellyfinSettings != null)
|
if (proxyService != null && jellyfinSettings != null)
|
||||||
{
|
{
|
||||||
@@ -640,7 +673,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
var userId = jellyfinSettings.UserId;
|
var userId = jellyfinSettings.UserId;
|
||||||
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
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))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
queryParams["UserId"] = userId;
|
queryParams["UserId"] = userId;
|
||||||
@@ -652,14 +685,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
foreach (var item in items.EnumerateArray())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
var song = new Song
|
var song = jellyfinModelMapper?.ParseSong(item) ?? CreateLocalSongSnapshot(item);
|
||||||
{
|
|
||||||
Id = item.GetProperty("Id").GetString() ?? "",
|
|
||||||
Title = item.GetProperty("Name").GetString() ?? "",
|
|
||||||
Artist = item.TryGetProperty("AlbumArtist", out var artist) ? artist.GetString() ?? "" : "",
|
|
||||||
Album = item.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
|
|
||||||
IsLocal = true
|
|
||||||
};
|
|
||||||
jellyfinTracks.Add(song);
|
jellyfinTracks.Add(song);
|
||||||
}
|
}
|
||||||
_logger.LogInformation("📚 Loaded {Count} tracks from Jellyfin playlist {Playlist}",
|
_logger.LogInformation("📚 Loaded {Count} tracks from Jellyfin playlist {Playlist}",
|
||||||
@@ -748,11 +774,28 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
if (cancellationToken.IsCancellationRequested) break;
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList();
|
var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList();
|
||||||
|
var batchStart = i + 1;
|
||||||
|
var batchEnd = i + batch.Count;
|
||||||
|
var batchStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Starting external matching batch for {Playlist}: tracks {Start}-{End}/{Total}",
|
||||||
|
playlistName,
|
||||||
|
batchStart,
|
||||||
|
batchEnd,
|
||||||
|
unmatchedSpotifyTracks.Count);
|
||||||
|
|
||||||
var batchTasks = batch.Select(async spotifyTrack =>
|
var batchTasks = batch.Select(async spotifyTrack =>
|
||||||
{
|
{
|
||||||
|
var primaryArtist = spotifyTrack.PrimaryArtist;
|
||||||
|
var trackStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeoutCts.CancelAfter(ExternalProviderSearchTimeout);
|
||||||
|
var trackCancellationToken = timeoutCts.Token;
|
||||||
|
|
||||||
var candidates = new List<(Song Song, double Score, string MatchType)>();
|
var candidates = new List<(Song Song, double Score, string MatchType)>();
|
||||||
|
|
||||||
// Check global external mapping first
|
// Check global external mapping first
|
||||||
@@ -764,12 +807,23 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) &&
|
if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) &&
|
||||||
!string.IsNullOrEmpty(globalMapping.ExternalId))
|
!string.IsNullOrEmpty(globalMapping.ExternalId))
|
||||||
{
|
{
|
||||||
mappedSong = await metadataService.GetSongAsync(globalMapping.ExternalProvider, globalMapping.ExternalId);
|
mappedSong = await metadataService.GetSongAsync(
|
||||||
|
globalMapping.ExternalProvider,
|
||||||
|
globalMapping.ExternalId,
|
||||||
|
trackCancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mappedSong != null)
|
if (mappedSong != null)
|
||||||
{
|
{
|
||||||
candidates.Add((mappedSong, 100.0, "global-mapping-external"));
|
candidates.Add((mappedSong, 100.0, "global-mapping-external"));
|
||||||
|
trackStopwatch.Stop();
|
||||||
|
_logger.LogDebug(
|
||||||
|
"External candidate search finished for {Playlist} track #{Position}: {Title} by {Artist} in {ElapsedMs}ms using global mapping",
|
||||||
|
playlistName,
|
||||||
|
spotifyTrack.Position,
|
||||||
|
spotifyTrack.Title,
|
||||||
|
primaryArtist,
|
||||||
|
trackStopwatch.ElapsedMilliseconds);
|
||||||
return (spotifyTrack, candidates);
|
return (spotifyTrack, candidates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -777,10 +831,31 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Try ISRC match
|
// Try ISRC match
|
||||||
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||||
{
|
{
|
||||||
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
try
|
||||||
if (isrcSong != null)
|
|
||||||
{
|
{
|
||||||
candidates.Add((isrcSong, 100.0, "isrc"));
|
var isrcSong = await TryMatchByIsrcAsync(
|
||||||
|
spotifyTrack.Isrc,
|
||||||
|
metadataService,
|
||||||
|
trackCancellationToken);
|
||||||
|
|
||||||
|
if (isrcSong != null)
|
||||||
|
{
|
||||||
|
candidates.Add((isrcSong, 100.0, "isrc"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
ex,
|
||||||
|
"ISRC lookup failed for {Playlist} track #{Position}: {Title} by {Artist}",
|
||||||
|
playlistName,
|
||||||
|
spotifyTrack.Position,
|
||||||
|
spotifyTrack.Title,
|
||||||
|
primaryArtist);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,7 +863,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
|
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
|
||||||
spotifyTrack.Title,
|
spotifyTrack.Title,
|
||||||
spotifyTrack.Artists,
|
spotifyTrack.Artists,
|
||||||
metadataService);
|
metadataService,
|
||||||
|
trackCancellationToken);
|
||||||
|
|
||||||
foreach (var (song, score) in fuzzySongs)
|
foreach (var (song, score) in fuzzySongs)
|
||||||
{
|
{
|
||||||
@@ -798,16 +874,48 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackStopwatch.Stop();
|
||||||
|
_logger.LogDebug(
|
||||||
|
"External candidate search finished for {Playlist} track #{Position}: {Title} by {Artist} in {ElapsedMs}ms with {CandidateCount} candidates",
|
||||||
|
playlistName,
|
||||||
|
spotifyTrack.Position,
|
||||||
|
spotifyTrack.Title,
|
||||||
|
primaryArtist,
|
||||||
|
trackStopwatch.ElapsedMilliseconds,
|
||||||
|
candidates.Count);
|
||||||
|
|
||||||
return (spotifyTrack, candidates);
|
return (spotifyTrack, candidates);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return (spotifyTrack, new List<(Song, double, string)>());
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"External candidate search timed out for {Playlist} track #{Position}: {Title} by {Artist} after {TimeoutSeconds}s",
|
||||||
|
playlistName,
|
||||||
|
spotifyTrack.Position,
|
||||||
|
spotifyTrack.Title,
|
||||||
|
primaryArtist,
|
||||||
|
ExternalProviderSearchTimeout.TotalSeconds);
|
||||||
|
return (spotifyTrack, new List<(Song, double, string)>());
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to match track: {Title}", spotifyTrack.Title);
|
_logger.LogError(
|
||||||
|
ex,
|
||||||
|
"Failed to match track for {Playlist} track #{Position}: {Title} by {Artist}",
|
||||||
|
playlistName,
|
||||||
|
spotifyTrack.Position,
|
||||||
|
spotifyTrack.Title,
|
||||||
|
primaryArtist);
|
||||||
return (spotifyTrack, new List<(Song, double, string)>());
|
return (spotifyTrack, new List<(Song, double, string)>());
|
||||||
}
|
}
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
var batchResults = await Task.WhenAll(batchTasks);
|
var batchResults = await Task.WhenAll(batchTasks);
|
||||||
|
batchStopwatch.Stop();
|
||||||
|
|
||||||
foreach (var result in batchResults)
|
foreach (var result in batchResults)
|
||||||
{
|
{
|
||||||
@@ -817,6 +925,16 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var batchCandidateCount = batchResults.Sum(result => result.Item2.Count);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Finished external matching batch for {Playlist}: tracks {Start}-{End}/{Total} in {ElapsedMs}ms ({CandidateCount} candidates)",
|
||||||
|
playlistName,
|
||||||
|
batchStart,
|
||||||
|
batchEnd,
|
||||||
|
unmatchedSpotifyTracks.Count,
|
||||||
|
batchStopwatch.ElapsedMilliseconds,
|
||||||
|
batchCandidateCount);
|
||||||
|
|
||||||
if (i + BatchSize < unmatchedSpotifyTracks.Count)
|
if (i + BatchSize < unmatchedSpotifyTracks.Count)
|
||||||
{
|
{
|
||||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||||
@@ -989,140 +1107,136 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
||||||
string title,
|
string title,
|
||||||
List<string> artists,
|
List<string> artists,
|
||||||
IMusicMetadataService metadataService)
|
IMusicMetadataService metadataService,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||||
|
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||||
|
var query = $"{titleStripped} {primaryArtist}";
|
||||||
|
|
||||||
|
var allCandidates = new List<(Song Song, double Score)>();
|
||||||
|
|
||||||
|
// STEP 1: Search LOCAL Jellyfin library FIRST
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||||
|
if (proxyService != null)
|
||||||
{
|
{
|
||||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
try
|
||||||
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
|
||||||
var query = $"{titleStripped} {primaryArtist}";
|
|
||||||
|
|
||||||
var allCandidates = new List<(Song Song, double Score)>();
|
|
||||||
|
|
||||||
// STEP 1: Search LOCAL Jellyfin library FIRST
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
|
||||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
|
||||||
if (proxyService != null)
|
|
||||||
{
|
{
|
||||||
try
|
// Search Jellyfin for local tracks
|
||||||
|
var searchParams = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
// Search Jellyfin for local tracks
|
["searchTerm"] = query,
|
||||||
var searchParams = new Dictionary<string, string>
|
["includeItemTypes"] = "Audio",
|
||||||
{
|
["recursive"] = "true",
|
||||||
["searchTerm"] = query,
|
["limit"] = "10"
|
||||||
["includeItemTypes"] = "Audio",
|
};
|
||||||
["recursive"] = "true",
|
|
||||||
["limit"] = "10"
|
|
||||||
};
|
|
||||||
|
|
||||||
var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
|
var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
|
||||||
|
|
||||||
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
|
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
|
||||||
|
{
|
||||||
|
var localResults = new List<Song>();
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
var localResults = new List<Song>();
|
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
|
||||||
foreach (var item in items.EnumerateArray())
|
var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||||
|
var artist = "";
|
||||||
|
|
||||||
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||||
{
|
{
|
||||||
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
|
artist = artistsEl[0].GetString() ?? "";
|
||||||
var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
}
|
||||||
var artist = "";
|
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||||
|
{
|
||||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
artist = albumArtistEl.GetString() ?? "";
|
||||||
{
|
|
||||||
artist = artistsEl[0].GetString() ?? "";
|
|
||||||
}
|
|
||||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
|
||||||
{
|
|
||||||
artist = albumArtistEl.GetString() ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
localResults.Add(new Song
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Title = songTitle,
|
|
||||||
Artist = artist,
|
|
||||||
IsLocal = true
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localResults.Count > 0)
|
localResults.Add(new Song
|
||||||
{
|
{
|
||||||
// Score local results
|
Id = id,
|
||||||
var scoredLocal = localResults
|
Title = songTitle,
|
||||||
.Select(song => new
|
Artist = artist,
|
||||||
{
|
IsLocal = true
|
||||||
Song = song,
|
});
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
}
|
||||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
|
||||||
})
|
|
||||||
.Select(x => new
|
|
||||||
{
|
|
||||||
x.Song,
|
|
||||||
x.TitleScore,
|
|
||||||
x.ArtistScore,
|
|
||||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
|
||||||
})
|
|
||||||
.Where(x =>
|
|
||||||
x.TotalScore >= 40 ||
|
|
||||||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
|
||||||
x.TitleScore >= 85)
|
|
||||||
.OrderByDescending(x => x.TotalScore)
|
|
||||||
.Select(x => (x.Song, x.TotalScore))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
allCandidates.AddRange(scoredLocal);
|
if (localResults.Count > 0)
|
||||||
|
{
|
||||||
// If we found good local matches, return them (don't search external)
|
// Score local results
|
||||||
if (scoredLocal.Any(x => x.TotalScore >= 70))
|
var scoredLocal = localResults
|
||||||
|
.Select(song => new
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search",
|
Song = song,
|
||||||
scoredLocal.Count, title);
|
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||||
return allCandidates;
|
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||||
}
|
})
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Song,
|
||||||
|
x.TitleScore,
|
||||||
|
x.ArtistScore,
|
||||||
|
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||||
|
})
|
||||||
|
.Where(x =>
|
||||||
|
x.TotalScore >= 40 ||
|
||||||
|
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||||
|
x.TitleScore >= 85)
|
||||||
|
.OrderByDescending(x => x.TotalScore)
|
||||||
|
.Select(x => (x.Song, x.TotalScore))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
allCandidates.AddRange(scoredLocal);
|
||||||
|
|
||||||
|
// If we found good local matches, return them (don't search external)
|
||||||
|
if (scoredLocal.Any(x => x.TotalScore >= 70))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search",
|
||||||
|
scoredLocal.Count, title);
|
||||||
|
return allCandidates;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to search local library for '{Title}'", title);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
// STEP 2: Only search EXTERNAL if no good local match found
|
|
||||||
var externalResults = await metadataService.SearchSongsAsync(query, limit: 10);
|
|
||||||
|
|
||||||
if (externalResults.Count > 0)
|
|
||||||
{
|
{
|
||||||
var scoredExternal = externalResults
|
_logger.LogWarning(ex, "Failed to search local library for '{Title}'", title);
|
||||||
.Select(song => new
|
|
||||||
{
|
|
||||||
Song = song,
|
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
|
||||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
|
||||||
})
|
|
||||||
.Select(x => new
|
|
||||||
{
|
|
||||||
x.Song,
|
|
||||||
x.TitleScore,
|
|
||||||
x.ArtistScore,
|
|
||||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
|
||||||
})
|
|
||||||
.Where(x =>
|
|
||||||
x.TotalScore >= 40 ||
|
|
||||||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
|
||||||
x.TitleScore >= 85)
|
|
||||||
.OrderByDescending(x => x.TotalScore)
|
|
||||||
.Select(x => (x.Song, x.TotalScore))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
allCandidates.AddRange(scoredExternal);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return allCandidates;
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
}
|
|
||||||
catch
|
// STEP 2: Only search EXTERNAL if no good local match found
|
||||||
|
var externalResults = await metadataService.SearchSongsAsync(query, limit: 10, cancellationToken);
|
||||||
|
|
||||||
|
if (externalResults.Count > 0)
|
||||||
{
|
{
|
||||||
return new List<(Song, double)>();
|
var scoredExternal = externalResults
|
||||||
|
.Select(song => new
|
||||||
|
{
|
||||||
|
Song = song,
|
||||||
|
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||||
|
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||||
|
})
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Song,
|
||||||
|
x.TitleScore,
|
||||||
|
x.ArtistScore,
|
||||||
|
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||||
|
})
|
||||||
|
.Where(x =>
|
||||||
|
x.TotalScore >= 40 ||
|
||||||
|
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||||
|
x.TitleScore >= 85)
|
||||||
|
.OrderByDescending(x => x.TotalScore)
|
||||||
|
.Select(x => (x.Song, x.TotalScore))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
allCandidates.AddRange(scoredExternal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return allCandidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist)
|
private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist)
|
||||||
@@ -1136,33 +1250,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
/// Attempts to match a track by ISRC.
|
/// Attempts to match a track by ISRC.
|
||||||
/// SEARCHES LOCAL FIRST, then external if no local match found.
|
/// SEARCHES LOCAL FIRST, then external if no local match found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
|
private async Task<Song?> TryMatchByIsrcAsync(
|
||||||
|
string isrc,
|
||||||
|
IMusicMetadataService metadataService,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
// STEP 1: Search LOCAL Jellyfin library FIRST by ISRC
|
||||||
{
|
// Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search
|
||||||
// STEP 1: Search LOCAL Jellyfin library FIRST by ISRC
|
// Local tracks will be found via fuzzy matching instead
|
||||||
// Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search
|
|
||||||
// Local tracks will be found via fuzzy matching instead
|
|
||||||
|
|
||||||
// STEP 2: Search EXTERNAL by ISRC
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
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
|
// STEP 2: Search EXTERNAL by ISRC
|
||||||
results = await metadataService.SearchSongsAsync(isrc, limit: 5);
|
return await metadataService.FindSongByIsrcAsync(isrc, cancellationToken);
|
||||||
var exactMatch = results.FirstOrDefault(r =>
|
|
||||||
!string.IsNullOrEmpty(r.Isrc) &&
|
|
||||||
r.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
return exactMatch;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1399,7 +1499,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Request all fields that clients typically need (not just MediaSources)
|
// 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);
|
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
||||||
|
|
||||||
if (statusCode != 200 || existingTracksResponse == null)
|
if (statusCode != 200 || existingTracksResponse == null)
|
||||||
@@ -1901,4 +2001,39 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
|
_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
|
// Increase timeout for large downloads and slow endpoints
|
||||||
_httpClient.Timeout = TimeSpan.FromMinutes(5);
|
_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);
|
return await _fallbackHelper.TryWithFallbackAsync(async baseUrl =>
|
||||||
|
|
||||||
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
|
|
||||||
{
|
{
|
||||||
"audio/flac" => ".flac",
|
var songId = BuildTrackedSongId(trackId);
|
||||||
"audio/mpeg" => ".mp3",
|
var downloadInfo = await FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken);
|
||||||
"audio/mp4" => ".m4a",
|
|
||||||
_ => ".flac" // Default to FLAC
|
Logger.LogInformation(
|
||||||
};
|
"Track download info resolved via {Endpoint} (Format: {Format}, Quality: {Quality})",
|
||||||
|
downloadInfo.Endpoint, downloadInfo.MimeType, downloadInfo.AudioQuality);
|
||||||
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
Logger.LogDebug("Resolved SquidWTF CDN download URL: {Url}", downloadInfo.DownloadUrl);
|
||||||
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);
|
|
||||||
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
|
var extension = downloadInfo.MimeType?.ToLower() switch
|
||||||
request.Headers.Add("User-Agent", "Mozilla/5.0");
|
{
|
||||||
request.Headers.Add("Accept", "*/*");
|
"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();
|
using var req = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
|
||||||
|
req.Headers.Add("User-Agent", "Mozilla/5.0");
|
||||||
// Download directly (no decryption needed - squid.wtf handles everything)
|
req.Headers.Add("Accept", "*/*");
|
||||||
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
await using var outputFile = IOFile.Create(outputPath);
|
res.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await responseStream.CopyToAsync(outputFile, cancellationToken);
|
|
||||||
|
|
||||||
// Close file before writing metadata
|
|
||||||
await outputFile.DisposeAsync();
|
|
||||||
|
|
||||||
// Start Spotify ID conversion in background (for lyrics support)
|
|
||||||
// This doesn't block streaming - lyrics endpoint will fetch it on-demand if needed
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None);
|
|
||||||
if (!string.IsNullOrEmpty(spotifyId))
|
|
||||||
{
|
|
||||||
Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId);
|
|
||||||
// Spotify ID is cached by Odesli service for future lyrics requests
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Write metadata and cover art (without Spotify ID - it's only needed for lyrics)
|
await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
await WriteMetadataAsync(outputPath, song, 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
|
protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
|
||||||
|
|
||||||
#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)
|
|
||||||
{
|
{
|
||||||
return await QueueRequestAsync(async () =>
|
return await QueueRequestAsync(async () =>
|
||||||
{
|
{
|
||||||
Exception? lastException = null;
|
Exception? lastException = null;
|
||||||
var qualityOrder = BuildQualityFallbackOrder(_squidwtfSettings.Quality);
|
var qualityOrder = BuildQualityFallbackOrder(_squidwtfSettings.Quality);
|
||||||
|
var basePath = CurrentStorageMode == StorageMode.Cache
|
||||||
|
? Path.Combine(DownloadPath, "cache") : Path.Combine(DownloadPath, "permanent");
|
||||||
|
|
||||||
foreach (var quality in qualityOrder)
|
foreach (var quality in qualityOrder)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(baseUrl =>
|
return await RunDownloadWithFallbackAsync(trackId, song, quality, basePath, cancellationToken);
|
||||||
FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken));
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
lastException = ex;
|
lastException = ex;
|
||||||
|
|
||||||
if (!string.Equals(quality, qualityOrder[^1], StringComparison.Ordinal))
|
if (!string.Equals(quality, qualityOrder[^1], StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
Logger.LogWarning(
|
Logger.LogWarning("Track {TrackId} unavailable at SquidWTF quality {Quality}: {Error}. Trying lower quality", trackId, quality, DescribeException(ex));
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
throw lastException ?? new Exception($"Unable to download track {trackId}");
|
||||||
throw lastException ?? new Exception($"Unable to fetch SquidWTF download info for 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(
|
private async Task<DownloadResult> FetchTrackDownloadInfoAsync(
|
||||||
string baseUrl,
|
string baseUrl,
|
||||||
string trackId,
|
string trackId,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using allstarr.Services.Common;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
namespace allstarr.Services.SquidWTF;
|
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.
|
/// 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.
|
/// This implementation follows the hifi-api specification documented at the forked repository.
|
||||||
///
|
///
|
||||||
/// API Endpoints (per hifi-api spec):
|
/// API Endpoints (per hifi-api README):
|
||||||
/// - GET /search/?s={query} - Search tracks (returns data.items array)
|
/// - GET /search/?s={query}&limit={limit}&offset={offset} - Search tracks (returns data.items array)
|
||||||
/// - GET /search/?a={query} - Search artists (returns data.artists.items array)
|
/// - GET /search/?i={isrc}&limit=1&offset=0 - Exact track lookup by ISRC (returns data.items array)
|
||||||
/// - GET /search/?al={query} - Search albums (returns data.albums.items array, undocumented)
|
/// - GET /search/?a={query}&limit={limit}&offset={offset} - Search artists (returns data.artists.items array)
|
||||||
/// - GET /search/?p={query} - Search playlists (returns data.playlists.items array, undocumented)
|
/// - GET /search/?al={query}&limit={limit}&offset={offset} - Search albums (returns data.albums.items array)
|
||||||
/// - GET /info/?id={trackId} - Get track metadata (returns data object with full track info)
|
/// - GET /search/?p={query}&limit={limit}&offset={offset} - Search playlists (returns data.playlists.items array)
|
||||||
/// - GET /track/?id={trackId}&quality={quality} - Get track download info (returns manifest)
|
/// - GET /info/?id={trackId} - Get track metadata (returns data object with full track info)
|
||||||
/// - GET /recommendations/?id={trackId} - Get recommended next/similar tracks
|
/// - GET /track/?id={trackId}&quality={quality} - Get track download info (returns manifest)
|
||||||
/// - GET /album/?id={albumId} - Get album with tracks (undocumented, returns data.items array)
|
/// - GET /recommendations/?id={trackId} - Get recommended next/similar tracks
|
||||||
/// - GET /artist/?f={artistId} - Get artist with albums (undocumented, returns albums.items array)
|
/// - GET /album/?id={albumId}&limit={limit}&offset={offset} - Get album with paginated tracks
|
||||||
/// - GET /playlist/?id={playlistId} - Get playlist with tracks (undocumented)
|
/// - 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:
|
/// Quality Options:
|
||||||
/// - HI_RES_LOSSLESS: 24-bit/192kHz FLAC
|
/// - HI_RES_LOSSLESS: 24-bit/192kHz FLAC
|
||||||
@@ -36,7 +37,8 @@ namespace allstarr.Services.SquidWTF;
|
|||||||
/// - LOW: 96kbps AAC
|
/// - LOW: 96kbps AAC
|
||||||
///
|
///
|
||||||
/// Response Structure:
|
/// 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,
|
/// Track objects include: id, title, duration, trackNumber, volumeNumber, explicit, bpm, isrc,
|
||||||
/// artist (singular), artists (array), album (object with id, title, cover UUID)
|
/// artist (singular), artists (array), album (object with id, title, cover UUID)
|
||||||
/// Cover art URLs: https://resources.tidal.com/images/{uuid-with-slashes}/{size}.jpg
|
/// 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
|
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 HttpClient _httpClient;
|
||||||
private readonly SubsonicSettings _settings;
|
private readonly SubsonicSettings _settings;
|
||||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
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)
|
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var normalizedLimit = NormalizeRemoteLimit(limit);
|
||||||
var allSongs = new List<Song>();
|
var allSongs = new List<Song>();
|
||||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var queryVariant in BuildSearchQueryVariants(query))
|
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)
|
foreach (var song in songs)
|
||||||
{
|
{
|
||||||
var key = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id;
|
var key = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id;
|
||||||
@@ -102,13 +111,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
allSongs.Add(song);
|
allSongs.Add(song);
|
||||||
if (allSongs.Count >= limit)
|
if (allSongs.Count >= normalizedLimit)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allSongs.Count >= limit)
|
if (allSongs.Count >= normalizedLimit)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -120,12 +129,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var normalizedLimit = NormalizeRemoteLimit(limit);
|
||||||
var allAlbums = new List<Album>();
|
var allAlbums = new List<Album>();
|
||||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var queryVariant in BuildSearchQueryVariants(query))
|
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)
|
foreach (var album in albums)
|
||||||
{
|
{
|
||||||
var key = !string.IsNullOrWhiteSpace(album.ExternalId) ? album.ExternalId : album.Id;
|
var key = !string.IsNullOrWhiteSpace(album.ExternalId) ? album.ExternalId : album.Id;
|
||||||
@@ -135,13 +145,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
allAlbums.Add(album);
|
allAlbums.Add(album);
|
||||||
if (allAlbums.Count >= limit)
|
if (allAlbums.Count >= normalizedLimit)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allAlbums.Count >= limit)
|
if (allAlbums.Count >= normalizedLimit)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -153,12 +163,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var normalizedLimit = NormalizeRemoteLimit(limit);
|
||||||
var allArtists = new List<Artist>();
|
var allArtists = new List<Artist>();
|
||||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var queryVariant in BuildSearchQueryVariants(query))
|
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)
|
foreach (var artist in artists)
|
||||||
{
|
{
|
||||||
var key = !string.IsNullOrWhiteSpace(artist.ExternalId) ? artist.ExternalId : artist.Id;
|
var key = !string.IsNullOrWhiteSpace(artist.ExternalId) ? artist.ExternalId : artist.Id;
|
||||||
@@ -168,13 +179,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
allArtists.Add(artist);
|
allArtists.Add(artist);
|
||||||
if (allArtists.Count >= limit)
|
if (allArtists.Count >= normalizedLimit)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allArtists.Count >= limit)
|
if (allArtists.Count >= normalizedLimit)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -186,11 +197,12 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
private async Task<List<Song>> SearchSongsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken)
|
private async Task<List<Song>> SearchSongsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var normalizedLimit = NormalizeRemoteLimit(limit);
|
||||||
// Use benchmark-ordered fallback (no endpoint racing).
|
// Use benchmark-ordered fallback (no endpoint racing).
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Use 's' parameter for track search as per hifi-api spec
|
// 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);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
@@ -216,7 +228,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
int count = 0;
|
int count = 0;
|
||||||
foreach (var track in items.EnumerateArray())
|
foreach (var track in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (count >= limit) break;
|
if (count >= normalizedLimit) break;
|
||||||
|
|
||||||
var song = ParseTidalTrack(track);
|
var song = ParseTidalTrack(track);
|
||||||
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
|
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)
|
private async Task<List<Album>> SearchAlbumsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var normalizedLimit = NormalizeRemoteLimit(limit);
|
||||||
// Use benchmark-ordered fallback (no endpoint racing).
|
// Use benchmark-ordered fallback (no endpoint racing).
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Use 'al' parameter for album search
|
// Use 'al' parameter for album search
|
||||||
// a= is for artists, al= is for albums, p= is for playlists
|
// 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);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
@@ -261,7 +274,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
int count = 0;
|
int count = 0;
|
||||||
foreach (var album in items.EnumerateArray())
|
foreach (var album in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (count >= limit) break;
|
if (count >= normalizedLimit) break;
|
||||||
|
|
||||||
albums.Add(ParseTidalAlbum(album));
|
albums.Add(ParseTidalAlbum(album));
|
||||||
count++;
|
count++;
|
||||||
@@ -278,11 +291,12 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
private async Task<List<Artist>> SearchArtistsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken)
|
private async Task<List<Artist>> SearchArtistsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var normalizedLimit = NormalizeRemoteLimit(limit);
|
||||||
// Use benchmark-ordered fallback (no endpoint racing).
|
// Use benchmark-ordered fallback (no endpoint racing).
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Per hifi-api spec: use 'a' parameter for artist search
|
// 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);
|
_logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
@@ -311,7 +325,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
int count = 0;
|
int count = 0;
|
||||||
foreach (var artist in items.EnumerateArray())
|
foreach (var artist in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (count >= limit) break;
|
if (count >= normalizedLimit) break;
|
||||||
|
|
||||||
var parsedArtist = ParseTidalArtist(artist);
|
var parsedArtist = ParseTidalArtist(artist);
|
||||||
artists.Add(parsedArtist);
|
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)
|
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var normalizedLimit = NormalizeRemoteLimit(limit);
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Per hifi-api spec: use 'p' parameter for playlist search
|
// 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);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -386,7 +474,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
int count = 0;
|
int count = 0;
|
||||||
foreach(var playlist in items.EnumerateArray())
|
foreach(var playlist in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (count >= limit) break;
|
if (count >= normalizedLimit) break;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -410,14 +498,19 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Execute searches in parallel
|
var songsTask = songLimit > 0
|
||||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
? SearchSongsAsync(query, songLimit, cancellationToken)
|
||||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
: Task.FromResult(new List<Song>());
|
||||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
var albumsTask = albumLimit > 0
|
||||||
|
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
|
||||||
|
: Task.FromResult(new List<Album>());
|
||||||
|
var artistsTask = artistLimit > 0
|
||||||
|
? SearchArtistsAsync(query, artistLimit, cancellationToken)
|
||||||
|
: Task.FromResult(new List<Artist>());
|
||||||
|
|
||||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||||
|
|
||||||
var temp = new SearchResult
|
var temp = new SearchResult
|
||||||
{
|
{
|
||||||
Songs = await songsTask,
|
Songs = await songsTask,
|
||||||
Albums = await albumsTask,
|
Albums = await albumsTask,
|
||||||
@@ -427,6 +520,65 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
return temp;
|
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)
|
public async Task<Song?> GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return null;
|
if (externalProvider != "squidwtf") return null;
|
||||||
@@ -584,48 +736,71 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Note: hifi-api doesn't document album endpoint, but /album/?id={albumId} is commonly used
|
Album? album = null;
|
||||||
var url = $"{baseUrl}/album/?id={externalId}";
|
var offset = DefaultSearchOffset;
|
||||||
|
var rawItemCount = 0;
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
while (true)
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
{
|
||||||
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);
|
if (album == null)
|
||||||
var result = JsonDocument.Parse(json);
|
{
|
||||||
|
throw new InvalidOperationException($"SquidWTF /album response for album {externalId} did not contain album data");
|
||||||
|
}
|
||||||
|
|
||||||
// Response structure: { "data": { album object with "items" array of tracks } }
|
await _cache.SetAsync(cacheKey, album, GetMetadataCacheTtl());
|
||||||
if (!result.RootElement.TryGetProperty("data", out var albumElement))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"SquidWTF /album response for album {externalId} did not contain data");
|
|
||||||
}
|
|
||||||
|
|
||||||
var album = ParseTidalAlbum(albumElement);
|
return album;
|
||||||
|
}, (Album?)null);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||||
@@ -645,8 +820,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
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/?id={Uri.EscapeDataString(externalId)}";
|
||||||
var url = $"{baseUrl}/artist/?f={externalId}";
|
|
||||||
_logger.LogDebug("Fetching artist from {Url}", url);
|
_logger.LogDebug("Fetching artist from {Url}", url);
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
@@ -654,73 +828,44 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
{
|
{
|
||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
_logger.LogDebug("SquidWTF artist response: {Json}", json.Length > 500 ? json.Substring(0, 500) + "..." : json);
|
_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;
|
if (!result.RootElement.TryGetProperty("artist", out var artistElement))
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
var keys = string.Join(", ", result.RootElement.EnumerateObject().Select(p => p.Name));
|
var keys = string.Join(", ", result.RootElement.EnumerateObject().Select(p => p.Name));
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"SquidWTF artist response for {externalId} did not contain artist data. Keys: {keys}");
|
$"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)
|
var artist = new Artist
|
||||||
string? pictureUuid = null;
|
|
||||||
if (artistElement.TryGetProperty("picture", out var pictureEl) && pictureEl.ValueKind != JsonValueKind.Null)
|
|
||||||
{
|
{
|
||||||
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
|
_logger.LogDebug("Successfully parsed artist {ArtistName} via /artist/?id=", artist.Name);
|
||||||
var normalizedArtist = new JsonObject
|
|
||||||
{
|
|
||||||
["id"] = artistElement.GetProperty("id").GetInt64(),
|
|
||||||
["name"] = artistElement.GetProperty("name").GetString(),
|
|
||||||
["albums_count"] = albumCount,
|
|
||||||
["picture"] = pictureUuid
|
|
||||||
};
|
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(normalizedArtist.ToJsonString());
|
await _cache.SetAsync(cacheKey, artist, GetMetadataCacheTtl());
|
||||||
var artist = ParseTidalArtist(doc.RootElement);
|
|
||||||
|
|
||||||
_logger.LogDebug("Successfully parsed artist {ArtistName} with {AlbumCount} albums", artist.Name, albumCount);
|
return artist;
|
||||||
|
|
||||||
// Cache for configurable duration
|
|
||||||
await _cache.SetAsync(cacheKey, artist, CacheExtensions.MetadataTTL);
|
|
||||||
|
|
||||||
return artist;
|
|
||||||
}, (Artist?)null);
|
}, (Artist?)null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,7 +877,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
{
|
{
|
||||||
_logger.LogDebug("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
|
_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}";
|
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||||
_logger.LogDebug("Fetching artist albums from URL: {Url}", url);
|
_logger.LogDebug("Fetching artist albums from URL: {Url}", url);
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
@@ -779,7 +924,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
{
|
{
|
||||||
_logger.LogDebug("GetArtistTracksAsync called for SquidWTF artist {ExternalId}", externalId);
|
_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}";
|
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||||
_logger.LogDebug("Fetching artist tracks from URL: {Url}", url);
|
_logger.LogDebug("Fetching artist tracks from URL: {Url}", url);
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
@@ -821,8 +966,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
|
var url = BuildPagedEndpointUrl(baseUrl, "playlist", "id", externalId, RemoteSearchMinLimit);
|
||||||
var url = $"{baseUrl}/playlist/?id={externalId}";
|
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -830,7 +974,8 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
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
|
// Check for error response
|
||||||
if (rootElement.TryGetProperty("error", out _))
|
if (rootElement.TryGetProperty("error", out _))
|
||||||
@@ -855,76 +1000,85 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
|
var songs = new List<Song>();
|
||||||
var url = $"{baseUrl}/playlist/?id={externalId}";
|
var offset = DefaultSearchOffset;
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var rawTrackCount = 0;
|
||||||
if (!response.IsSuccessStatusCode)
|
var trackIndex = 1;
|
||||||
{
|
string playlistName = "Unknown Playlist";
|
||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
int? expectedTrackCount = null;
|
||||||
}
|
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
while (true)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
var url = BuildPagedEndpointUrl(baseUrl, "playlist", "id", externalId, MetadataPageSize, offset);
|
||||||
$"SquidWTF playlist tracks response for {externalId} did not contain items");
|
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;
|
return songs;
|
||||||
}, new List<Song>());
|
}, new List<Song>());
|
||||||
}
|
}
|
||||||
@@ -1251,10 +1405,18 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
var externalId = artist.GetProperty("id").GetInt64().ToString();
|
var externalId = artist.GetProperty("id").GetInt64().ToString();
|
||||||
var artistName = artist.GetProperty("name").GetString() ?? "";
|
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")
|
? BuildTidalImageUrl(picture.GetString(), "320x320")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(imageUrl) &&
|
||||||
|
artist.TryGetProperty("imageUrl", out var imageUrlElement) &&
|
||||||
|
imageUrlElement.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
imageUrl = imageUrlElement.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(imageUrl))
|
if (!string.IsNullOrWhiteSpace(imageUrl))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Artist {ArtistName} picture: {ImageUrl}", artistName, imageUrl);
|
_logger.LogDebug("Artist {ArtistName} picture: {ImageUrl}", artistName, imageUrl);
|
||||||
@@ -1276,8 +1438,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses a Tidal playlist from hifi-api /playlist/ endpoint response.
|
/// Parses a Tidal playlist from hifi-api /playlist/ endpoint response.
|
||||||
/// Per hifi-api spec (undocumented), response structure is:
|
/// Response structure: { "playlist": { uuid, title, description, creator, created, numberOfTracks, duration, squareImage },
|
||||||
/// { "playlist": { uuid, title, description, creator, created, numberOfTracks, duration, squareImage },
|
|
||||||
/// "items": [ { "item": { track object } } ] }
|
/// "items": [ { "item": { track object } } ] }
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="playlistElement">Root JSON element containing playlist and items</param>
|
/// <param name="playlistElement">Root JSON element containing playlist and items</param>
|
||||||
@@ -1427,13 +1588,14 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<Song?>> SearchSongsInParallelAsync(List<string> queries, int limit = 10, CancellationToken cancellationToken = default)
|
public async Task<List<Song?>> SearchSongsInParallelAsync(List<string> queries, int limit = 10, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var normalizedLimit = NormalizeRemoteLimit(limit);
|
||||||
return await _fallbackHelper.ProcessInParallelAsync(
|
return await _fallbackHelper.ProcessInParallelAsync(
|
||||||
queries,
|
queries,
|
||||||
async (baseUrl, query, ct) =>
|
async (baseUrl, query, ct) =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
var url = BuildSearchUrl(baseUrl, "s", query, normalizedLimit);
|
||||||
var response = await _httpClient.GetAsync(url, ct);
|
var response = await _httpClient.GetAsync(url, ct);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
|
|||||||
@@ -51,15 +51,18 @@
|
|||||||
"Qobuz": {
|
"Qobuz": {
|
||||||
"UserAuthToken": "your-qobuz-token",
|
"UserAuthToken": "your-qobuz-token",
|
||||||
"UserId": "your-qobuz-user-id",
|
"UserId": "your-qobuz-user-id",
|
||||||
"Quality": "FLAC"
|
"Quality": "FLAC",
|
||||||
|
"MinRequestIntervalMs": 200
|
||||||
},
|
},
|
||||||
"Deezer": {
|
"Deezer": {
|
||||||
"Arl": "your-deezer-arl-token",
|
"Arl": "your-deezer-arl-token",
|
||||||
"ArlFallback": "",
|
"ArlFallback": "",
|
||||||
"Quality": "FLAC"
|
"Quality": "FLAC",
|
||||||
|
"MinRequestIntervalMs": 200
|
||||||
},
|
},
|
||||||
"SquidWTF": {
|
"SquidWTF": {
|
||||||
"Quality": "FLAC"
|
"Quality": "FLAC",
|
||||||
|
"MinRequestIntervalMs": 200
|
||||||
},
|
},
|
||||||
"Redis": {
|
"Redis": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
@@ -74,7 +77,8 @@
|
|||||||
"GenreDays": 30,
|
"GenreDays": 30,
|
||||||
"MetadataDays": 7,
|
"MetadataDays": 7,
|
||||||
"OdesliLookupDays": 60,
|
"OdesliLookupDays": 60,
|
||||||
"ProxyImagesDays": 14
|
"ProxyImagesDays": 14,
|
||||||
|
"TranscodeCacheMinutes": 60
|
||||||
},
|
},
|
||||||
"SpotifyImport": {
|
"SpotifyImport": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
|
|||||||
+113
-58
@@ -12,8 +12,8 @@
|
|||||||
<!-- Restart Required Banner -->
|
<!-- Restart Required Banner -->
|
||||||
<div class="restart-banner" id="restart-banner">
|
<div class="restart-banner" id="restart-banner">
|
||||||
⚠️ Configuration changed. Restart required to apply changes.
|
⚠️ Configuration changed. Restart required to apply changes.
|
||||||
<button onclick="restartContainer()">Restart Now</button>
|
<button data-action="restartContainer">Restart Allstarr</button>
|
||||||
<button onclick="dismissRestartBanner()"
|
<button data-action="dismissRestartBanner"
|
||||||
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -32,36 +32,64 @@
|
|||||||
<div class="auth-error" id="auth-error" role="alert"></div>
|
<div class="auth-error" id="auth-error" role="alert"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="support-badge">
|
||||||
|
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||||
|
supporting its development via
|
||||||
|
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||||
|
or
|
||||||
|
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container" id="main-container" style="display:none;">
|
<div class="container hidden" id="main-container">
|
||||||
<header>
|
<div class="app-shell">
|
||||||
<h1>
|
<aside class="sidebar" aria-label="Admin navigation">
|
||||||
Allstarr <span class="version" id="version">Loading...</span>
|
<div class="sidebar-brand">
|
||||||
</h1>
|
<div class="sidebar-title">Allstarr</div>
|
||||||
<div class="header-actions">
|
<div class="sidebar-subtitle" id="sidebar-version">Loading...</div>
|
||||||
<div class="auth-user" id="auth-user-display" style="display:none;">
|
|
||||||
Signed in as <strong id="auth-user-name">-</strong>
|
|
||||||
</div>
|
</div>
|
||||||
<button id="auth-logout-btn" onclick="logoutAdminSession()" style="display:none;">Logout</button>
|
<nav class="sidebar-nav">
|
||||||
<div id="status-indicator">
|
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button>
|
||||||
<span class="status-badge" id="spotify-status">
|
<button class="sidebar-link" type="button" data-tab="jellyfin-playlists">Link Playlists</button>
|
||||||
<span class="status-dot"></span>
|
<button class="sidebar-link" type="button" data-tab="playlists">Injected Playlists</button>
|
||||||
<span>Loading...</span>
|
<button class="sidebar-link" type="button" data-tab="kept">Kept Downloads</button>
|
||||||
</span>
|
<button class="sidebar-link" type="button" data-tab="scrobbling">Scrobbling</button>
|
||||||
|
<button class="sidebar-link" type="button" data-tab="config">Configuration</button>
|
||||||
|
<button class="sidebar-link" type="button" data-tab="endpoints">API Analytics</button>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="auth-user hidden" id="auth-user-display">
|
||||||
|
Signed in as <strong id="auth-user-name">-</strong>
|
||||||
|
</div>
|
||||||
|
<button id="auth-logout-btn" data-action="logoutAdminSession" class="hidden">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="tabs">
|
<main class="app-main">
|
||||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
<header class="app-header">
|
||||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
<h1>
|
||||||
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
Allstarr <span class="version" id="version">Loading...</span>
|
||||||
<div class="tab" data-tab="kept">Kept Downloads</div>
|
</h1>
|
||||||
<div class="tab" data-tab="scrobbling">Scrobbling</div>
|
<div class="header-actions">
|
||||||
<div class="tab" data-tab="config">Configuration</div>
|
<div id="status-indicator">
|
||||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
<span class="status-badge" id="spotify-status">
|
||||||
</div>
|
<span class="status-dot"></span>
|
||||||
|
<span>Loading...</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs top-tabs" aria-hidden="true">
|
||||||
|
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||||
|
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||||
|
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||||
|
<div class="tab" data-tab="kept">Kept Downloads</div>
|
||||||
|
<div class="tab" data-tab="scrobbling">Scrobbling</div>
|
||||||
|
<div class="tab" data-tab="config">Configuration</div>
|
||||||
|
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Dashboard Tab -->
|
<!-- Dashboard Tab -->
|
||||||
<div class="tab-content active" id="tab-dashboard">
|
<div class="tab-content active" id="tab-dashboard">
|
||||||
@@ -113,9 +141,9 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<div id="dashboard-guidance" class="guidance-stack"></div>
|
<div id="dashboard-guidance" class="guidance-stack"></div>
|
||||||
<div class="card-actions-row">
|
<div class="card-actions-row">
|
||||||
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
|
<button class="primary" data-action="refreshPlaylists">Refresh All Playlists</button>
|
||||||
<button onclick="clearCache()">Clear Cache</button>
|
<button data-action="clearCache">Clear Cache</button>
|
||||||
<button onclick="openAddPlaylist()">Add Playlist</button>
|
<button data-action="openAddPlaylist">Add Playlist</button>
|
||||||
<button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button>
|
<button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +158,7 @@
|
|||||||
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
|
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p class="text-secondary mb-16">
|
||||||
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing
|
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing
|
||||||
tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
|
tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
|
||||||
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more
|
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more
|
||||||
@@ -138,10 +166,9 @@
|
|||||||
</p>
|
</p>
|
||||||
<div id="jellyfin-guidance" class="guidance-stack"></div>
|
<div id="jellyfin-guidance" class="guidance-stack"></div>
|
||||||
|
|
||||||
<div id="jellyfin-user-filter" style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
|
<div id="jellyfin-user-filter" class="flex-row-wrap mb-16">
|
||||||
<div class="form-group" style="margin: 0; flex: 1; min-width: 200px;">
|
<div class="form-group jellyfin-user-form-group">
|
||||||
<label
|
<label class="text-secondary">User</label>
|
||||||
style="display: block; margin-bottom: 4px; color: var(--text-secondary); font-size: 0.85rem;">User</label>
|
|
||||||
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()"
|
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()"
|
||||||
style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
||||||
<option value="">All Users</option>
|
<option value="">All Users</option>
|
||||||
@@ -217,7 +244,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
<p class="text-secondary mb-12">
|
||||||
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music
|
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music
|
||||||
service.
|
service.
|
||||||
</p>
|
</p>
|
||||||
@@ -253,15 +280,14 @@
|
|||||||
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For
|
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For
|
||||||
local Jellyfin tracks, use the Spotify Import plugin instead.
|
local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||||
</p>
|
</p>
|
||||||
<div id="mappings-summary"
|
<div id="mappings-summary" class="summary-box">
|
||||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
||||||
<div>
|
<div>
|
||||||
<span style="color: var(--text-secondary);">Total:</span>
|
<span class="summary-label">Total:</span>
|
||||||
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
|
<span class="summary-value" id="mappings-total">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span style="color: var(--text-secondary);">External:</span>
|
<span class="summary-label">External:</span>
|
||||||
<span style="font-weight: 600; margin-left: 8px; color: var(--success);"
|
<span class="summary-value success"
|
||||||
id="mappings-external">0</span>
|
id="mappings-external">0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,15 +320,14 @@
|
|||||||
<button onclick="fetchMissingTracks()">Refresh</button>
|
<button onclick="fetchMissingTracks()">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
<p class="text-secondary mb-12">
|
||||||
Tracks that couldn't be matched locally or externally. Map them manually to add them to your
|
Tracks that couldn't be matched locally or externally. Map them manually to add them to your
|
||||||
playlists.
|
playlists.
|
||||||
</p>
|
</p>
|
||||||
<div id="missing-summary"
|
<div id="missing-summary" class="summary-box">
|
||||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
||||||
<div>
|
<div>
|
||||||
<span style="color: var(--text-secondary);">Total Missing:</span>
|
<span class="summary-label">Total Missing:</span>
|
||||||
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);"
|
<span class="summary-value warning"
|
||||||
id="missing-total">0</span>
|
id="missing-total">0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,23 +358,23 @@
|
|||||||
<h2>
|
<h2>
|
||||||
Kept Downloads
|
Kept Downloads
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="downloadAllKept()" style="background:var(--accent);border-color:var(--accent);">Download All</button>
|
<button onclick="downloadAllKept()" class="primary">Download All</button>
|
||||||
|
<button onclick="deleteAllKept()" class="danger">Delete All</button>
|
||||||
<button onclick="fetchDownloads()">Refresh</button>
|
<button onclick="fetchDownloads()">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
<p class="text-secondary mb-12">
|
||||||
Downloaded files stored permanently. Download individual tracks or download all as a zip archive.
|
Downloaded files stored permanently. Download individual tracks or download all as a zip archive.
|
||||||
</p>
|
</p>
|
||||||
<div id="downloads-summary"
|
<div id="downloads-summary" class="summary-box">
|
||||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
||||||
<div>
|
<div>
|
||||||
<span style="color: var(--text-secondary);">Total Files:</span>
|
<span class="summary-label">Total Files:</span>
|
||||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);"
|
<span class="summary-value accent"
|
||||||
id="downloads-count">0</span>
|
id="downloads-count">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span style="color: var(--text-secondary);">Total Size:</span>
|
<span class="summary-label">Total Size:</span>
|
||||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0
|
<span class="summary-value accent" id="downloads-size">0
|
||||||
B</span>
|
B</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -631,6 +656,12 @@
|
|||||||
<button
|
<button
|
||||||
onclick="openEditSetting('DEEZER_QUALITY', 'Deezer Quality', 'select', '', ['FLAC', 'MP3_320', 'MP3_128'])">Edit</button>
|
onclick="openEditSetting('DEEZER_QUALITY', 'Deezer Quality', 'select', '', ['FLAC', 'MP3_320', 'MP3_128'])">Edit</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -643,6 +674,12 @@
|
|||||||
<button
|
<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>
|
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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -680,10 +717,16 @@
|
|||||||
onclick="openEditSetting('QOBUZ_USER_AUTH_TOKEN', 'Qobuz User Auth Token', 'password', 'Get from browser while logged into Qobuz')">Update</button>
|
onclick="openEditSetting('QOBUZ_USER_AUTH_TOKEN', 'Qobuz User Auth Token', 'password', 'Get from browser while logged into Qobuz')">Update</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">Quality</span>
|
<span class="label">Preferred Quality</span>
|
||||||
<span class="value" id="config-qobuz-quality">-</span>
|
<span class="value" id="config-qobuz-quality">-</span>
|
||||||
<button
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -833,7 +876,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
<button class="danger" onclick="clearCache()">Clear All Cache</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -929,6 +972,18 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="support-footer">
|
||||||
|
<p>
|
||||||
|
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||||
|
supporting its development via
|
||||||
|
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||||
|
or
|
||||||
|
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Playlist Modal -->
|
<!-- Add Playlist Modal -->
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
function toBoolean(value) {
|
||||||
|
if (value === true || value === false) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const normalized = String(value ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
return normalized === "true" || normalized === "1" || normalized === "yes";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionArgs(el) {
|
||||||
|
if (!el || !el.dataset) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convention:
|
||||||
|
// - data-action="foo"
|
||||||
|
// - data-arg-bar="baz" => { bar: "baz" }
|
||||||
|
const args = {};
|
||||||
|
for (const [key, value] of Object.entries(el.dataset)) {
|
||||||
|
if (!key.startsWith("arg")) continue;
|
||||||
|
const argName = key.slice(3);
|
||||||
|
if (!argName) continue;
|
||||||
|
const normalized =
|
||||||
|
argName.charAt(0).toLowerCase() + argName.slice(1);
|
||||||
|
args[normalized] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initActionDispatcher({ root = document } = {}) {
|
||||||
|
const handlers = new Map();
|
||||||
|
|
||||||
|
function register(actionName, handler) {
|
||||||
|
if (!actionName || typeof handler !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlers.set(actionName, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatch(actionName, el, event = null) {
|
||||||
|
const handler = handlers.get(actionName);
|
||||||
|
const args = getActionArgs(el);
|
||||||
|
|
||||||
|
if (handler) {
|
||||||
|
return await handler({ el, event, args, toBoolean, toNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transitional fallback: if a legacy window function exists, call it.
|
||||||
|
// This allows incremental conversion away from inline onclick.
|
||||||
|
const legacy = typeof window !== "undefined" ? window[actionName] : null;
|
||||||
|
if (typeof legacy === "function") {
|
||||||
|
const legacyArgs = args && Object.keys(args).length > 0 ? [args] : [];
|
||||||
|
return legacy(...legacyArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`No handler registered for action "${actionName}"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
root.addEventListener("click", (event) => {
|
||||||
|
const trigger = event.target?.closest?.("[data-action]");
|
||||||
|
if (!trigger) return;
|
||||||
|
|
||||||
|
const actionName = trigger.getAttribute("data-action") || "";
|
||||||
|
if (!actionName) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
dispatch(actionName, trigger, event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bind();
|
||||||
|
|
||||||
|
return { register, dispatch };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -124,6 +124,14 @@ export async function deleteDownload(path) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteAllDownloads() {
|
||||||
|
return requestJson(
|
||||||
|
"/api/admin/downloads/all",
|
||||||
|
{ method: "DELETE" },
|
||||||
|
"Failed to delete all downloads",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchConfig() {
|
export async function fetchConfig() {
|
||||||
return requestJson(
|
return requestJson(
|
||||||
"/api/admin/config",
|
"/api/admin/config",
|
||||||
@@ -144,10 +152,15 @@ export async function fetchJellyfinUsers() {
|
|||||||
return requestOptionalJson("/api/admin/jellyfin/users");
|
return requestOptionalJson("/api/admin/jellyfin/users");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchJellyfinPlaylists(userId = null) {
|
export async function fetchJellyfinPlaylists(userId = null, includeStats = true) {
|
||||||
let url = "/api/admin/jellyfin/playlists";
|
let url = "/api/admin/jellyfin/playlists";
|
||||||
|
const params = [];
|
||||||
if (userId) {
|
if (userId) {
|
||||||
url += "?userId=" + encodeURIComponent(userId);
|
params.push("userId=" + encodeURIComponent(userId));
|
||||||
|
}
|
||||||
|
params.push("includeStats=" + String(Boolean(includeStats)));
|
||||||
|
if (params.length > 0) {
|
||||||
|
url += "?" + params.join("&");
|
||||||
}
|
}
|
||||||
|
|
||||||
return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
|
return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
|
||||||
@@ -274,7 +287,7 @@ export async function restartContainer() {
|
|||||||
return requestJson(
|
return requestJson(
|
||||||
"/api/admin/restart",
|
"/api/admin/restart",
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
"Failed to restart container",
|
"Failed to restart Allstarr",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { escapeHtml, showToast, formatCookieAge } from "./utils.js";
|
import { escapeHtml, escapeJs, showToast, formatCookieAge } from "./utils.js";
|
||||||
import * as API from "./api.js";
|
import * as API from "./api.js";
|
||||||
import * as UI from "./ui.js";
|
import * as UI from "./ui.js";
|
||||||
import { renderCookieAge } from "./settings-editor.js";
|
import { renderCookieAge } from "./settings-editor.js";
|
||||||
@@ -6,6 +6,7 @@ import { runAction } from "./operations.js";
|
|||||||
|
|
||||||
let playlistAutoRefreshInterval = null;
|
let playlistAutoRefreshInterval = null;
|
||||||
let dashboardRefreshInterval = null;
|
let dashboardRefreshInterval = null;
|
||||||
|
let downloadActivityEventSource = null;
|
||||||
|
|
||||||
let isAuthenticated = () => false;
|
let isAuthenticated = () => false;
|
||||||
let isAdminSession = () => false;
|
let isAdminSession = () => false;
|
||||||
@@ -14,6 +15,7 @@ let onCookieNeedsInit = async () => {};
|
|||||||
let setCurrentConfigState = () => {};
|
let setCurrentConfigState = () => {};
|
||||||
let syncConfigUiExtras = () => {};
|
let syncConfigUiExtras = () => {};
|
||||||
let loadScrobblingConfig = () => {};
|
let loadScrobblingConfig = () => {};
|
||||||
|
let jellyfinPlaylistRequestToken = 0;
|
||||||
|
|
||||||
async function fetchStatus() {
|
async function fetchStatus() {
|
||||||
try {
|
try {
|
||||||
@@ -128,6 +130,7 @@ async function fetchMissingTracks() {
|
|||||||
missing.forEach((t) => {
|
missing.forEach((t) => {
|
||||||
missingTracks.push({
|
missingTracks.push({
|
||||||
playlist: playlist.name,
|
playlist: playlist.name,
|
||||||
|
provider: t.externalProvider || t.provider || "squidwtf",
|
||||||
...t,
|
...t,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -150,6 +153,7 @@ async function fetchMissingTracks() {
|
|||||||
const artist =
|
const artist =
|
||||||
t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
|
t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
|
||||||
const searchQuery = `${t.title} ${artist}`;
|
const searchQuery = `${t.title} ${artist}`;
|
||||||
|
const provider = t.provider || "squidwtf";
|
||||||
const trackPosition = Number.isFinite(t.position)
|
const trackPosition = Number.isFinite(t.position)
|
||||||
? Number(t.position)
|
? Number(t.position)
|
||||||
: 0;
|
: 0;
|
||||||
@@ -162,7 +166,7 @@ async function fetchMissingTracks() {
|
|||||||
<td class="mapping-actions-cell">
|
<td class="mapping-actions-cell">
|
||||||
<button class="map-action-btn map-action-search missing-track-search-btn"
|
<button class="map-action-btn map-action-search missing-track-search-btn"
|
||||||
data-query="${escapeHtml(searchQuery)}"
|
data-query="${escapeHtml(searchQuery)}"
|
||||||
data-provider="squidwtf">🔍 Search</button>
|
data-provider="${escapeHtml(provider)}">🔍 Search</button>
|
||||||
<button class="map-action-btn map-action-local missing-track-local-btn"
|
<button class="map-action-btn map-action-local missing-track-local-btn"
|
||||||
data-playlist="${escapeHtml(t.playlist)}"
|
data-playlist="${escapeHtml(t.playlist)}"
|
||||||
data-position="${trackPosition}"
|
data-position="${trackPosition}"
|
||||||
@@ -212,9 +216,9 @@ async function fetchDownloads() {
|
|||||||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||||
<td>
|
<td>
|
||||||
<button onclick="downloadFile('${escapeJs(f.path)}')"
|
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
|
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
|
||||||
<button onclick="deleteDownload('${escapeJs(f.path)}')"
|
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -244,11 +248,28 @@ async function fetchJellyfinPlaylists() {
|
|||||||
'<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
'<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const requestToken = ++jellyfinPlaylistRequestToken;
|
||||||
const userId = isAdminSession()
|
const userId = isAdminSession()
|
||||||
? document.getElementById("jellyfin-user-select")?.value
|
? document.getElementById("jellyfin-user-select")?.value
|
||||||
: null;
|
: null;
|
||||||
const data = await API.fetchJellyfinPlaylists(userId);
|
const baseData = await API.fetchJellyfinPlaylists(userId, false);
|
||||||
UI.updateJellyfinPlaylistsUI(data);
|
if (requestToken !== jellyfinPlaylistRequestToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UI.updateJellyfinPlaylistsUI(baseData);
|
||||||
|
|
||||||
|
// Enrich counts after initial render so big accounts don't appear empty.
|
||||||
|
API.fetchJellyfinPlaylists(userId, true)
|
||||||
|
.then((statsData) => {
|
||||||
|
if (requestToken !== jellyfinPlaylistRequestToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UI.updateJellyfinPlaylistsUI(statsData);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed to fetch Jellyfin playlist track stats:", err);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch Jellyfin playlists:", error);
|
console.error("Failed to fetch Jellyfin playlists:", error);
|
||||||
tbody.innerHTML =
|
tbody.innerHTML =
|
||||||
@@ -324,6 +345,10 @@ function stopDashboardRefresh() {
|
|||||||
clearInterval(dashboardRefreshInterval);
|
clearInterval(dashboardRefreshInterval);
|
||||||
dashboardRefreshInterval = null;
|
dashboardRefreshInterval = null;
|
||||||
}
|
}
|
||||||
|
if (downloadActivityEventSource) {
|
||||||
|
downloadActivityEventSource.close();
|
||||||
|
downloadActivityEventSource = null;
|
||||||
|
}
|
||||||
stopPlaylistAutoRefresh();
|
stopPlaylistAutoRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +366,10 @@ function startDashboardRefresh() {
|
|||||||
fetchPlaylists();
|
fetchPlaylists();
|
||||||
fetchTrackMappings();
|
fetchTrackMappings();
|
||||||
fetchMissingTracks();
|
fetchMissingTracks();
|
||||||
fetchDownloads();
|
const keptTab = document.getElementById("tab-kept");
|
||||||
|
if (keptTab && keptTab.classList.contains("active")) {
|
||||||
|
fetchDownloads();
|
||||||
|
}
|
||||||
|
|
||||||
const endpointsTab = document.getElementById("tab-endpoints");
|
const endpointsTab = document.getElementById("tab-endpoints");
|
||||||
if (endpointsTab && endpointsTab.classList.contains("active")) {
|
if (endpointsTab && endpointsTab.classList.contains("active")) {
|
||||||
@@ -377,6 +405,133 @@ async function loadDashboardData() {
|
|||||||
startDashboardRefresh();
|
startDashboardRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startDownloadActivityStream() {
|
||||||
|
if (!isAdminSession()) return;
|
||||||
|
|
||||||
|
if (downloadActivityEventSource) {
|
||||||
|
downloadActivityEventSource.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadActivityEventSource = new EventSource("/api/admin/downloads/activity");
|
||||||
|
|
||||||
|
downloadActivityEventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const downloads = JSON.parse(event.data);
|
||||||
|
renderDownloadActivity(downloads);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to parse download activity:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
downloadActivityEventSource.onerror = (err) => {
|
||||||
|
console.error("Download activity SSE error:", err);
|
||||||
|
// EventSource will auto-reconnect
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDownloadActivity(downloads) {
|
||||||
|
const container = document.getElementById("download-activity-list");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!downloads || downloads.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No active downloads</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusIcons = {
|
||||||
|
0: '⏳', // NotStarted
|
||||||
|
1: '<span class="spinner" style="border-width:2px; height:12px; width:12px; display:inline-block; margin-right:4px;"></span> Downloading', // InProgress
|
||||||
|
2: '✅ Completed', // Completed
|
||||||
|
3: '❌ Failed' // Failed
|
||||||
|
};
|
||||||
|
|
||||||
|
const html = downloads.map(d => {
|
||||||
|
const downloadProgress = clampProgress(d.progress);
|
||||||
|
const playbackProgress = clampProgress(d.playbackProgress);
|
||||||
|
|
||||||
|
// Determine elapsed/duration text
|
||||||
|
let timeText = "";
|
||||||
|
if (d.startedAt) {
|
||||||
|
const start = new Date(d.startedAt);
|
||||||
|
const end = d.completedAt ? new Date(d.completedAt) : new Date();
|
||||||
|
const diffSecs = Math.floor((end.getTime() - start.getTime()) / 1000);
|
||||||
|
timeText = diffSecs < 60 ? `${diffSecs}s` : `${Math.floor(diffSecs/60)}m ${diffSecs%60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressMeta = [];
|
||||||
|
if (typeof d.durationSeconds === "number" && typeof d.playbackPositionSeconds === "number") {
|
||||||
|
progressMeta.push(`${formatSeconds(d.playbackPositionSeconds)} / ${formatSeconds(d.durationSeconds)}`);
|
||||||
|
} else if (typeof d.durationSeconds === "number") {
|
||||||
|
progressMeta.push(formatSeconds(d.durationSeconds));
|
||||||
|
}
|
||||||
|
if (d.requestedForStreaming) {
|
||||||
|
progressMeta.push("stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressMetaText = progressMeta.length > 0
|
||||||
|
? `<div class="download-progress-meta">${progressMeta.map(escapeHtml).join(" • ")}</div>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const progressBar = `
|
||||||
|
<div class="download-progress-bar" aria-hidden="true">
|
||||||
|
<div class="download-progress-buffer" style="width:${downloadProgress * 100}%"></div>
|
||||||
|
<div class="download-progress-playback" style="width:${playbackProgress * 100}%"></div>
|
||||||
|
</div>
|
||||||
|
${progressMetaText}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const title = d.title || 'Unknown Title';
|
||||||
|
const artist = d.artist || 'Unknown Artist';
|
||||||
|
const errorText = d.errorMessage ? `<div style="color:var(--error); font-size:0.8rem; margin-top:4px;">${escapeHtml(d.errorMessage)}</div>` : '';
|
||||||
|
const streamBadge = d.requestedForStreaming
|
||||||
|
? '<span class="download-queue-badge">Stream</span>'
|
||||||
|
: '';
|
||||||
|
const playingBadge = d.isPlaying
|
||||||
|
? '<span class="download-queue-badge is-playing">Playing</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="download-queue-item">
|
||||||
|
<div class="download-queue-info">
|
||||||
|
<div class="download-queue-title">${escapeHtml(title)}</div>
|
||||||
|
<div class="download-queue-meta">
|
||||||
|
<span class="download-queue-artist">${escapeHtml(artist)}</span>
|
||||||
|
<span class="download-queue-provider">${escapeHtml(d.externalProvider)}</span>
|
||||||
|
${streamBadge}
|
||||||
|
${playingBadge}
|
||||||
|
</div>
|
||||||
|
${progressBar}
|
||||||
|
${errorText}
|
||||||
|
</div>
|
||||||
|
<div class="download-queue-status">
|
||||||
|
<span style="font-size:0.85rem;">${statusIcons[d.status] || 'Unknown'}</span>
|
||||||
|
<span class="download-queue-time">${timeText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampProgress(value) {
|
||||||
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(totalSeconds) {
|
||||||
|
if (typeof totalSeconds !== "number" || Number.isNaN(totalSeconds) || totalSeconds < 0) {
|
||||||
|
return "0:00";
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = Math.floor(totalSeconds % 60);
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function initDashboardData(options) {
|
export function initDashboardData(options) {
|
||||||
isAuthenticated = options.isAuthenticated;
|
isAuthenticated = options.isAuthenticated;
|
||||||
isAdminSession = options.isAdminSession;
|
isAdminSession = options.isAdminSession;
|
||||||
|
|||||||
@@ -100,14 +100,14 @@ export async function viewTracks(name) {
|
|||||||
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
|
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
|
||||||
const externalSearchLink =
|
const externalSearchLink =
|
||||||
t.isLocal === false && t.searchQuery && t.externalProvider
|
t.isLocal === false && t.searchQuery && t.externalProvider
|
||||||
? `<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', '${escapeJs(t.externalProvider)}'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
? `<br><small style="color:var(--accent)"><a href="#" data-action="searchProvider" data-arg-query="${escapeHtml(escapeJs(t.searchQuery))}" data-arg-provider="${escapeHtml(escapeJs(t.externalProvider))}" style="color:var(--accent);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||||
: "";
|
: "";
|
||||||
const missingSearchLink =
|
const missingSearchLink =
|
||||||
t.isLocal === null && t.searchQuery
|
t.isLocal === null && t.searchQuery
|
||||||
? `<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', 'squidwtf'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
? `<br><small style="color:var(--text-secondary)"><a href="#" data-action="searchProvider" data-arg-query="${escapeHtml(escapeJs(t.searchQuery))}" data-arg-provider="squidwtf" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || "")}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
|
const lyricsMapButton = `<button class="small" data-action="openLyricsMap" data-arg-artist="${escapeHtml(escapeJs(firstArtist))}" data-arg-title="${escapeHtml(escapeJs(t.title))}" data-arg-album="${escapeHtml(escapeJs(t.album || ""))}" data-arg-duration-seconds="${durationSeconds}" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="track-item" data-position="${t.position}">
|
<div class="track-item" data-position="${t.position}">
|
||||||
@@ -246,7 +246,7 @@ export async function searchJellyfinTracks() {
|
|||||||
const artist = track.artist || "";
|
const artist = track.artist || "";
|
||||||
const album = track.album || "";
|
const album = track.album || "";
|
||||||
return `
|
return `
|
||||||
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" onclick="selectJellyfinTrack('${escapeJs(id)}')">
|
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" data-action="selectJellyfinTrack" data-arg-jellyfin-id="${escapeHtml(escapeJs(id))}">
|
||||||
<div>
|
<div>
|
||||||
<strong>${escapeHtml(title)}</strong>
|
<strong>${escapeHtml(title)}</strong>
|
||||||
<br>
|
<br>
|
||||||
@@ -344,7 +344,15 @@ export async function searchExternalTracks() {
|
|||||||
const externalUrl = track.url || "";
|
const externalUrl = track.url || "";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}" onclick="selectExternalTrack(${index}, '${escapeJs(id)}', '${escapeJs(title)}', '${escapeJs(artist)}', '${escapeJs(providerName)}', '${escapeJs(externalUrl)}')">
|
<div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}"
|
||||||
|
data-action="selectExternalTrack"
|
||||||
|
data-arg-result-index="${index}"
|
||||||
|
data-arg-external-id="${escapeHtml(escapeJs(id))}"
|
||||||
|
data-arg-title="${escapeHtml(escapeJs(title))}"
|
||||||
|
data-arg-artist="${escapeHtml(escapeJs(artist))}"
|
||||||
|
data-arg-provider="${escapeHtml(escapeJs(providerName))}"
|
||||||
|
data-arg-external-url="${escapeHtml(escapeJs(externalUrl))}"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<strong>${escapeHtml(title)}</strong>
|
<strong>${escapeHtml(title)}</strong>
|
||||||
<br>
|
<br>
|
||||||
@@ -662,13 +670,26 @@ export async function saveLyricsMapping() {
|
|||||||
// Search provider (open in new tab)
|
// Search provider (open in new tab)
|
||||||
export async function searchProvider(query, provider) {
|
export async function searchProvider(query, provider) {
|
||||||
try {
|
try {
|
||||||
const data = await API.getSquidWTFBaseUrl();
|
const normalizedProvider = (provider || "squidwtf").toLowerCase();
|
||||||
const baseUrl = data.baseUrl; // Use the actual property name from API
|
let searchUrl = "";
|
||||||
const searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
|
|
||||||
|
if (normalizedProvider === "squidwtf" || normalizedProvider === "tidal") {
|
||||||
|
const data = await API.getSquidWTFBaseUrl();
|
||||||
|
const baseUrl = data.baseUrl;
|
||||||
|
searchUrl = `${baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||||
|
} else if (normalizedProvider === "deezer") {
|
||||||
|
searchUrl = `https://www.deezer.com/search/${encodeURIComponent(query)}`;
|
||||||
|
} else if (normalizedProvider === "qobuz") {
|
||||||
|
searchUrl = `https://www.qobuz.com/search?query=${encodeURIComponent(query)}`;
|
||||||
|
} else {
|
||||||
|
const data = await API.getSquidWTFBaseUrl();
|
||||||
|
const baseUrl = data.baseUrl;
|
||||||
|
searchUrl = `${baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||||
|
}
|
||||||
|
|
||||||
window.open(searchUrl, "_blank");
|
window.open(searchUrl, "_blank");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get SquidWTF base URL:", error);
|
console.error("Failed to open provider search:", error);
|
||||||
// Fallback to first encoded URL (triton)
|
showToast("Failed to open provider search link", "warning");
|
||||||
showToast("Failed to get SquidWTF URL, using fallback", "warning");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-29
@@ -34,17 +34,13 @@ import {
|
|||||||
} from "./playlist-admin.js";
|
} from "./playlist-admin.js";
|
||||||
import { initScrobblingAdmin } from "./scrobbling-admin.js";
|
import { initScrobblingAdmin } from "./scrobbling-admin.js";
|
||||||
import { initAuthSession } from "./auth-session.js";
|
import { initAuthSession } from "./auth-session.js";
|
||||||
|
import { initActionDispatcher } from "./action-dispatcher.js";
|
||||||
|
import { initNavigationView } from "./views/navigation-view.js";
|
||||||
|
import { initScrobblingView } from "./views/scrobbling-view.js";
|
||||||
|
|
||||||
let cookieDateInitialized = false;
|
let cookieDateInitialized = false;
|
||||||
let restartRequired = false;
|
let restartRequired = false;
|
||||||
|
|
||||||
window.showToast = showToast;
|
|
||||||
window.escapeHtml = escapeHtml;
|
|
||||||
window.escapeJs = escapeJs;
|
|
||||||
window.openModal = openModal;
|
|
||||||
window.closeModal = closeModal;
|
|
||||||
window.capitalizeProvider = capitalizeProvider;
|
|
||||||
|
|
||||||
window.showRestartBanner = function () {
|
window.showRestartBanner = function () {
|
||||||
restartRequired = true;
|
restartRequired = true;
|
||||||
document.getElementById("restart-banner")?.classList.add("active");
|
document.getElementById("restart-banner")?.classList.add("active");
|
||||||
@@ -58,17 +54,30 @@ window.switchTab = function (tabName) {
|
|||||||
document
|
document
|
||||||
.querySelectorAll(".tab")
|
.querySelectorAll(".tab")
|
||||||
.forEach((tab) => tab.classList.remove("active"));
|
.forEach((tab) => tab.classList.remove("active"));
|
||||||
|
document
|
||||||
|
.querySelectorAll(".sidebar-link")
|
||||||
|
.forEach((link) => link.classList.remove("active"));
|
||||||
document
|
document
|
||||||
.querySelectorAll(".tab-content")
|
.querySelectorAll(".tab-content")
|
||||||
.forEach((content) => content.classList.remove("active"));
|
.forEach((content) => content.classList.remove("active"));
|
||||||
|
|
||||||
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
||||||
|
const sidebarLink = document.querySelector(
|
||||||
|
`.sidebar-link[data-tab="${tabName}"]`,
|
||||||
|
);
|
||||||
const content = document.getElementById(`tab-${tabName}`);
|
const content = document.getElementById(`tab-${tabName}`);
|
||||||
|
|
||||||
if (tab && content) {
|
if (tab && content) {
|
||||||
tab.classList.add("active");
|
tab.classList.add("active");
|
||||||
|
if (sidebarLink) {
|
||||||
|
sidebarLink.classList.add("active");
|
||||||
|
}
|
||||||
content.classList.add("active");
|
content.classList.add("active");
|
||||||
window.location.hash = tabName;
|
window.location.hash = tabName;
|
||||||
|
|
||||||
|
if (tabName === "kept" && typeof window.fetchDownloads === "function") {
|
||||||
|
window.fetchDownloads();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,46 +147,112 @@ const authSession = initAuthSession({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
window.viewTracks = viewTracks;
|
|
||||||
window.openManualMap = openManualMap;
|
window.openManualMap = openManualMap;
|
||||||
window.openExternalMap = openExternalMap;
|
window.openExternalMap = openExternalMap;
|
||||||
window.openMapToLocal = openManualMap;
|
window.openMapToLocal = openManualMap;
|
||||||
window.openMapToExternal = openExternalMap;
|
window.openMapToExternal = openExternalMap;
|
||||||
|
window.openModal = openModal;
|
||||||
|
window.closeModal = closeModal;
|
||||||
window.searchJellyfinTracks = searchJellyfinTracks;
|
window.searchJellyfinTracks = searchJellyfinTracks;
|
||||||
window.selectJellyfinTrack = selectJellyfinTrack;
|
|
||||||
window.saveLocalMapping = saveLocalMapping;
|
window.saveLocalMapping = saveLocalMapping;
|
||||||
window.saveManualMapping = saveManualMapping;
|
window.saveManualMapping = saveManualMapping;
|
||||||
window.searchExternalTracks = searchExternalTracks;
|
window.searchExternalTracks = searchExternalTracks;
|
||||||
window.selectExternalTrack = selectExternalTrack;
|
|
||||||
window.validateExternalMapping = validateExternalMapping;
|
|
||||||
window.openLyricsMap = openLyricsMap;
|
|
||||||
window.saveLyricsMapping = saveLyricsMapping;
|
|
||||||
window.searchProvider = searchProvider;
|
window.searchProvider = searchProvider;
|
||||||
|
window.validateExternalMapping = validateExternalMapping;
|
||||||
|
window.saveLyricsMapping = saveLyricsMapping;
|
||||||
|
// Note: viewTracks/selectExternalTrack/selectJellyfinTrack/openLyricsMap/searchProvider
|
||||||
|
// are now wired via the ActionDispatcher and no longer require window exports.
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
console.log("🚀 Allstarr Admin UI (Modular) loaded");
|
console.log("🚀 Allstarr Admin UI (Modular) loaded");
|
||||||
|
|
||||||
document.querySelectorAll(".tab").forEach((tab) => {
|
const dispatcher = initActionDispatcher({ root: document });
|
||||||
tab.addEventListener("click", () => {
|
// Register a few core actions first; more will be migrated as inline
|
||||||
window.switchTab(tab.dataset.tab);
|
// onclick handlers are removed from HTML and generated markup.
|
||||||
});
|
dispatcher.register("switchTab", ({ args }) => {
|
||||||
|
const tab = args?.tab || args?.tabName;
|
||||||
|
if (tab) {
|
||||||
|
window.switchTab(tab);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
dispatcher.register("logoutAdminSession", () => window.logoutAdminSession?.());
|
||||||
|
dispatcher.register("dismissRestartBanner", () =>
|
||||||
|
window.dismissRestartBanner?.(),
|
||||||
|
);
|
||||||
|
dispatcher.register("restartContainer", () => window.restartContainer?.());
|
||||||
|
dispatcher.register("refreshPlaylists", () => window.refreshPlaylists?.());
|
||||||
|
dispatcher.register("clearCache", () => window.clearCache?.());
|
||||||
|
dispatcher.register("openAddPlaylist", () => window.openAddPlaylist?.());
|
||||||
|
dispatcher.register("toggleRowMenu", ({ event, args }) =>
|
||||||
|
window.toggleRowMenu?.(event, args?.menuId),
|
||||||
|
);
|
||||||
|
dispatcher.register("toggleDetailsRow", ({ event, args }) =>
|
||||||
|
window.toggleDetailsRow?.(event, args?.detailsRowId),
|
||||||
|
);
|
||||||
|
dispatcher.register("viewTracks", ({ args }) => viewTracks(args?.playlistName));
|
||||||
|
dispatcher.register("refreshPlaylist", ({ args }) =>
|
||||||
|
window.refreshPlaylist?.(args?.playlistName),
|
||||||
|
);
|
||||||
|
dispatcher.register("matchPlaylistTracks", ({ args }) =>
|
||||||
|
window.matchPlaylistTracks?.(args?.playlistName),
|
||||||
|
);
|
||||||
|
dispatcher.register("clearPlaylistCache", ({ args }) =>
|
||||||
|
window.clearPlaylistCache?.(args?.playlistName),
|
||||||
|
);
|
||||||
|
dispatcher.register("editPlaylistSchedule", ({ args }) =>
|
||||||
|
window.editPlaylistSchedule?.(args?.playlistName, args?.syncSchedule),
|
||||||
|
);
|
||||||
|
dispatcher.register("removePlaylist", ({ args }) =>
|
||||||
|
window.removePlaylist?.(args?.playlistName),
|
||||||
|
);
|
||||||
|
dispatcher.register("openLinkPlaylist", ({ args }) =>
|
||||||
|
window.openLinkPlaylist?.(args?.jellyfinId, args?.jellyfinName),
|
||||||
|
);
|
||||||
|
dispatcher.register("unlinkPlaylist", ({ args }) =>
|
||||||
|
window.unlinkPlaylist?.(args?.jellyfinId, args?.jellyfinName),
|
||||||
|
);
|
||||||
|
dispatcher.register("fetchJellyfinPlaylists", () =>
|
||||||
|
window.fetchJellyfinPlaylists?.(),
|
||||||
|
);
|
||||||
|
dispatcher.register("searchProvider", ({ args }) =>
|
||||||
|
searchProvider(args?.query, args?.provider),
|
||||||
|
);
|
||||||
|
dispatcher.register("openLyricsMap", ({ args, toNumber }) =>
|
||||||
|
openLyricsMap(
|
||||||
|
args?.artist,
|
||||||
|
args?.title,
|
||||||
|
args?.album,
|
||||||
|
toNumber(args?.durationSeconds) ?? 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
dispatcher.register("selectJellyfinTrack", ({ args }) =>
|
||||||
|
selectJellyfinTrack(args?.jellyfinId),
|
||||||
|
);
|
||||||
|
dispatcher.register("selectExternalTrack", ({ args, toNumber }) =>
|
||||||
|
selectExternalTrack(
|
||||||
|
toNumber(args?.resultIndex),
|
||||||
|
args?.externalId,
|
||||||
|
args?.title,
|
||||||
|
args?.artist,
|
||||||
|
args?.provider,
|
||||||
|
args?.externalUrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
dispatcher.register("downloadFile", ({ args }) =>
|
||||||
|
window.downloadFile?.(args?.path),
|
||||||
|
);
|
||||||
|
dispatcher.register("deleteDownload", ({ args }) =>
|
||||||
|
window.deleteDownload?.(args?.path),
|
||||||
|
);
|
||||||
|
|
||||||
const hash = window.location.hash.substring(1);
|
initNavigationView({ switchTab: window.switchTab });
|
||||||
if (hash) {
|
|
||||||
window.switchTab(hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
setupModalBackdropClose();
|
setupModalBackdropClose();
|
||||||
|
|
||||||
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
|
initScrobblingView({
|
||||||
if (scrobblingTab) {
|
isAuthenticated: () => authSession.isAuthenticated(),
|
||||||
scrobblingTab.addEventListener("click", () => {
|
loadScrobblingConfig: () => window.loadScrobblingConfig?.(),
|
||||||
if (authSession.isAuthenticated()) {
|
});
|
||||||
window.loadScrobblingConfig();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
authSession.bootstrapAuth();
|
authSession.bootstrapAuth();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,100 @@
|
|||||||
// Modal management
|
// Modal management
|
||||||
|
const modalState = new Map();
|
||||||
|
const FOCUSABLE_SELECTOR =
|
||||||
|
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
function getModal(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFocusableElements(modal) {
|
||||||
|
return Array.from(modal.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
|
||||||
|
(el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModalKeyDown(event, modal) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeModal(modal.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== "Tab") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusable = getFocusableElements(modal);
|
||||||
|
if (focusable.length === 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
const isShift = event.shiftKey;
|
||||||
|
|
||||||
|
if (isShift && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!isShift && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function openModal(id) {
|
export function openModal(id) {
|
||||||
document.getElementById(id).classList.add('active');
|
const modal = getModal(id);
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
const modalContent = modal.querySelector(".modal-content");
|
||||||
|
if (!modalContent) return;
|
||||||
|
|
||||||
|
const previousActive = document.activeElement;
|
||||||
|
modalState.set(id, { previousActive });
|
||||||
|
|
||||||
|
modal.setAttribute("role", "dialog");
|
||||||
|
modal.setAttribute("aria-modal", "true");
|
||||||
|
modal.removeAttribute("aria-hidden");
|
||||||
|
modal.classList.add("active");
|
||||||
|
|
||||||
|
const keydownHandler = (event) => onModalKeyDown(event, modal);
|
||||||
|
modalState.set(id, { previousActive, keydownHandler });
|
||||||
|
modal.addEventListener("keydown", keydownHandler);
|
||||||
|
|
||||||
|
const focusable = getFocusableElements(modalContent);
|
||||||
|
if (focusable.length > 0) {
|
||||||
|
focusable[0].focus();
|
||||||
|
} else {
|
||||||
|
modalContent.setAttribute("tabindex", "-1");
|
||||||
|
modalContent.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeModal(id) {
|
export function closeModal(id) {
|
||||||
document.getElementById(id).classList.remove('active');
|
const modal = getModal(id);
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
modal.classList.remove("active");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
|
const state = modalState.get(id);
|
||||||
|
if (state?.keydownHandler) {
|
||||||
|
modal.removeEventListener("keydown", state.keydownHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state?.previousActive && typeof state.previousActive.focus === "function") {
|
||||||
|
state.previousActive.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
modalState.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupModalBackdropClose() {
|
export function setupModalBackdropClose() {
|
||||||
document.querySelectorAll('.modal').forEach(modal => {
|
document.querySelectorAll(".modal").forEach((modal) => {
|
||||||
modal.addEventListener('click', e => {
|
modal.setAttribute("aria-hidden", "true");
|
||||||
if (e.target === modal) closeModal(modal.id);
|
modal.addEventListener("click", (e) => {
|
||||||
});
|
if (e.target === modal) closeModal(modal.id);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,20 @@ function downloadAllKept() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteAllKept() {
|
||||||
|
const result = await runAction({
|
||||||
|
confirmMessage:
|
||||||
|
"Delete ALL kept downloads?\n\nThis will permanently remove all kept audio files.",
|
||||||
|
task: () => API.deleteAllDownloads(),
|
||||||
|
success: (data) => data.message || "All kept downloads deleted",
|
||||||
|
error: (err) => err.message || "Failed to delete all kept downloads",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
await fetchDownloads();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteDownload(path) {
|
async function deleteDownload(path) {
|
||||||
const result = await runAction({
|
const result = await runAction({
|
||||||
confirmMessage: `Delete this file?\n\n${path}\n\nThis action cannot be undone.`,
|
confirmMessage: `Delete this file?\n\n${path}\n\nThis action cannot be undone.`,
|
||||||
@@ -270,7 +284,7 @@ async function importEnv(event) {
|
|||||||
|
|
||||||
const result = await runAction({
|
const result = await runAction({
|
||||||
confirmMessage:
|
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),
|
task: () => API.importEnv(file),
|
||||||
success: (data) => data.message,
|
success: (data) => data.message,
|
||||||
error: (err) => err.message || "Failed to import .env file",
|
error: (err) => err.message || "Failed to import .env file",
|
||||||
@@ -283,7 +297,7 @@ async function importEnv(event) {
|
|||||||
async function restartContainer() {
|
async function restartContainer() {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!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;
|
return;
|
||||||
@@ -291,7 +305,7 @@ async function restartContainer() {
|
|||||||
|
|
||||||
const result = await runAction({
|
const result = await runAction({
|
||||||
task: () => API.restartContainer(),
|
task: () => API.restartContainer(),
|
||||||
error: "Failed to restart container",
|
error: "Failed to restart Allstarr",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -301,7 +315,7 @@ async function restartContainer() {
|
|||||||
document.getElementById("restart-overlay")?.classList.add("active");
|
document.getElementById("restart-overlay")?.classList.add("active");
|
||||||
const statusEl = document.getElementById("restart-status");
|
const statusEl = document.getElementById("restart-status");
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
statusEl.textContent = "Stopping container...";
|
statusEl.textContent = "Restarting Allstarr...";
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -364,6 +378,7 @@ export function initOperations(options) {
|
|||||||
window.deleteTrackMapping = deleteTrackMapping;
|
window.deleteTrackMapping = deleteTrackMapping;
|
||||||
window.downloadFile = downloadFile;
|
window.downloadFile = downloadFile;
|
||||||
window.downloadAllKept = downloadAllKept;
|
window.downloadAllKept = downloadAllKept;
|
||||||
|
window.deleteAllKept = deleteAllKept;
|
||||||
window.deleteDownload = deleteDownload;
|
window.deleteDownload = deleteDownload;
|
||||||
window.refreshPlaylists = refreshPlaylists;
|
window.refreshPlaylists = refreshPlaylists;
|
||||||
window.refreshPlaylist = refreshPlaylist;
|
window.refreshPlaylist = refreshPlaylist;
|
||||||
|
|||||||
@@ -70,7 +70,12 @@ async function openLinkPlaylist(jellyfinId, name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
spotifyUserPlaylists = await API.fetchSpotifyUserPlaylists(selectedUserId);
|
const response = await API.fetchSpotifyUserPlaylists(selectedUserId);
|
||||||
|
spotifyUserPlaylists = Array.isArray(response?.playlists)
|
||||||
|
? response.playlists
|
||||||
|
: Array.isArray(response)
|
||||||
|
? response
|
||||||
|
: [];
|
||||||
spotifyUserPlaylistsScopeUserId = selectedUserId;
|
spotifyUserPlaylistsScopeUserId = selectedUserId;
|
||||||
const availablePlaylists = spotifyUserPlaylists.filter((p) => !p.isLinked);
|
const availablePlaylists = spotifyUserPlaylists.filter((p) => !p.isLinked);
|
||||||
|
|
||||||
|
|||||||
@@ -211,12 +211,26 @@ const SETTINGS_REGISTRY = {
|
|||||||
ensureConfigSection(config, "deezer").quality = value;
|
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(
|
SQUIDWTF_QUALITY: textBinding(
|
||||||
(config) => config?.squidWtf?.quality ?? "LOSSLESS",
|
(config) => config?.squidWtf?.quality ?? "LOSSLESS",
|
||||||
(config, value) => {
|
(config, value) => {
|
||||||
ensureConfigSection(config, "squidWtf").quality = 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(
|
MUSICBRAINZ_ENABLED: toggleBinding(
|
||||||
(config) => config?.musicBrainz?.enabled ?? false,
|
(config) => config?.musicBrainz?.enabled ?? false,
|
||||||
(config, value) => {
|
(config, value) => {
|
||||||
@@ -247,6 +261,13 @@ const SETTINGS_REGISTRY = {
|
|||||||
ensureConfigSection(config, "qobuz").quality = value;
|
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(
|
JELLYFIN_URL: textBinding(
|
||||||
(config) => config?.jellyfin?.url ?? "",
|
(config) => config?.jellyfin?.url ?? "",
|
||||||
(config, value) => {
|
(config, value) => {
|
||||||
|
|||||||
+92
-30
@@ -3,6 +3,8 @@
|
|||||||
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
|
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
|
||||||
|
|
||||||
let rowMenuHandlersBound = false;
|
let rowMenuHandlersBound = false;
|
||||||
|
let tableRowHandlersBound = false;
|
||||||
|
const expandedInjectedPlaylistDetails = new Set();
|
||||||
|
|
||||||
function bindRowMenuHandlers() {
|
function bindRowMenuHandlers() {
|
||||||
if (rowMenuHandlersBound) {
|
if (rowMenuHandlersBound) {
|
||||||
@@ -16,6 +18,41 @@ function bindRowMenuHandlers() {
|
|||||||
rowMenuHandlersBound = true;
|
rowMenuHandlersBound = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindTableRowHandlers() {
|
||||||
|
if (tableRowHandlersBound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const detailsTrigger = event.target.closest?.(
|
||||||
|
"button.details-trigger[data-details-target]",
|
||||||
|
);
|
||||||
|
if (detailsTrigger) {
|
||||||
|
const target = detailsTrigger.getAttribute("data-details-target");
|
||||||
|
if (target) {
|
||||||
|
toggleDetailsRow(event, target);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = event.target.closest?.("tr.compact-row[data-details-row]");
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.closest("button, a, .row-actions-menu")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailsRowId = row.getAttribute("data-details-row");
|
||||||
|
if (detailsRowId) {
|
||||||
|
toggleDetailsRow(null, detailsRowId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tableRowHandlersBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
function closeAllRowMenus(exceptId = null) {
|
function closeAllRowMenus(exceptId = null) {
|
||||||
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
|
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
|
||||||
if (!exceptId || menu.id !== exceptId) {
|
if (!exceptId || menu.id !== exceptId) {
|
||||||
@@ -82,6 +119,18 @@ function toggleDetailsRow(event, detailsRowId) {
|
|||||||
);
|
);
|
||||||
if (parentRow) {
|
if (parentRow) {
|
||||||
parentRow.classList.toggle("expanded", isExpanded);
|
parentRow.classList.toggle("expanded", isExpanded);
|
||||||
|
|
||||||
|
// Persist Injected Playlists details expansion across auto-refreshes.
|
||||||
|
if (parentRow.closest("#playlist-table-body")) {
|
||||||
|
const detailsKey = parentRow.getAttribute("data-details-key");
|
||||||
|
if (detailsKey) {
|
||||||
|
if (isExpanded) {
|
||||||
|
expandedInjectedPlaylistDetails.add(detailsKey);
|
||||||
|
} else {
|
||||||
|
expandedInjectedPlaylistDetails.delete(detailsKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,11 +232,15 @@ if (typeof window !== "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bindRowMenuHandlers();
|
bindRowMenuHandlers();
|
||||||
|
bindTableRowHandlers();
|
||||||
|
|
||||||
export function updateStatusUI(data) {
|
export function updateStatusUI(data) {
|
||||||
const versionEl = document.getElementById("version");
|
const versionEl = document.getElementById("version");
|
||||||
if (versionEl) versionEl.textContent = "v" + data.version;
|
if (versionEl) versionEl.textContent = "v" + data.version;
|
||||||
|
|
||||||
|
const sidebarVersionEl = document.getElementById("sidebar-version");
|
||||||
|
if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version;
|
||||||
|
|
||||||
const backendTypeEl = document.getElementById("backend-type");
|
const backendTypeEl = document.getElementById("backend-type");
|
||||||
if (backendTypeEl) backendTypeEl.textContent = data.backendType;
|
if (backendTypeEl) backendTypeEl.textContent = data.backendType;
|
||||||
|
|
||||||
@@ -271,6 +324,7 @@ export function updatePlaylistsUI(data) {
|
|||||||
const playlists = data.playlists || [];
|
const playlists = data.playlists || [];
|
||||||
|
|
||||||
if (playlists.length === 0) {
|
if (playlists.length === 0) {
|
||||||
|
expandedInjectedPlaylistDetails.clear();
|
||||||
tbody.innerHTML =
|
tbody.innerHTML =
|
||||||
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
|
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
|
||||||
renderGuidance("playlists-guidance", [
|
renderGuidance("playlists-guidance", [
|
||||||
@@ -329,9 +383,12 @@ export function updatePlaylistsUI(data) {
|
|||||||
const summary = getPlaylistStatusSummary(playlist);
|
const summary = getPlaylistStatusSummary(playlist);
|
||||||
const detailsRowId = `playlist-details-${index}`;
|
const detailsRowId = `playlist-details-${index}`;
|
||||||
const menuId = `playlist-menu-${index}`;
|
const menuId = `playlist-menu-${index}`;
|
||||||
|
const detailsKey = `${playlist.id || playlist.name || index}`;
|
||||||
|
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
|
||||||
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
|
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
|
||||||
const escapedPlaylistName = escapeJs(playlist.name);
|
const escapedPlaylistName = escapeHtml(playlist.name);
|
||||||
const escapedSyncSchedule = escapeJs(syncSchedule);
|
const escapedSyncSchedule = escapeHtml(syncSchedule);
|
||||||
|
const escapedDetailsKey = escapeHtml(detailsKey);
|
||||||
|
|
||||||
const breakdownBadges = [
|
const breakdownBadges = [
|
||||||
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
||||||
@@ -345,7 +402,7 @@ export function updatePlaylistsUI(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
|
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
|
||||||
<td>
|
<td>
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<strong>${escapeHtml(playlist.name)}</strong>
|
<strong>${escapeHtml(playlist.name)}</strong>
|
||||||
@@ -358,24 +415,23 @@ export function updatePlaylistsUI(data) {
|
|||||||
</td>
|
</td>
|
||||||
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
|
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
|
||||||
<td class="row-controls">
|
<td class="row-controls">
|
||||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false"
|
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button>
|
||||||
onclick="toggleDetailsRow(event, '${detailsRowId}')">Details</button>
|
|
||||||
<div class="row-actions-wrap">
|
<div class="row-actions-wrap">
|
||||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
||||||
onclick="toggleRowMenu(event, '${menuId}')">...</button>
|
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
||||||
<div class="row-actions-menu" id="${menuId}" role="menu">
|
<div class="row-actions-menu" id="${menuId}" role="menu">
|
||||||
<button onclick="closeRowMenu(event, '${menuId}'); viewTracks('${escapedPlaylistName}')">View Tracks</button>
|
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button>
|
||||||
<button onclick="closeRowMenu(event, '${menuId}'); refreshPlaylist('${escapedPlaylistName}')">Refresh</button>
|
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button>
|
||||||
<button onclick="closeRowMenu(event, '${menuId}'); matchPlaylistTracks('${escapedPlaylistName}')">Rematch</button>
|
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button>
|
||||||
<button onclick="closeRowMenu(event, '${menuId}'); clearPlaylistCache('${escapedPlaylistName}')">Rebuild</button>
|
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button>
|
||||||
<button onclick="closeRowMenu(event, '${menuId}'); editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit Schedule</button>
|
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button>
|
||||||
<hr>
|
<hr>
|
||||||
<button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); removePlaylist('${escapedPlaylistName}')">Remove Playlist</button>
|
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="${detailsRowId}" class="details-row" hidden>
|
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
<div class="details-panel">
|
<div class="details-panel">
|
||||||
<div class="details-grid">
|
<div class="details-grid">
|
||||||
@@ -383,7 +439,7 @@ export function updatePlaylistsUI(data) {
|
|||||||
<span class="detail-label">Sync Schedule</span>
|
<span class="detail-label">Sync Schedule</span>
|
||||||
<span class="detail-value mono">
|
<span class="detail-value mono">
|
||||||
${escapeHtml(syncSchedule)}
|
${escapeHtml(syncSchedule)}
|
||||||
<button class="inline-action-link" onclick="editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit</button>
|
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
@@ -478,9 +534,9 @@ export function updateDownloadsUI(data) {
|
|||||||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||||
<td>
|
<td>
|
||||||
<button onclick="downloadFile('${escapeJs(f.path)}')"
|
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
|
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
|
||||||
<button onclick="deleteDownload('${escapeJs(f.path)}')"
|
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -536,8 +592,12 @@ export function updateConfigUI(data) {
|
|||||||
data.deezer.arl || "(not set)";
|
data.deezer.arl || "(not set)";
|
||||||
document.getElementById("config-deezer-quality").textContent =
|
document.getElementById("config-deezer-quality").textContent =
|
||||||
data.deezer.quality;
|
data.deezer.quality;
|
||||||
|
document.getElementById("config-deezer-ratelimit").textContent =
|
||||||
|
(data.deezer.minRequestIntervalMs || 200) + " ms";
|
||||||
document.getElementById("config-squid-quality").textContent =
|
document.getElementById("config-squid-quality").textContent =
|
||||||
data.squidWtf.quality;
|
data.squidWtf.quality;
|
||||||
|
document.getElementById("config-squid-ratelimit").textContent =
|
||||||
|
(data.squidWtf.minRequestIntervalMs || 200) + " ms";
|
||||||
document.getElementById("config-musicbrainz-enabled").textContent = data
|
document.getElementById("config-musicbrainz-enabled").textContent = data
|
||||||
.musicBrainz.enabled
|
.musicBrainz.enabled
|
||||||
? "Yes"
|
? "Yes"
|
||||||
@@ -546,6 +606,8 @@ export function updateConfigUI(data) {
|
|||||||
data.qobuz.userAuthToken || "(not set)";
|
data.qobuz.userAuthToken || "(not set)";
|
||||||
document.getElementById("config-qobuz-quality").textContent =
|
document.getElementById("config-qobuz-quality").textContent =
|
||||||
data.qobuz.quality || "FLAC";
|
data.qobuz.quality || "FLAC";
|
||||||
|
document.getElementById("config-qobuz-ratelimit").textContent =
|
||||||
|
(data.qobuz.minRequestIntervalMs || 200) + " ms";
|
||||||
document.getElementById("config-jellyfin-url").textContent =
|
document.getElementById("config-jellyfin-url").textContent =
|
||||||
data.jellyfin.url || "-";
|
data.jellyfin.url || "-";
|
||||||
document.getElementById("config-jellyfin-api-key").textContent =
|
document.getElementById("config-jellyfin-api-key").textContent =
|
||||||
@@ -628,26 +690,27 @@ export function updateJellyfinPlaylistsUI(data) {
|
|||||||
.map((playlist, index) => {
|
.map((playlist, index) => {
|
||||||
const detailsRowId = `jellyfin-details-${index}`;
|
const detailsRowId = `jellyfin-details-${index}`;
|
||||||
const menuId = `jellyfin-menu-${index}`;
|
const menuId = `jellyfin-menu-${index}`;
|
||||||
|
const statsPending = Boolean(playlist.statsPending);
|
||||||
const localCount = playlist.localTracks || 0;
|
const localCount = playlist.localTracks || 0;
|
||||||
const externalCount = playlist.externalTracks || 0;
|
const externalCount = playlist.externalTracks || 0;
|
||||||
const externalAvailable = playlist.externalAvailable || 0;
|
const externalAvailable = playlist.externalAvailable || 0;
|
||||||
const escapedId = escapeJs(playlist.id);
|
const escapedId = escapeHtml(playlist.id);
|
||||||
const escapedName = escapeJs(playlist.name);
|
const escapedName = escapeHtml(playlist.name);
|
||||||
const statusClass = playlist.isConfigured ? "success" : "info";
|
const statusClass = playlist.isConfigured ? "success" : "info";
|
||||||
const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked";
|
const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked";
|
||||||
|
|
||||||
const actionButtons = playlist.isConfigured
|
const actionButtons = playlist.isConfigured
|
||||||
? `
|
? `
|
||||||
<button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
|
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
|
||||||
<button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); unlinkPlaylist('${escapedId}', '${escapedName}')">Unlink from Spotify</button>
|
<button class="danger-item" data-action="unlinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Unlink from Spotify</button>
|
||||||
`
|
`
|
||||||
: `
|
: `
|
||||||
<button onclick="closeRowMenu(event, '${menuId}'); openLinkPlaylist('${escapedId}', '${escapedName}')">Link to Spotify</button>
|
<button data-action="openLinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Link to Spotify</button>
|
||||||
<button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
|
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
|
<tr class="compact-row" data-details-row="${detailsRowId}">
|
||||||
<td>
|
<td>
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<strong>${escapeHtml(playlist.name)}</strong>
|
<strong>${escapeHtml(playlist.name)}</strong>
|
||||||
@@ -655,16 +718,15 @@ export function updateJellyfinPlaylistsUI(data) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="track-count">${localCount + externalAvailable}</span>
|
<span class="track-count">${statsPending ? "..." : localCount + externalAvailable}</span>
|
||||||
<div class="meta-text">L ${localCount} • E ${externalAvailable}/${externalCount}</div>
|
<div class="meta-text">${statsPending ? "Loading track stats..." : `L ${localCount} • E ${externalAvailable}/${externalCount}`}</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
|
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
|
||||||
<td class="row-controls">
|
<td class="row-controls">
|
||||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false"
|
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false">Details</button>
|
||||||
onclick="toggleDetailsRow(event, '${detailsRowId}')">Details</button>
|
|
||||||
<div class="row-actions-wrap">
|
<div class="row-actions-wrap">
|
||||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
||||||
onclick="toggleRowMenu(event, '${menuId}')">...</button>
|
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
||||||
<div class="row-actions-menu" id="${menuId}" role="menu">
|
<div class="row-actions-menu" id="${menuId}" role="menu">
|
||||||
${actionButtons}
|
${actionButtons}
|
||||||
</div>
|
</div>
|
||||||
@@ -677,11 +739,11 @@ export function updateJellyfinPlaylistsUI(data) {
|
|||||||
<div class="details-grid">
|
<div class="details-grid">
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">Local Tracks</span>
|
<span class="detail-label">Local Tracks</span>
|
||||||
<span class="detail-value">${localCount}</span>
|
<span class="detail-value">${statsPending ? "..." : localCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">External Tracks</span>
|
<span class="detail-label">External Tracks</span>
|
||||||
<span class="detail-value">${externalAvailable}/${externalCount}</span>
|
<span class="detail-value">${statsPending ? "Loading..." : `${externalAvailable}/${externalCount}`}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">Linked Spotify ID</span>
|
<span class="detail-label">Linked Spotify ID</span>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
This folder contains small “view” modules for the admin UI.
|
||||||
|
|
||||||
|
Goal: keep `js/main.js` as orchestration only, while view modules encapsulate DOM wiring for each section.
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export function initNavigationView({ switchTab } = {}) {
|
||||||
|
const doSwitch =
|
||||||
|
typeof switchTab === "function" ? switchTab : (tab) => window.switchTab?.(tab);
|
||||||
|
|
||||||
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
doSwitch(tab.dataset.tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".sidebar-link").forEach((link) => {
|
||||||
|
link.addEventListener("click", () => {
|
||||||
|
doSwitch(link.dataset.tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hash = window.location.hash.substring(1);
|
||||||
|
if (hash) {
|
||||||
|
doSwitch(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export function initScrobblingView({
|
||||||
|
isAuthenticated,
|
||||||
|
loadScrobblingConfig,
|
||||||
|
} = {}) {
|
||||||
|
const canLoad =
|
||||||
|
typeof isAuthenticated === "function" ? isAuthenticated : () => false;
|
||||||
|
const load =
|
||||||
|
typeof loadScrobblingConfig === "function"
|
||||||
|
? loadScrobblingConfig
|
||||||
|
: () => window.loadScrobblingConfig?.();
|
||||||
|
|
||||||
|
function onActivateScrobbling() {
|
||||||
|
if (canLoad()) {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
|
||||||
|
if (scrobblingTab) {
|
||||||
|
scrobblingTab.addEventListener("click", onActivateScrobbling);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrobblingSidebar = document.querySelector(
|
||||||
|
'.sidebar-link[data-tab="scrobbling"]',
|
||||||
|
);
|
||||||
|
if (scrobblingSidebar) {
|
||||||
|
scrobblingSidebar.addEventListener("click", onActivateScrobbling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Spotify Track Mappings - Allstarr</title>
|
<title>Spotify Track Mappings - Allstarr</title>
|
||||||
|
<link rel="stylesheet" href="styles.css" />
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #0d1117;
|
--bg-primary: #0d1117;
|
||||||
@@ -41,6 +42,26 @@
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.support-footer {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
padding: 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-footer a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-footer a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -646,5 +667,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="support-footer">
|
||||||
|
<p>
|
||||||
|
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||||
|
supporting its development via
|
||||||
|
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||||
|
or
|
||||||
|
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ let localMapContext = null;
|
|||||||
let localMapResults = [];
|
let localMapResults = [];
|
||||||
let localMapSelectedIndex = -1;
|
let localMapSelectedIndex = -1;
|
||||||
let externalMapContext = null;
|
let externalMapContext = null;
|
||||||
|
const modalFocusState = new Map();
|
||||||
|
|
||||||
function showToast(message, type = "success", duration = 3000) {
|
function showToast(message, type = "success", duration = 3000) {
|
||||||
const toast = document.createElement("div");
|
const toast = document.createElement("div");
|
||||||
@@ -247,9 +248,26 @@ function toggleModal(modalId, shouldOpen) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
|
const previousActive = document.activeElement;
|
||||||
|
modalFocusState.set(modalId, previousActive);
|
||||||
|
modal.setAttribute("role", "dialog");
|
||||||
|
modal.setAttribute("aria-modal", "true");
|
||||||
|
modal.removeAttribute("aria-hidden");
|
||||||
modal.classList.add("active");
|
modal.classList.add("active");
|
||||||
|
const firstFocusable = modal.querySelector(
|
||||||
|
'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
if (firstFocusable) {
|
||||||
|
firstFocusable.focus();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
modal.classList.remove("active");
|
modal.classList.remove("active");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
const previousActive = modalFocusState.get(modalId);
|
||||||
|
if (previousActive && typeof previousActive.focus === "function") {
|
||||||
|
previousActive.focus();
|
||||||
|
}
|
||||||
|
modalFocusState.delete(modalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,6 +645,10 @@ function initializeEventListeners() {
|
|||||||
closeLocalMapModal();
|
closeLocalMapModal();
|
||||||
closeExternalMapModal();
|
closeExternalMapModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".modal-overlay").forEach((modal) => {
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
|
|||||||
@@ -69,12 +69,144 @@ body {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.support-badge {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(22, 27, 34, 0.94);
|
||||||
|
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-badge a,
|
||||||
|
.support-footer a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-badge a:hover,
|
||||||
|
.support-footer a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(22, 27, 34, 0.8);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
padding: 14px;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-subtitle {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link:hover {
|
||||||
|
background: rgba(33, 38, 45, 0.7);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link.active {
|
||||||
|
background: rgba(88, 166, 255, 0.12);
|
||||||
|
border-color: rgba(88, 166, 255, 0.35);
|
||||||
|
color: #9ecbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-tabs,
|
||||||
|
.tabs.top-tabs {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-footer {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 20px 0 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -859,6 +991,31 @@ input::placeholder {
|
|||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-badge {
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: min(340px, calc(100vw - 24px));
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-footer {
|
||||||
|
padding-top: 16px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tab-content {
|
.tab-content {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -867,6 +1024,140 @@ input::placeholder {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Utility classes to reduce inline styles in index.html */
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-warning {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-16 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-8 {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-12 {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-row-wrap {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-row-wrap-8 {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.warning {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.accent {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout.warning {
|
||||||
|
background: rgba(245, 158, 11, 0.12);
|
||||||
|
border-color: var(--warning);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout.warning-strong {
|
||||||
|
background: rgba(255, 193, 7, 0.15);
|
||||||
|
border-color: #ffc107;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.callout.danger {
|
||||||
|
background: rgba(248, 81, 73, 0.15);
|
||||||
|
border-color: var(--error);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-card {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid-auto {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-h-600 {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jellyfin-user-form-group {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jellyfin-user-form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tracks-list {
|
.tracks-list {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -980,3 +1271,119 @@ input::placeholder {
|
|||||||
transform: rotate(360deg);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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
@@ -1,17 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
redis:
|
valkey:
|
||||||
image: redis:7-alpine
|
image: valkey/valkey:8
|
||||||
container_name: allstarr-redis
|
container_name: allstarr-valkey
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Redis is only accessible internally - no external port exposure
|
# Valkey is only accessible internally - no external port exposure
|
||||||
expose:
|
expose:
|
||||||
- "6379"
|
- "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:
|
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
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
volumes:
|
volumes:
|
||||||
- ${REDIS_DATA_PATH:-./redis-data}:/data
|
- ${REDIS_DATA_PATH:-./redis-data}:/data
|
||||||
networks:
|
networks:
|
||||||
@@ -52,7 +54,7 @@ services:
|
|||||||
# DO NOT expose through reverse proxy - contains sensitive config
|
# DO NOT expose through reverse proxy - contains sensitive config
|
||||||
- "5275:5275"
|
- "5275:5275"
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
valkey:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
spotify-lyrics:
|
spotify-lyrics:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
@@ -72,8 +74,8 @@ services:
|
|||||||
- Admin__BindAnyIp=${ADMIN_BIND_ANY_IP:-false}
|
- Admin__BindAnyIp=${ADMIN_BIND_ANY_IP:-false}
|
||||||
- Admin__TrustedSubnets=${ADMIN_TRUSTED_SUBNETS:-}
|
- Admin__TrustedSubnets=${ADMIN_TRUSTED_SUBNETS:-}
|
||||||
|
|
||||||
# ===== REDIS CACHE =====
|
# ===== REDIS / VALKEY CACHE =====
|
||||||
- Redis__ConnectionString=redis:6379
|
- Redis__ConnectionString=valkey:6379
|
||||||
- Redis__Enabled=${REDIS_ENABLED:-true}
|
- Redis__Enabled=${REDIS_ENABLED:-true}
|
||||||
|
|
||||||
# ===== CACHE TTL SETTINGS =====
|
# ===== CACHE TTL SETTINGS =====
|
||||||
@@ -86,6 +88,7 @@ services:
|
|||||||
- Cache__MetadataDays=${CACHE_METADATA_DAYS:-7}
|
- Cache__MetadataDays=${CACHE_METADATA_DAYS:-7}
|
||||||
- Cache__OdesliLookupDays=${CACHE_ODESLI_LOOKUP_DAYS:-60}
|
- Cache__OdesliLookupDays=${CACHE_ODESLI_LOOKUP_DAYS:-60}
|
||||||
- Cache__ProxyImagesDays=${CACHE_PROXY_IMAGES_DAYS:-14}
|
- Cache__ProxyImagesDays=${CACHE_PROXY_IMAGES_DAYS:-14}
|
||||||
|
- Cache__TranscodeCacheMinutes=${CACHE_TRANSCODE_MINUTES:-60}
|
||||||
|
|
||||||
# ===== SUBSONIC BACKEND =====
|
# ===== SUBSONIC BACKEND =====
|
||||||
- Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533}
|
- Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533}
|
||||||
@@ -152,12 +155,15 @@ services:
|
|||||||
# ===== SHARED =====
|
# ===== SHARED =====
|
||||||
- Library__DownloadPath=/app/downloads
|
- Library__DownloadPath=/app/downloads
|
||||||
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}
|
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}
|
||||||
|
- SquidWTF__MinRequestIntervalMs=${SQUIDWTF_MIN_REQUEST_INTERVAL_MS:-200}
|
||||||
- Deezer__Arl=${DEEZER_ARL:-}
|
- Deezer__Arl=${DEEZER_ARL:-}
|
||||||
- Deezer__ArlFallback=${DEEZER_ARL_FALLBACK:-}
|
- Deezer__ArlFallback=${DEEZER_ARL_FALLBACK:-}
|
||||||
- Deezer__Quality=${DEEZER_QUALITY:-FLAC}
|
- Deezer__Quality=${DEEZER_QUALITY:-FLAC}
|
||||||
|
- Deezer__MinRequestIntervalMs=${DEEZER_MIN_REQUEST_INTERVAL_MS:-200}
|
||||||
- Qobuz__UserAuthToken=${QOBUZ_USER_AUTH_TOKEN:-}
|
- Qobuz__UserAuthToken=${QOBUZ_USER_AUTH_TOKEN:-}
|
||||||
- Qobuz__UserId=${QOBUZ_USER_ID:-}
|
- Qobuz__UserId=${QOBUZ_USER_ID:-}
|
||||||
- Qobuz__Quality=${QOBUZ_QUALITY:-FLAC}
|
- Qobuz__Quality=${QOBUZ_QUALITY:-FLAC}
|
||||||
|
- Qobuz__MinRequestIntervalMs=${QOBUZ_MIN_REQUEST_INTERVAL_MS:-200}
|
||||||
- MusicBrainz__Enabled=${MUSICBRAINZ_ENABLED:-true}
|
- MusicBrainz__Enabled=${MUSICBRAINZ_ENABLED:-true}
|
||||||
- MusicBrainz__Username=${MUSICBRAINZ_USERNAME:-}
|
- MusicBrainz__Username=${MUSICBRAINZ_USERNAME:-}
|
||||||
- MusicBrainz__Password=${MUSICBRAINZ_PASSWORD:-}
|
- MusicBrainz__Password=${MUSICBRAINZ_PASSWORD:-}
|
||||||
|
|||||||
Reference in New Issue
Block a user