Compare commits

...

17 Commits

Author SHA1 Message Date
joshpatra 3c291d5fac v1.5.1-beta.1: version bump, refactor: fixed searched to properly run a FIFO search interleaving, Got SyncPlay and Sessions to transparently proxy a bit better, overhauled the WebUI to use the space a little better, cleaned up some broken features, stopped the major version rebuild from blocking the WebUI from opening, broken button fixes 2026-04-07 17:25:59 -04:00
joshpatra 2a430a1c38 v1.5.0-beta.1: version bump, refactor: fixed searched to properly run a FIFO search interleaving, Got SyncPlay and Sessions to transparently proxy a bit better, overhauled the WebUI to use the space a little better, cleaned up some broken features, stopped the major version rebuild from blocking the WebUI from opening, broken button fixes 2026-04-07 17:24:46 -04:00
joshpatra 1a0f7c0282 fix(jellyfin): remove duplicate playlist image tag resolver 2026-04-07 17:13:26 -04:00
joshpatra 6b89fe548f v1.5.0-beta.1: refactor: fixed searched to properly run a FIFO search interleaving, Got SyncPlay and Sessions to transparently proxy a bit better, overhauled the WebUI to use the space a little better, cleaned up some broken features, stopped the major version rebuild from blocking the WebUI from opening, broken button fixes 2026-04-07 16:51:12 -04:00
joshpatra 233af5dc8f v1.4.6-beta.1: Hopefully handles #14 and #15, fixes search up to truly interleave, and more transparently proxies /sessions and /socket
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-04-04 17:36:47 -04:00
joshpatra 4c1e6979b3 v1.4.4-beta.1: re-releasing tag
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-25 16:30:19 -04:00
joshpatra 0738e2d588 Merge branch 'main' into beta 2026-03-25 16:28:27 -04:00
joshpatra 0a5b383526 v1.4.3: fixed .env restarting from Admin UI, re-release of prev ver 2026-03-25 16:11:27 -04:00
joshpatra 5e8cb13d1a v1.4.3-beta.1: fixed .env restarting from Admin UI, re-release of prev ver 2026-03-25 16:05:59 -04:00
joshpatra efdeef927a Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-24 11:12:49 -04:00
joshpatra 5c184d38c8 v1.4.2: added an env migration service, fixed DOWNLOAD_PATH requiring Subsonic settings in the backend
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-24 11:11:46 -04:00
joshpatra 30f68729fc v1.4.2-beta.1: added an env migratino service, fixed DOWNLOAD_PATH requiring Subsonic settings in the backend 2026-03-24 11:10:29 -04:00
joshpatra 53f7b5e8b3 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-23 13:13:01 -04:00
joshpatra 4b423eecb2 Updated funding sources in funding.yml
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-03-23 13:12:22 -04:00
joshpatra da33ba9fbd Updated funding sources in funding.yml 2026-03-23 13:07:32 -04:00
joshpatra 6c95cfd2d6 Merge branch 'main' into beta 2026-03-23 11:20:34 -04:00
joshpatra d4230a2f79 v1.4.1: MAJOR FIX - Moved from Redis to Valkey, added migration service to support, Utilizing Hi-Fi API 2.7 with ISRC search, preserve local item json objects, add a quality fallback, added "transcoding" support that just reduces the fetched quality, while still downloading at the quality set in the .env, introduced real-time download visualizer on web-ui (not complete), move some stuff from json to redis, better retry logic, configurable timeouts per provider 2026-03-23 11:20:28 -04:00
67 changed files with 4171 additions and 1227 deletions
+4 -6
View File
@@ -100,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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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}}
+1
View File
@@ -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
+2 -2
View File
@@ -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,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,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,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!;
}
}
+163
View File
@@ -311,6 +311,169 @@ public class JellyfinProxyServiceTests
Assert.Contains("UserId=user-abc", 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);
}
}
}
+25
View File
@@ -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);
@@ -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()
{ {
+1 -1
View File
@@ -9,5 +9,5 @@ public static class AppVersion
/// <summary> /// <summary>
/// Current application version. /// Current application version.
/// </summary> /// </summary>
public const string Version = "1.4.1"; public const string Version = "1.5.0";
} }
+79 -48
View File
@@ -474,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());
@@ -549,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()
@@ -665,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"
}); });
} }
@@ -718,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"
}); });
} }
} }
@@ -727,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"
}); });
} }
} }
@@ -859,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)
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using allstarr.Models.Download; using allstarr.Models.Download;
using allstarr.Services; using allstarr.Services;
using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers; namespace allstarr.Controllers;
@@ -10,13 +11,16 @@ namespace allstarr.Controllers;
public class DownloadActivityController : ControllerBase public class DownloadActivityController : ControllerBase
{ {
private readonly IEnumerable<IDownloadService> _downloadServices; private readonly IEnumerable<IDownloadService> _downloadServices;
private readonly JellyfinSessionManager _sessionManager;
private readonly ILogger<DownloadActivityController> _logger; private readonly ILogger<DownloadActivityController> _logger;
public DownloadActivityController( public DownloadActivityController(
IEnumerable<IDownloadService> downloadServices, IEnumerable<IDownloadService> downloadServices,
JellyfinSessionManager sessionManager,
ILogger<DownloadActivityController> logger) ILogger<DownloadActivityController> logger)
{ {
_downloadServices = downloadServices; _downloadServices = downloadServices;
_sessionManager = sessionManager;
_logger = logger; _logger = logger;
} }
@@ -26,7 +30,7 @@ public class DownloadActivityController : ControllerBase
[HttpGet("queue")] [HttpGet("queue")]
public IActionResult GetDownloadQueue() public IActionResult GetDownloadQueue()
{ {
var allDownloads = GetAllActiveDownloads(); var allDownloads = GetAllActivityEntries();
return Ok(allDownloads); return Ok(allDownloads);
} }
@@ -58,7 +62,7 @@ public class DownloadActivityController : ControllerBase
{ {
while (!token.IsCancellationRequested) while (!token.IsCancellationRequested)
{ {
var allDownloads = GetAllActiveDownloads(); var allDownloads = GetAllActivityEntries();
var payload = JsonSerializer.Serialize(allDownloads, jsonOptions); var payload = JsonSerializer.Serialize(allDownloads, jsonOptions);
var message = $"data: {payload}\n\n"; var message = $"data: {payload}\n\n";
@@ -83,7 +87,7 @@ public class DownloadActivityController : ControllerBase
} }
} }
private List<DownloadInfo> GetAllActiveDownloads() private List<DownloadActivityEntry> GetAllActivityEntries()
{ {
var allDownloads = new List<DownloadInfo>(); var allDownloads = new List<DownloadInfo>();
foreach (var service in _downloadServices) foreach (var service in _downloadServices)
@@ -91,10 +95,87 @@ public class DownloadActivityController : ControllerBase
allDownloads.AddRange(service.GetActiveDownloads()); allDownloads.AddRange(service.GetActiveDownloads());
} }
// Sort: InProgress first, then by StartedAt descending var orderedDownloads = allDownloads
return allDownloads
.OrderByDescending(d => d.Status == DownloadStatus.InProgress) .OrderByDescending(d => d.Status == DownloadStatus.InProgress)
.ThenByDescending(d => d.StartedAt) .ThenByDescending(d => d.StartedAt)
.ToList(); .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
+111
View File
@@ -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>
@@ -407,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
@@ -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)
}; };
+210 -401
View File
@@ -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)
+79 -21
View File
@@ -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,7 @@ 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 // Check Redis cache for previously fetched external image
@@ -689,7 +696,7 @@ public partial class JellyfinController : ControllerBase
if (cachedImageBytes != null) if (cachedImageBytes != null)
{ {
_logger.LogDebug("Cache hit for external {Type} image: {Provider}/{ExternalId}", type, provider, externalId); _logger.LogDebug("Cache hit for external {Type} image: {Provider}/{ExternalId}", type, provider, externalId);
return File(cachedImageBytes, "image/jpeg"); return CreateConditionalImageResponse(cachedImageBytes, "image/jpeg");
} }
// Get external cover art URL // Get external cover art URL
@@ -760,7 +767,7 @@ public partial class JellyfinController : ControllerBase
_logger.LogDebug("Successfully fetched and cached external image from host {Host}, size: {Size} bytes", _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)
{ {
@@ -782,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
@@ -790,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
@@ -1292,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.)
@@ -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)
{ {
+2
View File
@@ -12,6 +12,8 @@ public class DownloadInfo
public string Artist { 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; }
+25 -1
View File
@@ -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();
@@ -945,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()
+81 -10
View File
@@ -39,6 +39,30 @@ public abstract class BaseDownloadService : IDownloadService
private DateTime _lastRequestTime = DateTime.MinValue; private DateTime _lastRequestTime = DateTime.MinValue;
protected 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>
@@ -105,7 +129,12 @@ 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);
} }
@@ -129,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);
} }
@@ -152,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);
@@ -295,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)
{ {
@@ -371,15 +423,20 @@ 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;
bool isInitiator = false; bool isInitiator = false;
@@ -405,6 +462,11 @@ 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)
{ {
if (requestedForStreaming)
{
activeDownload.RequestedForStreaming = true;
}
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId); Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
// We are not the initiator; we will wait outside the lock. // We are not the initiator; we will wait outside the lock.
} }
@@ -420,6 +482,8 @@ public abstract class BaseDownloadService : IDownloadService
Title = "Unknown Title", // Will be updated after fetching Title = "Unknown Title", // Will be updated after fetching
Artist = "Unknown Artist", Artist = "Unknown Artist",
Status = DownloadStatus.InProgress, Status = DownloadStatus.InProgress,
Progress = 0,
RequestedForStreaming = requestedForStreaming,
StartedAt = DateTime.UtcNow StartedAt = DateTime.UtcNow
}; };
} }
@@ -464,7 +528,7 @@ public abstract class BaseDownloadService : IDownloadService
// 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);
@@ -500,6 +564,7 @@ public abstract class BaseDownloadService : IDownloadService
{ {
info.Title = song.Title ?? "Unknown Title"; info.Title = song.Title ?? "Unknown Title";
info.Artist = song.Artist ?? "Unknown Artist"; info.Artist = song.Artist ?? "Unknown Artist";
info.DurationSeconds = song.Duration;
} }
var localPath = await DownloadTrackAsync(externalId, song, cancellationToken); var localPath = await DownloadTrackAsync(externalId, song, cancellationToken);
@@ -507,6 +572,7 @@ public abstract class BaseDownloadService : IDownloadService
if (ActiveDownloads.TryGetValue(songId, out var successInfo)) if (ActiveDownloads.TryGetValue(songId, out var successInfo))
{ {
successInfo.Status = DownloadStatus.Completed; successInfo.Status = DownloadStatus.Completed;
successInfo.Progress = 1.0;
successInfo.LocalPath = localPath; successInfo.LocalPath = localPath;
successInfo.CompletedAt = DateTime.UtcNow; successInfo.CompletedAt = DateTime.UtcNow;
} }
@@ -559,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))
@@ -642,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)
@@ -659,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)
{ {
+102 -5
View File
@@ -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,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,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;
}
}
@@ -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)
@@ -99,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
@@ -135,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);
+187 -152
View File
@@ -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;
@@ -153,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);
@@ -245,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)
@@ -267,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
@@ -335,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);
@@ -411,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();
}
}
} }
@@ -102,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);
@@ -160,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);
+102 -20
View File
@@ -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("========================================");
@@ -38,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();
@@ -295,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);
} }
@@ -337,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)
{ {
@@ -345,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>
@@ -757,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
@@ -773,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);
} }
} }
@@ -786,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);
} }
} }
@@ -797,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)
{ {
@@ -807,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)
{ {
@@ -826,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);
@@ -998,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)
@@ -1145,21 +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();
return await metadataService.FindSongByIsrcAsync(isrc);
} // STEP 2: Search EXTERNAL by ISRC
catch return await metadataService.FindSongByIsrcAsync(isrc, cancellationToken);
{
return null;
}
} }
/// <summary> /// <summary>
@@ -101,6 +101,7 @@ public class SquidWTFDownloadService : BaseDownloadService
{ {
return await _fallbackHelper.TryWithFallbackAsync(async baseUrl => return await _fallbackHelper.TryWithFallbackAsync(async baseUrl =>
{ {
var songId = BuildTrackedSongId(trackId);
var downloadInfo = await FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken); var downloadInfo = await FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken);
Logger.LogInformation( Logger.LogInformation(
@@ -136,8 +137,29 @@ public class SquidWTFDownloadService : BaseDownloadService
await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken); await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath); await using var outputFile = IOFile.Create(outputPath);
await responseStream.CopyToAsync(outputFile, cancellationToken); var totalBytes = res.Content.Headers.ContentLength;
var buffer = new byte[81920];
long totalBytesRead = 0;
while (true)
{
var bytesRead = await responseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (bytesRead <= 0)
{
break;
}
await outputFile.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
totalBytesRead += bytesRead;
if (totalBytes.HasValue && totalBytes.Value > 0)
{
SetDownloadProgress(songId, (double)totalBytesRead / totalBytes.Value);
}
}
await outputFile.DisposeAsync(); await outputFile.DisposeAsync();
SetDownloadProgress(songId, 1.0);
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
@@ -166,8 +188,8 @@ public class SquidWTFDownloadService : BaseDownloadService
{ {
Exception? lastException = null; Exception? lastException = null;
var qualityOrder = BuildQualityFallbackOrder(_squidwtfSettings.Quality); var qualityOrder = BuildQualityFallbackOrder(_squidwtfSettings.Quality);
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache var basePath = CurrentStorageMode == StorageMode.Cache
? Path.Combine("downloads", "cache") : Path.Combine("downloads", "permanent"); ? Path.Combine(DownloadPath, "cache") : Path.Combine(DownloadPath, "permanent");
foreach (var quality in qualityOrder) foreach (var quality in qualityOrder)
{ {
@@ -498,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,
+93 -63
View File
@@ -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,46 +32,67 @@
<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">
<div class="card" id="download-activity-card">
<h2>Live Download Queue</h2>
<div id="download-activity-list" class="download-queue-list">
<div class="empty-state">No active downloads</div>
</div>
</div>
<div class="grid"> <div class="grid">
<div class="card"> <div class="card">
<h2>Spotify API</h2> <h2>Spotify API</h2>
@@ -120,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>
@@ -137,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
@@ -145,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>
@@ -224,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>
@@ -260,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>
@@ -301,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>
@@ -340,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>
@@ -858,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>
@@ -954,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 -->
+84
View File
@@ -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 };
}
+16 -3
View File
@@ -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",
); );
} }
+82 -8
View File
@@ -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";
@@ -15,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 {
@@ -129,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,
}); });
}); });
@@ -151,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;
@@ -163,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}"
@@ -213,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>
@@ -245,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 =
@@ -346,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")) {
@@ -380,7 +403,6 @@ async function loadDashboardData() {
} }
startDashboardRefresh(); startDashboardRefresh();
startDownloadActivityStream();
} }
function startDownloadActivityStream() { function startDownloadActivityStream() {
@@ -424,6 +446,9 @@ function renderDownloadActivity(downloads) {
}; };
const html = downloads.map(d => { const html = downloads.map(d => {
const downloadProgress = clampProgress(d.progress);
const playbackProgress = clampProgress(d.playbackProgress);
// Determine elapsed/duration text // Determine elapsed/duration text
let timeText = ""; let timeText = "";
if (d.startedAt) { if (d.startedAt) {
@@ -433,9 +458,37 @@ function renderDownloadActivity(downloads) {
timeText = diffSecs < 60 ? `${diffSecs}s` : `${Math.floor(diffSecs/60)}m ${diffSecs%60}s`; 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 title = d.title || 'Unknown Title';
const artist = d.artist || 'Unknown Artist'; 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 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 ` return `
<div class="download-queue-item"> <div class="download-queue-item">
@@ -444,7 +497,10 @@ function renderDownloadActivity(downloads) {
<div class="download-queue-meta"> <div class="download-queue-meta">
<span class="download-queue-artist">${escapeHtml(artist)}</span> <span class="download-queue-artist">${escapeHtml(artist)}</span>
<span class="download-queue-provider">${escapeHtml(d.externalProvider)}</span> <span class="download-queue-provider">${escapeHtml(d.externalProvider)}</span>
${streamBadge}
${playingBadge}
</div> </div>
${progressBar}
${errorText} ${errorText}
</div> </div>
<div class="download-queue-status"> <div class="download-queue-status">
@@ -458,6 +514,24 @@ function renderDownloadActivity(downloads) {
container.innerHTML = html; 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;
+32 -11
View File
@@ -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
View File
@@ -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();
}); });
+89 -6
View File
@@ -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);
}); });
});
} }
+19 -4
View File
@@ -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;
+6 -1
View File
@@ -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);
+86 -30
View File
@@ -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>
@@ -634,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>
@@ -661,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>
@@ -683,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>
+4
View File
@@ -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);
}
}
+31
View File
@@ -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>
+22
View File
@@ -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
+339
View File
@@ -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;
@@ -1003,6 +1294,8 @@ input::placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
min-width: 0;
flex: 1;
} }
.download-queue-title { .download-queue-title {
@@ -1030,6 +1323,52 @@ input::placeholder {
text-transform: uppercase; 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 { .download-queue-status {
display: flex; display: flex;
align-items: center; align-items: center;
-72
View File
@@ -1,72 +0,0 @@
# Admin UI Modularity Guide
This document defines the modular JavaScript architecture for `allstarr/wwwroot/js` and the guardrails future agents should follow.
## Goals
- Keep admin UI code split by feature and responsibility.
- Centralize request handling and async UI action handling.
- Minimize `window.*` globals to only those required by inline HTML handlers.
- Keep polling and refresh lifecycle in one place.
## Current Module Map
- `main.js`: Composition root only. Wires modules, shared globals, and bootstrap lifecycle.
- `auth-session.js`: Auth/session state, role-based scope, login/logout wiring, 401 recovery handling.
- `dashboard-data.js`: Polling lifecycle + data loading/render orchestration.
- `operations.js`: Shared `runAction` helper + non-domain operational actions.
- `settings-editor.js`: Settings registry, modal editor rendering, local config state sync.
- `playlist-admin.js`: Playlist linking and admin CRUD.
- `scrobbling-admin.js`: Scrobbling configuration actions and UI state updates.
- `api.js`: API transport layer wrappers and endpoint functions.
## Required Patterns
### 1) Request Layer Rules
- All HTTP requests must go through `api.js`.
- `api.js` owns low-level `fetch` usage (`requestJson`, `requestBlob`, `requestOptionalJson`).
- Feature modules should call `API.*` methods and avoid direct `fetch`.
### 2) Action Flow Rules
- UI actions with toast/error handling should use `runAction(...)` from `operations.js`.
- If an action always reloads scrobbling UI state, use `runScrobblingAction(...)` in `scrobbling-admin.js`.
### 3) Polling Rules
- Polling timers must stay in `dashboard-data.js`.
- New background refresh loops should be added to existing refresh lifecycle, not separate timers in other modules.
### 4) Global Surface Rules
- Expose only `window.*` members needed by current inline HTML (`onclick`, `onchange`, `oninput`) or legacy UI templates.
- Keep new feature logic module-scoped and expose narrow entry points in `init*` functions.
## Adding New Admin UI Behavior
1. Add/extend endpoint method in `api.js`.
2. Implement feature logic in the relevant module (`*-admin.js`, `dashboard-data.js`, etc.).
3. Prefer `runAction(...)` for async UI operations.
4. Export/init through module `init*` only.
5. Wire it from `main.js` if cross-module dependencies are needed.
6. Add/adjust tests in `allstarr.Tests/JavaScriptSyntaxTests.cs`.
## Tests That Enforce This Architecture
`allstarr.Tests/JavaScriptSyntaxTests.cs` includes checks for:
- Module existence and syntax.
- Coordinator bootstrap expectations.
- API request centralization (`fetch` calls constrained to helper functions in `api.js`).
- Scrobbling module prohibition on direct `fetch`.
## Fast Validation Commands
```bash
# Full suite
dotnet test allstarr.sln
# JS architecture/syntax focused
dotnet test allstarr.Tests/allstarr.Tests.csproj --filter JavaScriptSyntaxTests
```
+249
View File
@@ -0,0 +1,249 @@
services:
valkey:
image: valkey/valkey:8
container_name: allstarr-valkey
restart: unless-stopped
# Valkey is only accessible internally - no external port exposure
expose:
- "6379"
# Use a self-healing entrypoint to automatically handle Redis -> Valkey migration pitfalls (like RDB format 12 errors)
# Only delete Valkey/Redis persistence artifacts so misconfigured REDIS_DATA_PATH values do not wipe app cache files.
entrypoint:
- "sh"
- "-ec"
- |
log_file=/tmp/valkey-startup.log
log_pipe=/tmp/valkey-startup.pipe
server_pid=
tee_pid=
forward_signal() {
if [ -n "$$server_pid" ]; then
kill -TERM "$$server_pid" 2>/dev/null || true
wait "$$server_pid" 2>/dev/null || true
fi
if [ -n "$$tee_pid" ]; then
kill "$$tee_pid" 2>/dev/null || true
wait "$$tee_pid" 2>/dev/null || true
fi
rm -f "$$log_pipe"
exit 143
}
trap forward_signal TERM INT
start_valkey() {
rm -f "$$log_file" "$$log_pipe"
: > "$$log_file"
mkfifo "$$log_pipe"
tee -a "$$log_file" < "$$log_pipe" &
tee_pid=$$!
valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes > "$$log_pipe" 2>&1 &
server_pid=$$!
wait "$$server_pid"
status=$$?
wait "$$tee_pid" 2>/dev/null || true
rm -f "$$log_pipe"
server_pid=
tee_pid=
return "$$status"
}
is_incompatible_persistence_error() {
grep -Eq "Can't handle RDB format version|Error reading the RDB base file|AOF loading aborted" "$$log_file"
}
cleanup_incompatible_persistence() {
echo 'Valkey failed to start (likely incompatible Redis persistence files). Removing persisted RDB/AOF artifacts and retrying...'
rm -f /data/*.rdb /data/*.aof /data/*.manifest
rm -rf /data/appendonlydir /data/appendonlydir-*
}
if ! start_valkey; then
if is_incompatible_persistence_error; then
cleanup_incompatible_persistence
exec valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes
fi
exit 1
fi
healthcheck:
# Use CMD-SHELL for broader compatibility in some environments
test: ["CMD-SHELL", "valkey-cli ping || exit 1"]
interval: 10s
timeout: 3s
retries: 5
start_period: 20s
volumes:
- ${REDIS_DATA_PATH:-./redis-data}:/data
networks:
- allstarr-network
# Spotify Lyrics API sidecar service
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
spotify-lyrics:
image: akashrchandran/spotify-lyrics-api:latest
platform: linux/amd64
container_name: allstarr-spotify-lyrics
restart: unless-stopped
ports:
- "8365:8080"
environment:
- SP_DC=${SPOTIFY_API_SESSION_COOKIE:-}
networks:
- allstarr-network
allstarr:
# Use pre-built image from GitHub Container Registry
# For latest stable: ghcr.io/sopat712/allstarr:latest
# For beta/testing: ghcr.io/sopat712/allstarr:beta
# To build locally instead, uncomment the build section below
image: ghcr.io/sopat712/allstarr:latest
# Uncomment to build locally instead of using GHCR image:
# build:
# context: .
# dockerfile: Dockerfile
# image: allstarr:local
container_name: allstarr
restart: unless-stopped
ports:
- "5274:8080"
# Admin UI on port 5275 - for local/Tailscale access only
# DO NOT expose through reverse proxy - contains sensitive config
- "5275:5275"
depends_on:
valkey:
condition: service_healthy
spotify-lyrics:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- allstarr-network
environment:
- ASPNETCORE_ENVIRONMENT=Production
# Backend type: Subsonic or Jellyfin (default: Subsonic)
- Backend__Type=${BACKEND_TYPE:-Subsonic}
# Admin network controls (port 5275)
- Admin__BindAnyIp=${ADMIN_BIND_ANY_IP:-false}
- Admin__TrustedSubnets=${ADMIN_TRUSTED_SUBNETS:-}
# ===== REDIS / VALKEY CACHE =====
- Redis__ConnectionString=valkey:6379
- Redis__Enabled=${REDIS_ENABLED:-true}
# ===== CACHE TTL SETTINGS =====
- Cache__SearchResultsMinutes=${CACHE_SEARCH_RESULTS_MINUTES:-1}
- Cache__PlaylistImagesHours=${CACHE_PLAYLIST_IMAGES_HOURS:-168}
- Cache__SpotifyPlaylistItemsHours=${CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS:-168}
- Cache__SpotifyMatchedTracksDays=${CACHE_SPOTIFY_MATCHED_TRACKS_DAYS:-30}
- Cache__LyricsDays=${CACHE_LYRICS_DAYS:-14}
- Cache__GenreDays=${CACHE_GENRE_DAYS:-30}
- Cache__MetadataDays=${CACHE_METADATA_DAYS:-7}
- Cache__OdesliLookupDays=${CACHE_ODESLI_LOOKUP_DAYS:-60}
- Cache__ProxyImagesDays=${CACHE_PROXY_IMAGES_DAYS:-14}
- Cache__TranscodeCacheMinutes=${CACHE_TRANSCODE_MINUTES:-60}
# ===== SUBSONIC BACKEND =====
- Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533}
- Subsonic__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly}
- Subsonic__DownloadMode=${DOWNLOAD_MODE:-Track}
- Subsonic__MusicService=${MUSIC_SERVICE:-SquidWTF}
- Subsonic__StorageMode=${STORAGE_MODE:-Permanent}
- Subsonic__CacheDurationHours=${CACHE_DURATION_HOURS:-1}
- Subsonic__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true}
- Subsonic__PlaylistsDirectory=${PLAYLISTS_DIRECTORY:-playlists}
# ===== JELLYFIN BACKEND =====
- Jellyfin__Url=${JELLYFIN_URL:-http://localhost:8096}
- Jellyfin__ApiKey=${JELLYFIN_API_KEY:-}
- Jellyfin__UserId=${JELLYFIN_USER_ID:-}
- Jellyfin__LibraryId=${JELLYFIN_LIBRARY_ID:-}
- Jellyfin__ClientUsername=${JELLYFIN_CLIENT_USERNAME:-}
- Jellyfin__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly}
- Jellyfin__DownloadMode=${DOWNLOAD_MODE:-Track}
- Jellyfin__MusicService=${MUSIC_SERVICE:-SquidWTF}
- Jellyfin__StorageMode=${STORAGE_MODE:-Permanent}
- Jellyfin__CacheDurationHours=${CACHE_DURATION_HOURS:-1}
- Jellyfin__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true}
- Jellyfin__PlaylistsDirectory=${PLAYLISTS_DIRECTORY:-playlists}
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
- SpotifyImport__Enabled=${SPOTIFY_IMPORT_ENABLED:-false}
- SpotifyImport__SyncStartHour=${SPOTIFY_IMPORT_SYNC_START_HOUR:-16}
- SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15}
- SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2}
- SpotifyImport__MatchingIntervalHours=${SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS:-24}
- SpotifyImport__Playlists=${SPOTIFY_IMPORT_PLAYLISTS:-}
- SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-}
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
- SpotifyImport__PlaylistLocalTracksPositions=${SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS:-}
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
- SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100}
- SpotifyApi__PreferIsrcMatching=${SPOTIFY_API_PREFER_ISRC_MATCHING:-true}
# Spotify Lyrics API sidecar service URL (internal)
- SpotifyApi__LyricsApiUrl=${SPOTIFY_LYRICS_API_URL:-http://spotify-lyrics:8080}
# ===== SCROBBLING (LAST.FM, LISTENBRAINZ) =====
- Scrobbling__Enabled=${SCROBBLING_ENABLED:-false}
- Scrobbling__LocalTracksEnabled=${SCROBBLING_LOCAL_TRACKS_ENABLED:-false}
- Scrobbling__SyntheticLocalPlayedSignalEnabled=${SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED:-false}
- Scrobbling__LastFm__Enabled=${SCROBBLING_LASTFM_ENABLED:-false}
- Scrobbling__LastFm__ApiKey=${SCROBBLING_LASTFM_API_KEY:-}
- Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-}
- Scrobbling__LastFm__SessionKey=${SCROBBLING_LASTFM_SESSION_KEY:-}
- Scrobbling__LastFm__Username=${SCROBBLING_LASTFM_USERNAME:-}
- Scrobbling__LastFm__Password=${SCROBBLING_LASTFM_PASSWORD:-}
- Scrobbling__ListenBrainz__Enabled=${SCROBBLING_LISTENBRAINZ_ENABLED:-false}
- Scrobbling__ListenBrainz__UserToken=${SCROBBLING_LISTENBRAINZ_USER_TOKEN:-}
# ===== DEBUG SETTINGS =====
- Debug__LogAllRequests=${DEBUG_LOG_ALL_REQUESTS:-false}
- Debug__RedactSensitiveRequestValues=${DEBUG_REDACT_SENSITIVE_REQUEST_VALUES:-false}
# ===== SHARED =====
- Library__DownloadPath=/app/downloads
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}
- SquidWTF__MinRequestIntervalMs=${SQUIDWTF_MIN_REQUEST_INTERVAL_MS:-200}
- Deezer__Arl=${DEEZER_ARL:-}
- Deezer__ArlFallback=${DEEZER_ARL_FALLBACK:-}
- Deezer__Quality=${DEEZER_QUALITY:-FLAC}
- Deezer__MinRequestIntervalMs=${DEEZER_MIN_REQUEST_INTERVAL_MS:-200}
- Qobuz__UserAuthToken=${QOBUZ_USER_AUTH_TOKEN:-}
- Qobuz__UserId=${QOBUZ_USER_ID:-}
- Qobuz__Quality=${QOBUZ_QUALITY:-FLAC}
- Qobuz__MinRequestIntervalMs=${QOBUZ_MIN_REQUEST_INTERVAL_MS:-200}
- MusicBrainz__Enabled=${MUSICBRAINZ_ENABLED:-true}
- MusicBrainz__Username=${MUSICBRAINZ_USERNAME:-}
- MusicBrainz__Password=${MUSICBRAINZ_PASSWORD:-}
volumes:
- ${DOWNLOAD_PATH:-./downloads}:/app/downloads
- ${KEPT_PATH:-./kept}:/app/kept
- ${CACHE_PATH:-./cache}:/app/cache
# Mount .env file for runtime configuration updates from admin UI
- ./.env:/app/.env
# Docker socket for self-restart capability (admin UI only)
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
allstarr-network:
name: allstarr-network
driver: bridge
+1 -68
View File
@@ -6,74 +6,7 @@ services:
# Valkey is only accessible internally - no external port exposure # Valkey is only accessible internally - no external port exposure
expose: expose:
- "6379" - "6379"
# Use a self-healing entrypoint to automatically handle Redis -> Valkey migration pitfalls (like RDB format 12 errors) command: valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes
# 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: healthcheck:
# Use CMD-SHELL for broader compatibility in some environments # Use CMD-SHELL for broader compatibility in some environments
test: ["CMD-SHELL", "valkey-cli ping || exit 1"] test: ["CMD-SHELL", "valkey-cli ping || exit 1"]