From fac1ffeda5bac7e04b17762d583411c273c6efbc Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sun, 24 May 2026 23:35:06 -0400 Subject: [PATCH] v2.0.2: fix SquidWTF nullable metadata warning --- .env.example | 6 +- .github/ISSUE_TEMPLATE/bug-report.md | 48 +- .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature-request.md | 33 +- CONFIGURATION.md | 28 +- allstarr.Tests/DeezerMetadataServiceTests.cs | 355 +++++++++- .../DownloadsControllerLyricsArchiveTests.cs | 180 +++++ .../DownloadsControllerPathSecurityTests.cs | 16 +- .../InjectedPlaylistItemHelperTests.cs | 35 + .../JellyfinResponseBuilderTests.cs | 97 ++- .../ScrobblingAdminControllerTests.cs | 22 +- allstarr/AppVersion.cs | 2 +- allstarr/Controllers/AdminAuthController.cs | 6 +- allstarr/Controllers/DownloadsController.cs | 103 ++- .../Controllers/JellyfinController.Audio.cs | 81 ++- .../Controllers/JellyfinController.Lyrics.cs | 2 + .../Controllers/JellyfinController.Spotify.cs | 63 +- allstarr/Controllers/JellyfinController.cs | 10 +- allstarr/Controllers/MappingController.cs | 180 +++-- allstarr/Controllers/PlaylistController.cs | 35 +- .../Controllers/ScrobblingAdminController.cs | 24 +- .../Controllers/SpotifyAdminController.cs | 22 +- allstarr/Controllers/SubSonicController.cs | 29 +- allstarr/Models/Domain/Song.cs | 2 +- .../Models/Settings/ScrobblingSettings.cs | 34 +- .../Models/Spotify/SpotifyTrackMapping.cs | 56 ++ allstarr/Program.cs | 42 +- .../Services/Admin/AdminAuthSessionService.cs | 201 +++++- .../Services/Common/ExplicitContentFilter.cs | 2 +- .../Common/InjectedPlaylistItemHelper.cs | 25 + .../Common/RoundRobinFallbackHelper.cs | 170 +++-- .../Services/Common/StreamQualityHelper.cs | 3 +- .../Services/Deezer/DeezerDownloadService.cs | 4 +- .../Services/Deezer/DeezerMetadataService.cs | 499 ++++++++++++-- .../Jellyfin/JellyfinResponseBuilder.cs | 68 +- .../Lyrics/IKeptLyricsSidecarService.cs | 15 + .../Lyrics/KeptLyricsSidecarService.cs | 321 +++++++++ .../Scrobbling/LastFmScrobblingService.cs | 62 +- .../Services/Spotify/SpotifyMappingService.cs | 209 +++++- .../Spotify/SpotifyTrackMatchingService.cs | 28 +- .../SquidWTF/SquidWTFDownloadService.cs | 162 ++--- .../SquidWTF/SquidWTFMetadataService.cs | 98 ++- .../SquidWTF/SquidWTFStartupValidator.cs | 93 ++- .../SquidWTF/SquidWtfEndpointCatalog.cs | 10 - .../SquidWTF/SquidWtfEndpointDiscovery.cs | 18 +- allstarr/wwwroot/index.html | 172 ++++- allstarr/wwwroot/js/api.js | 16 +- allstarr/wwwroot/js/auth-session.js | 5 +- allstarr/wwwroot/js/dashboard-data.js | 125 ++-- allstarr/wwwroot/js/issue-reporter.js | 501 ++++++++++++++ allstarr/wwwroot/js/main.js | 33 +- allstarr/wwwroot/js/mapping-targets.js | 87 +++ allstarr/wwwroot/js/operations.js | 11 +- allstarr/wwwroot/js/scrobbling-admin.js | 31 +- allstarr/wwwroot/js/song-migration.js | 622 ++++++++++++++++++ allstarr/wwwroot/js/ui.js | 490 +++++++++++--- allstarr/wwwroot/spotify-mappings.html | 72 +- allstarr/wwwroot/spotify-mappings.js | 215 +++++- allstarr/wwwroot/styles.css | 226 ++++++- docker-compose-redis2valkey.yml | 1 + docker-compose.yml | 1 + 61 files changed, 5388 insertions(+), 724 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 allstarr.Tests/DownloadsControllerLyricsArchiveTests.cs create mode 100644 allstarr/Services/Lyrics/IKeptLyricsSidecarService.cs create mode 100644 allstarr/Services/Lyrics/KeptLyricsSidecarService.cs create mode 100644 allstarr/wwwroot/js/issue-reporter.js create mode 100644 allstarr/wwwroot/js/mapping-targets.js create mode 100644 allstarr/wwwroot/js/song-migration.js diff --git a/.env.example b/.env.example index 8b27751..ec7ab20 100644 --- a/.env.example +++ b/.env.example @@ -285,9 +285,9 @@ SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED=false # Enable Last.fm scrobbling (default: false) SCROBBLING_LASTFM_ENABLED=false -# Last.fm API credentials (OPTIONAL - uses hardcoded credentials by default) -# Only set these if you want to use your own API account -# Get from: https://www.last.fm/api/account/create +# Last.fm API credentials (REQUIRED when SCROBBLING_LASTFM_ENABLED=true) +# The old shared Jellyfin plugin key is suspended by Last.fm — you must use your own app. +# Create at: https://www.last.fm/api/account/create SCROBBLING_LASTFM_API_KEY= SCROBBLING_LASTFM_SHARED_SECRET= diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index 425072d..4af5d44 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -7,42 +7,52 @@ assignees: SoPat712 --- -**Describe the bug** +## Describe the bug + A clear and concise description of what the bug is. -**To Reproduce** +## To Reproduce + Steps to reproduce the behavior: + 1. Go to '...' -2. Click on '....' -3. Scroll down to '....' +2. Click on '...' +3. Scroll down to '...' 4. See error -**Expected behavior** +## Expected behavior + A clear and concise description of what you expected to happen. -**Screenshots** -If applicable, add screenshots to help explain your problem. +## Additional context -**Details (please complete the following information):** - - Version [e.g. v1.1.3] -- Client [e.g. Feishin] +Add any other context, screenshots, or surrounding details here. -
+## Safe diagnostics from Allstarr -Please paste your docker-compose.yaml in between the tickmarks +- Sensitive values stay redacted in this block. +- Allstarr Version: [e.g. v1.5.3] +- Backend Type: [e.g. Jellyfin] +- Music Service: [e.g. SquidWTF] +- Storage Mode: [e.g. Cache] +- Download Mode: [e.g. Track] +- Redis Enabled: [e.g. Yes] +- Spotify Import Enabled: [e.g. Yes] +- Scrobbling Enabled: [e.g. Disabled] +- Spotify Status: [e.g. Spotify Ready] +- Jellyfin URL: [Configured (redacted) or Not configured] +- Client: [e.g. Firefox 149 on macOS] +- Generated At (UTC): [e.g. 2026-04-19T02:18:52.483Z] +- Browser Time Zone: [e.g. America/New_York] + +## docker-compose.yaml (optional) ```yaml ``` -
-
-Please paste your .env file REDACTED OF ALL PASSWORDS in between the tickmarks: +## .env (redacted, optional) ```env ``` -
- -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c60f8c7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Allstarr Documentation + url: https://github.com/SoPat712/allstarr#readme + about: Check the setup and usage docs before filing a new issue. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index 8061c98..dc01af6 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -7,14 +7,35 @@ assignees: SoPat712 --- -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +## Problem to solve + +A clear and concise description of the problem this feature should solve. + +## Solution you'd like -**Describe the solution you'd like** A clear and concise description of what you want to happen. -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. +## Alternatives considered + +A clear and concise description of any alternative solutions or workarounds you've considered. + +## Additional context -**Additional context** Add any other context or screenshots about the feature request here. + +## Safe diagnostics from Allstarr (optional) + +- Sensitive values stay redacted in this block. +- Allstarr Version: [e.g. v1.5.3] +- Backend Type: [e.g. Jellyfin] +- Music Service: [e.g. SquidWTF] +- Storage Mode: [e.g. Cache] +- Download Mode: [e.g. Track] +- Redis Enabled: [e.g. Yes] +- Spotify Import Enabled: [e.g. Yes] +- Scrobbling Enabled: [e.g. Disabled] +- Spotify Status: [e.g. Spotify Ready] +- Jellyfin URL: [Configured (redacted) or Not configured] +- Client: [e.g. Firefox 149 on macOS] +- Generated At (UTC): [e.g. 2026-04-19T02:18:52.483Z] +- Browser Time Zone: [e.g. America/New_York] diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 867adfa..4fcd78c 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -225,6 +225,8 @@ Track your listening history to Last.fm and/or ListenBrainz. Allstarr automatica | `Scrobbling:Enabled` | Enable scrobbling globally (default: `false`) | | `Scrobbling:LocalTracksEnabled` | Enable scrobbling for local library tracks (default: `false`) - See note below | | `Scrobbling:LastFm:Enabled` | Enable Last.fm scrobbling (default: `false`) | +| `Scrobbling:LastFm:ApiKey` | Your Last.fm API key (required when enabled) — [create an app](https://www.last.fm/api/account/create) | +| `Scrobbling:LastFm:SharedSecret` | Your Last.fm shared secret (required when enabled) | | `Scrobbling:LastFm:Username` | Your Last.fm username | | `Scrobbling:LastFm:Password` | Your Last.fm password (only used for authentication) | | `Scrobbling:LastFm:SessionKey` | Last.fm session key (auto-generated via Web UI) | @@ -242,11 +244,13 @@ SCROBBLING_ENABLED=true # - ListenBrainz: https://github.com/lyarenei/jellyfin-plugin-listenbrainz SCROBBLING_LOCAL_TRACKS_ENABLED=false -# Last.fm configuration +# Last.fm configuration (API key + secret required — shared Jellyfin plugin key is suspended) SCROBBLING_LASTFM_ENABLED=true +SCROBBLING_LASTFM_API_KEY=your-api-key +SCROBBLING_LASTFM_SHARED_SECRET=your-shared-secret SCROBBLING_LASTFM_USERNAME=your-username SCROBBLING_LASTFM_PASSWORD=your-password -# Session key is auto-generated via Web UI +# Session key is auto-generated via Web UI after you set API credentials # ListenBrainz configuration SCROBBLING_LISTENBRAINZ_ENABLED=true @@ -258,12 +262,14 @@ SCROBBLING_LISTENBRAINZ_USER_TOKEN=your-token-here The easiest way to configure scrobbling is through the Web UI at `http://localhost:5275`: **Last.fm Setup:** -1. Navigate to the **Scrobbling** tab -2. Toggle "Last.fm Enabled" to enable -3. Click "Edit" next to Username and enter your Last.fm username -4. Click "Edit" next to Password and enter your Last.fm password -5. Click "Authenticate & Save" to generate a session key -6. Restart the container for changes to take effect +1. Create a Last.fm API account at https://www.last.fm/api/account/create and note the API key and shared secret +2. Set `SCROBBLING_LASTFM_API_KEY` and `SCROBBLING_LASTFM_SHARED_SECRET` in your `.env` file +3. Navigate to the **Scrobbling** tab +4. Toggle "Last.fm Enabled" to enable +5. Click "Edit" next to Username and enter your Last.fm username +6. Click "Edit" next to Password and enter your Last.fm password +7. Click "Authenticate & Save" to generate a session key (must be done again if you change API key) +8. Restart the container for changes to take effect **ListenBrainz Setup:** 1. Get your user token from [ListenBrainz Settings](https://listenbrainz.org/settings/) @@ -288,8 +294,14 @@ The easiest way to configure scrobbling is through the Web UI at `http://localho #### Troubleshooting +**Last.fm "API Key Suspended" or "not allowed to make requests":** +- Last.fm suspended the old shared Jellyfin plugin API key that Allstarr used by default +- Create your own application at https://www.last.fm/api/account/create +- Set `SCROBBLING_LASTFM_API_KEY` and `SCROBBLING_LASTFM_SHARED_SECRET` in `.env`, restart, then authenticate again in Admin → Scrobbling + **Last.fm authentication fails:** - Verify your username and password are correct +- Ensure API key and shared secret are set (not empty) - Check that there are no extra spaces in your credentials - Try re-authenticating via the Web UI diff --git a/allstarr.Tests/DeezerMetadataServiceTests.cs b/allstarr.Tests/DeezerMetadataServiceTests.cs index d8a22fd..49e3f5e 100644 --- a/allstarr.Tests/DeezerMetadataServiceTests.cs +++ b/allstarr.Tests/DeezerMetadataServiceTests.cs @@ -34,7 +34,8 @@ public class DeezerMetadataServiceTests private DeezerMetadataService CreateService(SubsonicSettings settings) { var options = Options.Create(settings); - return new DeezerMetadataService(_httpClientFactoryMock.Object, options); + var deezerOptions = Options.Create(new DeezerSettings { MinRequestIntervalMs = 0 }); + return new DeezerMetadataService(_httpClientFactoryMock.Object, options, deezerSettings: deezerOptions); } [Fact] @@ -49,6 +50,7 @@ public class DeezerMetadataServiceTests { id = 123456, title = "Test Song", + isrc = "TESTISRC1234", duration = 180, track_position = 1, artist = new { id = 789, name = "Test Artist" }, @@ -70,10 +72,49 @@ public class DeezerMetadataServiceTests Assert.Equal("Test Artist", result[0].Artist); Assert.Equal("Test Album", result[0].Album); Assert.Equal(180, result[0].Duration); + Assert.Equal("TESTISRC1234", result[0].Isrc); Assert.False(result[0].IsLocal); Assert.Equal("deezer", result[0].ExternalProvider); } + [Fact] + public async Task SearchSongsAsync_AmpersandVariant_PreservesProviderOrderAndDeduplicates() + { + var requests = new List(); + SetupHttpResponse(request => + { + var pathAndQuery = request.RequestUri!.PathAndQuery; + requests.Add(pathAndQuery); + + var response = pathAndQuery.Contains("q=love%20and%20hyperbole", StringComparison.Ordinal) + ? new + { + data = new object[] + { + CreateTrackSearchResult(2, "Shared Result"), + CreateTrackSearchResult(3, "Variant Result") + } + } + : new + { + data = new object[] + { + CreateTrackSearchResult(1, "Original Result"), + CreateTrackSearchResult(2, "Shared Result") + } + }; + + return CreateJsonResponse(JsonSerializer.Serialize(response)); + }); + + var result = await _service.SearchSongsAsync("love & hyperbole", 3); + + Assert.Equal(["Original Result", "Shared Result", "Variant Result"], result.Select(song => song.Title)); + Assert.Equal(2, requests.Count); + Assert.Contains("q=love%20%26%20hyperbole&limit=3&order=RANKING", requests[0]); + Assert.Contains("q=love%20and%20hyperbole&limit=3&order=RANKING", requests[1]); + } + [Fact] public async Task SearchAlbumsAsync_ReturnsListOfAlbums() { @@ -159,6 +200,70 @@ public class DeezerMetadataServiceTests Assert.NotNull(result.Artists); } + [Fact] + public async Task SearchAllAsync_AmpersandQuery_UsesVariantsForEachRequestedBucket() + { + var requests = new List(); + SetupHttpResponse(request => + { + lock (requests) + { + requests.Add(request.RequestUri!.PathAndQuery); + } + + return CreateJsonResponse(JsonSerializer.Serialize(new { data = Array.Empty() })); + }); + + await _service.SearchAllAsync("love & hyperbole", songLimit: 1, albumLimit: 1, artistLimit: 1); + + Assert.Contains(requests, request => + request.Contains("/search/track?q=love%20%26%20hyperbole&limit=1", StringComparison.Ordinal)); + Assert.Contains(requests, request => + request.Contains("/search/track?q=love%20and%20hyperbole&limit=1", StringComparison.Ordinal)); + Assert.Contains(requests, request => + request.Contains("/search/album?q=love%20%26%20hyperbole&limit=1", StringComparison.Ordinal)); + Assert.Contains(requests, request => + request.Contains("/search/album?q=love%20and%20hyperbole&limit=1", StringComparison.Ordinal)); + Assert.Contains(requests, request => + request.Contains("/search/artist?q=love%20%26%20hyperbole&limit=1", StringComparison.Ordinal)); + Assert.Contains(requests, request => + request.Contains("/search/artist?q=love%20and%20hyperbole&limit=1", StringComparison.Ordinal)); + } + + [Fact] + public async Task FindSongByIsrcAsync_UsesExactTrackEndpoint() + { + var requests = new List(); + SetupHttpResponse(request => + { + requests.Add(request.RequestUri!.PathAndQuery); + + return CreateJsonResponse(JsonSerializer.Serialize(new + { + id = 116348632, + title = "Hey Jude", + isrc = "GBUM71505902", + duration = 429, + track_position = 21, + disk_number = 1, + artist = new { id = 1, name = "The Beatles" }, + album = new + { + id = 12047956, + title = "1", + cover_medium = "https://example.com/cover.jpg" + } + })); + }); + + var result = await _service.FindSongByIsrcAsync(" GBUM71505902 "); + + Assert.NotNull(result); + Assert.Equal("ext-deezer-song-116348632", result.Id); + Assert.Equal("GBUM71505902", result.Isrc); + Assert.Equal(["/track/isrc:GBUM71505902"], requests); + } + [Fact] public async Task GetSongAsync_WithDeezerProvider_ReturnsSong() { @@ -285,6 +390,111 @@ public class DeezerMetadataServiceTests Assert.Null(result); } + [Fact] + public async Task GetAlbumAsync_PaginatesTracklistWhenAlbumDetailIsPartial() + { + var requests = new List(); + SetupHttpResponse(request => + { + var pathAndQuery = request.RequestUri!.PathAndQuery; + requests.Add(pathAndQuery); + + if (pathAndQuery.Contains("index=1", StringComparison.Ordinal)) + { + return CreateJsonResponse(JsonSerializer.Serialize(new + { + data = new object[] + { + CreateTrackSearchResult(222, "Track 2") + } + })); + } + + if (pathAndQuery.Contains("/tracks", StringComparison.Ordinal)) + { + return CreateJsonResponse(JsonSerializer.Serialize(new + { + data = new object[] + { + CreateTrackSearchResult(111, "Track 1") + }, + next = "https://api.deezer.com/album/456789/tracks?limit=100&index=1" + })); + } + + return CreateJsonResponse(JsonSerializer.Serialize(new + { + id = 456789, + title = "Paged Album", + nb_tracks = 2, + artist = new { id = 123, name = "Test Artist" }, + tracks = new + { + data = new object[] + { + CreateTrackSearchResult(111, "Track 1") + } + } + })); + }); + + var result = await _service.GetAlbumAsync("deezer", "456789"); + + Assert.NotNull(result); + Assert.Equal(["Track 1", "Track 2"], result.Songs.Select(song => song.Title)); + Assert.Contains("/album/456789/tracks?index=0&limit=100", requests); + Assert.Contains("/album/456789/tracks?limit=100&index=1", requests); + } + + [Fact] + public async Task GetArtistAlbumsAsync_FallsBackToDocumentedIndexPagination() + { + var requests = new List(); + SetupHttpResponse(request => + { + var pathAndQuery = request.RequestUri!.PathAndQuery; + requests.Add(pathAndQuery); + + if (pathAndQuery.Contains("index=1", StringComparison.Ordinal)) + { + return CreateJsonResponse(JsonSerializer.Serialize(new + { + data = new object[] + { + new + { + id = 2002, + title = "Second Album", + nb_tracks = 8, + artist = new { id = 27, name = "Artist" } + } + } + })); + } + + return CreateJsonResponse(JsonSerializer.Serialize(new + { + data = new object[] + { + new + { + id = 2001, + title = "First Album", + nb_tracks = 10, + artist = new { id = 27, name = "Artist" } + } + }, + total = 2 + })); + }); + + var result = await _service.GetArtistAlbumsAsync("deezer", "27"); + + Assert.Equal(["First Album", "Second Album"], result.Select(album => album.Title)); + Assert.Contains("/artist/27/albums?index=0&limit=100", requests); + Assert.Contains("/artist/27/albums?index=1&limit=100", requests); + } + private void SetupHttpResponse(string content, HttpStatusCode statusCode = HttpStatusCode.OK) { _httpMessageHandlerMock @@ -300,6 +510,51 @@ public class DeezerMetadataServiceTests }); } + private void SetupHttpResponse(Func responseFactory) + { + _httpMessageHandlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns((HttpRequestMessage request, CancellationToken _) => + Task.FromResult(responseFactory(request))); + } + + private static HttpResponseMessage CreateJsonResponse(string content) + { + return new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(content) + }; + } + + private static object CreateTrackSearchResult(long id, string title) + { + return new + { + id, + title, + duration = 180, + artist = new { id = 789, name = "Test Artist" }, + album = new { id = 456, title = "Test Album", cover_medium = "https://example.com/cover.jpg" } + }; + } + + private static object CreatePlaylistSearchResult(long id, string title) + { + return new + { + id, + title, + nb_tracks = 10, + picture_medium = "https://example.com/playlist.jpg", + user = new { name = "Playlist User" } + }; + } + #region Explicit Filter Tests [Fact] @@ -622,6 +877,44 @@ public class DeezerMetadataServiceTests Assert.Equal("ext-deezer-playlist-12345", result[0].Id); } + [Fact] + public async Task SearchPlaylistsAsync_AmpersandVariant_PreservesProviderOrderAndDeduplicates() + { + var requests = new List(); + SetupHttpResponse(request => + { + var pathAndQuery = request.RequestUri!.PathAndQuery; + requests.Add(pathAndQuery); + + var response = pathAndQuery.Contains("q=love%20and%20hyperbole", StringComparison.Ordinal) + ? new + { + data = new object[] + { + CreatePlaylistSearchResult(2, "Shared Playlist"), + CreatePlaylistSearchResult(3, "Variant Playlist") + } + } + : new + { + data = new object[] + { + CreatePlaylistSearchResult(1, "Original Playlist"), + CreatePlaylistSearchResult(2, "Shared Playlist") + } + }; + + return CreateJsonResponse(JsonSerializer.Serialize(response)); + }); + + var result = await _service.SearchPlaylistsAsync("love & hyperbole", 3); + + Assert.Equal(["Original Playlist", "Shared Playlist", "Variant Playlist"], result.Select(playlist => playlist.Name)); + Assert.Equal(2, requests.Count); + Assert.Contains("q=love%20%26%20hyperbole&limit=3&order=RANKING", requests[0]); + Assert.Contains("q=love%20and%20hyperbole&limit=3&order=RANKING", requests[1]); + } + [Fact] public async Task SearchPlaylistsAsync_WithLimit_RespectsLimit() { @@ -718,6 +1011,7 @@ public class DeezerMetadataServiceTests { id = 111, title = "Track 1", + isrc = "TESTISRC0001", duration = 200, track_position = 1, disk_number = 1, @@ -738,6 +1032,7 @@ public class DeezerMetadataServiceTests { id = 222, title = "Track 2", + isrc = "TESTISRC0002", duration = 180, track_position = 2, disk_number = 1, @@ -768,6 +1063,7 @@ public class DeezerMetadataServiceTests Assert.Equal("Track 1", result[0].Title); Assert.Equal("Artist A", result[0].Artist); Assert.Equal("ext-deezer-song-111", result[0].Id); + Assert.Equal("TESTISRC0001", result[0].Isrc); } [Fact] @@ -780,6 +1076,63 @@ public class DeezerMetadataServiceTests Assert.Empty(result); } + [Fact] + public async Task GetPlaylistTracksAsync_PaginatesTracklistWhenPlaylistDetailIsPartial() + { + var requests = new List(); + SetupHttpResponse(request => + { + var pathAndQuery = request.RequestUri!.PathAndQuery; + requests.Add(pathAndQuery); + + if (pathAndQuery.Contains("index=1", StringComparison.Ordinal)) + { + return CreateJsonResponse(JsonSerializer.Serialize(new + { + data = new object[] + { + CreateTrackSearchResult(222, "Track 2") + } + })); + } + + if (pathAndQuery.Contains("/tracks", StringComparison.Ordinal)) + { + return CreateJsonResponse(JsonSerializer.Serialize(new + { + data = new object[] + { + CreateTrackSearchResult(111, "Track 1") + }, + next = "https://api.deezer.com/playlist/12345/tracks?limit=100&index=1" + })); + } + + return CreateJsonResponse(JsonSerializer.Serialize(new + { + id = 12345, + title = "Paged Playlist", + nb_tracks = 2, + tracks = new + { + data = new object[] + { + CreateTrackSearchResult(111, "Track 1") + } + } + })); + }); + + var result = await _service.GetPlaylistTracksAsync("deezer", "12345"); + + Assert.Equal(["Track 1", "Track 2"], result.Select(song => song.Title)); + Assert.All(result, song => Assert.Equal("Paged Playlist", song.Album)); + Assert.Equal(1, result[0].Track); + Assert.Equal(2, result[1].Track); + Assert.Contains("/playlist/12345/tracks?index=0&limit=100", requests); + Assert.Contains("/playlist/12345/tracks?limit=100&index=1", requests); + } + [Fact] public async Task GetPlaylistTracksAsync_WithEmptyPlaylist_ReturnsEmptyList() { diff --git a/allstarr.Tests/DownloadsControllerLyricsArchiveTests.cs b/allstarr.Tests/DownloadsControllerLyricsArchiveTests.cs new file mode 100644 index 0000000..b8de3d6 --- /dev/null +++ b/allstarr.Tests/DownloadsControllerLyricsArchiveTests.cs @@ -0,0 +1,180 @@ +using System.IO.Compression; +using allstarr.Controllers; +using allstarr.Models.Domain; +using allstarr.Services.Lyrics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; + +namespace allstarr.Tests; + +public class DownloadsControllerLyricsArchiveTests +{ + [Fact] + public async Task DownloadFile_WithLyricsSidecar_ReturnsZipContainingAudioAndLrc() + { + var testRoot = CreateTestRoot(); + var downloadsRoot = Path.Combine(testRoot, "downloads"); + var artistDir = Path.Combine(downloadsRoot, "kept", "Artist"); + var audioPath = Path.Combine(artistDir, "track.mp3"); + + Directory.CreateDirectory(artistDir); + await File.WriteAllTextAsync(audioPath, "audio-data"); + + try + { + var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: true)); + + var result = await controller.DownloadFile("Artist/track.mp3"); + + var fileResult = Assert.IsType(result); + Assert.Equal("application/zip", fileResult.ContentType); + Assert.Equal("track.zip", fileResult.FileDownloadName); + + var entries = ReadArchiveEntries(fileResult.FileStream); + Assert.Contains("track.mp3", entries); + Assert.Contains("track.lrc", entries); + } + finally + { + DeleteTestRoot(testRoot); + } + } + + [Fact] + public async Task DownloadAllFiles_BackfillsLyricsSidecarsIntoArchive() + { + var testRoot = CreateTestRoot(); + var downloadsRoot = Path.Combine(testRoot, "downloads"); + var artistDir = Path.Combine(downloadsRoot, "kept", "Artist", "Album"); + var audioPath = Path.Combine(artistDir, "01 - track.mp3"); + + Directory.CreateDirectory(artistDir); + await File.WriteAllTextAsync(audioPath, "audio-data"); + + try + { + var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: true)); + + var result = await controller.DownloadAllFiles(); + + var fileResult = Assert.IsType(result); + Assert.Equal("application/zip", fileResult.ContentType); + + var entries = ReadArchiveEntries(fileResult.FileStream); + Assert.Contains(Path.Combine("Artist", "Album", "01 - track.mp3").Replace('\\', '/'), entries); + Assert.Contains(Path.Combine("Artist", "Album", "01 - track.lrc").Replace('\\', '/'), entries); + } + finally + { + DeleteTestRoot(testRoot); + } + } + + [Fact] + public void DeleteDownload_RemovesAdjacentLyricsSidecar() + { + var testRoot = CreateTestRoot(); + var downloadsRoot = Path.Combine(testRoot, "downloads"); + var artistDir = Path.Combine(downloadsRoot, "kept", "Artist"); + var audioPath = Path.Combine(artistDir, "track.mp3"); + var sidecarPath = Path.Combine(artistDir, "track.lrc"); + + Directory.CreateDirectory(artistDir); + File.WriteAllText(audioPath, "audio-data"); + File.WriteAllText(sidecarPath, "[00:00.00]lyrics"); + + try + { + var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: false)); + + var result = controller.DeleteDownload("Artist/track.mp3"); + + Assert.IsType(result); + Assert.False(File.Exists(audioPath)); + Assert.False(File.Exists(sidecarPath)); + } + finally + { + DeleteTestRoot(testRoot); + } + } + + private static DownloadsController CreateController(string downloadsRoot, IKeptLyricsSidecarService? keptLyricsSidecarService = null) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Library:DownloadPath"] = downloadsRoot + }) + .Build(); + + return new DownloadsController( + NullLogger.Instance, + config, + keptLyricsSidecarService) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + } + + private static HashSet ReadArchiveEntries(Stream archiveStream) + { + archiveStream.Position = 0; + using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true); + return zip.Entries + .Select(entry => entry.FullName.Replace('\\', '/')) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + private static string CreateTestRoot() + { + var root = Path.Combine(Path.GetTempPath(), "allstarr-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + private static void DeleteTestRoot(string root) + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + + private sealed class FakeKeptLyricsSidecarService : IKeptLyricsSidecarService + { + private readonly bool _createSidecar; + + public FakeKeptLyricsSidecarService(bool createSidecar) + { + _createSidecar = createSidecar; + } + + public string GetSidecarPath(string audioFilePath) + { + return Path.ChangeExtension(audioFilePath, ".lrc"); + } + + public Task EnsureSidecarAsync( + string audioFilePath, + Song? song = null, + string? externalProvider = null, + string? externalId = null, + CancellationToken cancellationToken = default) + { + var sidecarPath = GetSidecarPath(audioFilePath); + if (_createSidecar) + { + File.WriteAllText(sidecarPath, "[00:00.00]lyrics"); + return Task.FromResult(sidecarPath); + } + + return Task.FromResult(null); + } + } +} diff --git a/allstarr.Tests/DownloadsControllerPathSecurityTests.cs b/allstarr.Tests/DownloadsControllerPathSecurityTests.cs index 8712fe4..6f852e8 100644 --- a/allstarr.Tests/DownloadsControllerPathSecurityTests.cs +++ b/allstarr.Tests/DownloadsControllerPathSecurityTests.cs @@ -9,7 +9,7 @@ namespace allstarr.Tests; public class DownloadsControllerPathSecurityTests { [Fact] - public void DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected() + public async Task DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected() { var testRoot = CreateTestRoot(); var downloadsRoot = Path.Combine(testRoot, "downloads"); @@ -23,7 +23,7 @@ public class DownloadsControllerPathSecurityTests try { var controller = CreateController(downloadsRoot); - var result = controller.DownloadFile("../kept-malicious/attack.mp3"); + var result = await controller.DownloadFile("../kept-malicious/attack.mp3"); var badRequest = Assert.IsType(result); Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); @@ -63,7 +63,7 @@ public class DownloadsControllerPathSecurityTests } [Fact] - public void DownloadFile_ValidPathInsideKeptFolder_AllowsDownload() + public async Task DownloadFile_ValidPathInsideKeptFolder_AllowsDownload() { var testRoot = CreateTestRoot(); var downloadsRoot = Path.Combine(testRoot, "downloads"); @@ -76,7 +76,7 @@ public class DownloadsControllerPathSecurityTests try { var controller = CreateController(downloadsRoot); - var result = controller.DownloadFile("Artist/track.mp3"); + var result = await controller.DownloadFile("Artist/track.mp3"); Assert.IsType(result); } @@ -97,7 +97,13 @@ public class DownloadsControllerPathSecurityTests return new DownloadsController( NullLogger.Instance, - config); + config) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; } private static string CreateTestRoot() diff --git a/allstarr.Tests/InjectedPlaylistItemHelperTests.cs b/allstarr.Tests/InjectedPlaylistItemHelperTests.cs index fbb79fd..4cdbd76 100644 --- a/allstarr.Tests/InjectedPlaylistItemHelperTests.cs +++ b/allstarr.Tests/InjectedPlaylistItemHelperTests.cs @@ -82,4 +82,39 @@ public class InjectedPlaylistItemHelperTests Assert.False(InjectedPlaylistItemHelper.LooksLikeLocalItemMissingGenreMetadata(item)); } + + [Theory] + [InlineData("ext-deezer-song-123", "Track [S]")] + [InlineData("ext-deezer-song-123", "Track [S] [E]")] + [InlineData("ext-qobuz-song-123", "Track [S]")] + public void LooksLikeLegacyExternalSourceLabeledItem_ReturnsTrue_ForRelabeledProviders( + string id, + string name) + { + var item = new Dictionary + { + ["Id"] = id, + ["Name"] = name + }; + + Assert.True(InjectedPlaylistItemHelper.LooksLikeLegacyExternalSourceLabeledItem(item)); + } + + [Theory] + [InlineData("ext-deezer-song-123", "Track [D]")] + [InlineData("ext-qobuz-song-123", "Track [Q]")] + [InlineData("ext-squidwtf-song-123", "Track [S]")] + [InlineData("local-song-123", "Track [S]")] + public void LooksLikeLegacyExternalSourceLabeledItem_ReturnsFalse_ForCurrentLabels( + string id, + string name) + { + var item = new Dictionary + { + ["Id"] = id, + ["Name"] = name + }; + + Assert.False(InjectedPlaylistItemHelper.LooksLikeLegacyExternalSourceLabeledItem(item)); + } } diff --git a/allstarr.Tests/JellyfinResponseBuilderTests.cs b/allstarr.Tests/JellyfinResponseBuilderTests.cs index baf2cf7..3eb597c 100644 --- a/allstarr.Tests/JellyfinResponseBuilderTests.cs +++ b/allstarr.Tests/JellyfinResponseBuilderTests.cs @@ -115,6 +115,56 @@ public class JellyfinResponseBuilderTests Assert.Equal("Sunflower [S]", result["Name"]); } + [Theory] + [InlineData("deezer", "[D]")] + [InlineData("qobuz", "[Q]")] + [InlineData("squidwtf", "[S]")] + public void ConvertSongToJellyfinItem_ExternalSong_UsesProviderSourceLabel(string provider, string label) + { + var song = new Song + { + Id = $"ext-{provider}-song-12345", + Title = "External Track", + Artist = "External Artist", + Artists = new List { "External Artist" }, + Album = "External Album", + IsLocal = false, + ExternalProvider = provider, + ExternalId = "12345" + }; + + var result = _builder.ConvertSongToJellyfinItem(song); + + Assert.Equal($"External Track {label}", result["Name"]); + Assert.Equal($"External Album {label}", result["Album"]); + var artists = Assert.IsType(result["Artists"]); + Assert.Equal(new[] { $"External Artist {label}" }, artists); + } + + [Fact] + public void ConvertSongToJellyfinItem_DeezerPlaylistMatch_LabelsFallbackArtist() + { + var matchedSong = new Song + { + Id = "ext-deezer-song-12345", + Title = "Matched Track", + Artist = "Matched Artist", + Album = "Matched Album", + IsLocal = false, + ExternalProvider = "deezer", + ExternalId = "12345" + }; + + var result = _builder.ConvertSongToJellyfinItem(matchedSong); + + Assert.Equal("Matched Track [D]", result["Name"]); + Assert.Equal("Matched Album [D]", result["Album"]); + var artists = Assert.IsType(result["Artists"]); + Assert.Equal(["Matched Artist [D]"], artists); + var artistItems = Assert.IsType[]>(result["ArtistItems"]); + Assert.Equal("Matched Artist [D]", artistItems[0]["Name"]); + } + [Theory] [InlineData("deezer")] [InlineData("qobuz")] @@ -199,6 +249,28 @@ public class JellyfinResponseBuilderTests Assert.NotNull(result["BasicSyncInfo"]); } + [Theory] + [InlineData("deezer", "[D]")] + [InlineData("qobuz", "[Q]")] + [InlineData("squidwtf", "[S]")] + public void ConvertAlbumToJellyfinItem_ExternalAlbum_UsesProviderSourceLabel(string provider, string label) + { + var album = new Album + { + Id = $"ext-{provider}-album-456", + Title = "External Album", + Artist = "External Artist", + IsLocal = false, + ExternalProvider = provider, + ExternalId = "456" + }; + + var result = _builder.ConvertAlbumToJellyfinItem(album); + + Assert.Equal($"External Album {label}", result["Name"]); + Assert.Equal($"External Album {label}", result["SortName"]); + } + [Fact] public void ConvertArtistToJellyfinItem_SetsCorrectFields() { @@ -225,6 +297,27 @@ public class JellyfinResponseBuilderTests Assert.NotNull(result["BasicSyncInfo"]); } + [Theory] + [InlineData("deezer", "[D]")] + [InlineData("qobuz", "[Q]")] + [InlineData("squidwtf", "[S]")] + public void ConvertArtistToJellyfinItem_ExternalArtist_UsesProviderSourceLabel(string provider, string label) + { + var artist = new Artist + { + Id = $"ext-{provider}-artist-789", + Name = "External Artist", + IsLocal = false, + ExternalProvider = provider, + ExternalId = "789" + }; + + var result = _builder.ConvertArtistToJellyfinItem(artist); + + Assert.Equal($"External Artist {label}", result["Name"]); + Assert.Equal($"External Artist {label}", result["SortName"]); + } + [Fact] public void ConvertPlaylistToAlbumItem_SetsPlaylistType() { @@ -246,12 +339,12 @@ public class JellyfinResponseBuilderTests // Assert Assert.Equal("ext-playlist-deezer-999", result["Id"]); - Assert.Equal("Summer Vibes [S/P]", result["Name"]); + Assert.Equal("Summer Vibes [D/P]", result["Name"]); Assert.Equal("MusicAlbum", result["Type"]); Assert.Equal("DJ Cool", result["AlbumArtist"]); Assert.Equal(50, result["ChildCount"]); Assert.Equal(2023, result["ProductionYear"]); - Assert.Equal("Summer Vibes [S/P]", result["SortName"]); + Assert.Equal("Summer Vibes [D/P]", result["SortName"]); Assert.NotNull(result["DateCreated"]); Assert.NotNull(result["BasicSyncInfo"]); } diff --git a/allstarr.Tests/ScrobblingAdminControllerTests.cs b/allstarr.Tests/ScrobblingAdminControllerTests.cs index 53c4f5b..882084d 100644 --- a/allstarr.Tests/ScrobblingAdminControllerTests.cs +++ b/allstarr.Tests/ScrobblingAdminControllerTests.cs @@ -130,6 +130,24 @@ public class ScrobblingAdminControllerTests Assert.DoesNotContain(userToken, payload, StringComparison.Ordinal); } + private const string TestLastFmApiKey = "0123456789abcdef0123456789abcdef"; + private const string TestLastFmSharedSecret = "fedcba9876543210fedcba9876543210"; + + [Fact] + public async Task AuthenticateLastFm_LegacyApiKey_ReturnsBadRequest() + { + var settings = CreateSettings("testuser", "password123"); + settings.LastFm.ApiKey = LastFmSettings.LegacyJellyfinPluginApiKey; + settings.LastFm.SharedSecret = LastFmSettings.LegacyJellyfinPluginSharedSecret; + + var controller = CreateController( + settings, + new HttpResponseMessage(HttpStatusCode.OK)); + + var result = await controller.AuthenticateLastFm(); + Assert.IsType(result); + } + private static ScrobblingSettings CreateSettings(string? username, string? password) { return new ScrobblingSettings @@ -139,8 +157,8 @@ public class ScrobblingAdminControllerTests LastFm = new LastFmSettings { Enabled = true, - ApiKey = "cb3bdcd415fcb40cd572b137b2b255f5", - SharedSecret = "3a08f9fad6ddc4c35b0dce0062cecb5e", + ApiKey = TestLastFmApiKey, + SharedSecret = TestLastFmSharedSecret, SessionKey = string.Empty, Username = username, Password = password diff --git a/allstarr/AppVersion.cs b/allstarr/AppVersion.cs index 9cb9a30..477b230 100644 --- a/allstarr/AppVersion.cs +++ b/allstarr/AppVersion.cs @@ -9,5 +9,5 @@ public static class AppVersion /// /// Current application version. /// - public const string Version = "1.5.4"; + public const string Version = "2.0.2"; } diff --git a/allstarr/Controllers/AdminAuthController.cs b/allstarr/Controllers/AdminAuthController.cs index 95b56d4..3936380 100644 --- a/allstarr/Controllers/AdminAuthController.cs +++ b/allstarr/Controllers/AdminAuthController.cs @@ -114,7 +114,8 @@ public class AdminAuthController : ControllerBase userName: userName, isAdministrator: isAdministrator, jellyfinAccessToken: accessToken, - jellyfinServerId: serverId); + jellyfinServerId: serverId, + isPersistent: request.RememberMe); SetSessionCookie(session.SessionId, session.ExpiresAtUtc); @@ -130,6 +131,7 @@ public class AdminAuthController : ControllerBase name = session.UserName, isAdministrator = session.IsAdministrator }, + rememberMe = session.IsPersistent, expiresAtUtc = session.ExpiresAtUtc }); } @@ -159,6 +161,7 @@ public class AdminAuthController : ControllerBase name = session.UserName, isAdministrator = session.IsAdministrator }, + rememberMe = session.IsPersistent, expiresAtUtc = session.ExpiresAtUtc }); } @@ -196,6 +199,7 @@ public class AdminAuthController : ControllerBase { public string? Username { get; set; } public string? Password { get; set; } + public bool RememberMe { get; set; } } private sealed class JellyfinAuthenticateRequest diff --git a/allstarr/Controllers/DownloadsController.cs b/allstarr/Controllers/DownloadsController.cs index 5a2b7cf..bef268c 100644 --- a/allstarr/Controllers/DownloadsController.cs +++ b/allstarr/Controllers/DownloadsController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using allstarr.Filters; using allstarr.Services.Admin; +using allstarr.Services.Lyrics; namespace allstarr.Controllers; @@ -9,15 +10,20 @@ namespace allstarr.Controllers; [ServiceFilter(typeof(AdminPortFilter))] public class DownloadsController : ControllerBase { + private static readonly string[] AudioExtensions = [".flac", ".mp3", ".m4a", ".opus"]; + private readonly ILogger _logger; private readonly IConfiguration _configuration; + private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService; public DownloadsController( ILogger logger, - IConfiguration configuration) + IConfiguration configuration, + IKeptLyricsSidecarService? keptLyricsSidecarService = null) { _logger = logger; _configuration = configuration; + _keptLyricsSidecarService = keptLyricsSidecarService; } [HttpGet("downloads")] @@ -36,10 +42,8 @@ public class DownloadsController : ControllerBase long totalSize = 0; // Recursively get all audio files from kept folder - var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" }; - var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories) - .Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) + .Where(IsSupportedAudioFile) .ToList(); foreach (var filePath in allFiles) @@ -112,6 +116,11 @@ public class DownloadsController : ControllerBase } System.IO.File.Delete(fullPath); + var sidecarPath = _keptLyricsSidecarService?.GetSidecarPath(fullPath) ?? Path.ChangeExtension(fullPath, ".lrc"); + if (System.IO.File.Exists(sidecarPath)) + { + System.IO.File.Delete(sidecarPath); + } // Clean up empty directories (Album folder, then Artist folder if empty) var directory = Path.GetDirectoryName(fullPath); @@ -154,9 +163,8 @@ public class DownloadsController : ControllerBase 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())) + .Where(IsSupportedAudioFile) .ToList(); foreach (var filePath in allFiles) @@ -164,6 +172,12 @@ public class DownloadsController : ControllerBase System.IO.File.Delete(filePath); } + var sidecarFiles = Directory.GetFiles(keptPath, "*.lrc", SearchOption.AllDirectories); + foreach (var sidecarFile in sidecarFiles) + { + System.IO.File.Delete(sidecarFile); + } + // Clean up empty directories under kept root (deepest first) var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories) .OrderByDescending(d => d.Length); @@ -194,7 +208,7 @@ public class DownloadsController : ControllerBase /// Downloads a specific file from the kept folder /// [HttpGet("downloads/file")] - public IActionResult DownloadFile([FromQuery] string path) + public async Task DownloadFile([FromQuery] string path) { try { @@ -216,8 +230,16 @@ public class DownloadsController : ControllerBase } var fileName = Path.GetFileName(fullPath); - var fileStream = System.IO.File.OpenRead(fullPath); + if (IsSupportedAudioFile(fullPath)) + { + var sidecarPath = await EnsureLyricsSidecarIfPossibleAsync(fullPath, HttpContext.RequestAborted); + if (System.IO.File.Exists(sidecarPath)) + { + return await CreateSingleTrackArchiveAsync(fullPath, sidecarPath, fileName); + } + } + var fileStream = System.IO.File.OpenRead(fullPath); return File(fileStream, "application/octet-stream", fileName); } catch (Exception ex) @@ -232,7 +254,7 @@ public class DownloadsController : ControllerBase /// Downloads all kept files as a zip archive /// [HttpGet("downloads/all")] - public IActionResult DownloadAllFiles() + public async Task DownloadAllFiles() { try { @@ -243,9 +265,8 @@ public class DownloadsController : ControllerBase return NotFound(new { error = "No kept files found" }); } - var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" }; var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories) - .Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) + .Where(IsSupportedAudioFile) .ToList(); if (allFiles.Count == 0) @@ -259,14 +280,18 @@ public class DownloadsController : ControllerBase var memoryStream = new MemoryStream(); using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true)) { + var addedEntries = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var filePath in allFiles) { var relativePath = Path.GetRelativePath(keptPath, filePath); - var entry = archive.CreateEntry(relativePath, System.IO.Compression.CompressionLevel.NoCompression); + await AddFileToArchiveAsync(archive, filePath, relativePath, addedEntries); - using var entryStream = entry.Open(); - using var fileStream = System.IO.File.OpenRead(filePath); - fileStream.CopyTo(entryStream); + var sidecarPath = await EnsureLyricsSidecarIfPossibleAsync(filePath, HttpContext.RequestAborted); + if (System.IO.File.Exists(sidecarPath)) + { + var sidecarRelativePath = Path.GetRelativePath(keptPath, sidecarPath); + await AddFileToArchiveAsync(archive, sidecarPath, sidecarRelativePath, addedEntries); + } } } @@ -330,6 +355,54 @@ public class DownloadsController : ControllerBase : StringComparison.Ordinal; } + private async Task EnsureLyricsSidecarIfPossibleAsync(string audioFilePath, CancellationToken cancellationToken) + { + var sidecarPath = _keptLyricsSidecarService?.GetSidecarPath(audioFilePath) ?? Path.ChangeExtension(audioFilePath, ".lrc"); + if (System.IO.File.Exists(sidecarPath) || _keptLyricsSidecarService == null) + { + return sidecarPath; + } + + var generatedSidecar = await _keptLyricsSidecarService.EnsureSidecarAsync(audioFilePath, cancellationToken: cancellationToken); + return generatedSidecar ?? sidecarPath; + } + + private async Task CreateSingleTrackArchiveAsync(string audioFilePath, string sidecarPath, string fileName) + { + var archiveStream = new MemoryStream(); + using (var archive = new System.IO.Compression.ZipArchive(archiveStream, System.IO.Compression.ZipArchiveMode.Create, true)) + { + await AddFileToArchiveAsync(archive, audioFilePath, Path.GetFileName(audioFilePath), null); + await AddFileToArchiveAsync(archive, sidecarPath, Path.GetFileName(sidecarPath), null); + } + + archiveStream.Position = 0; + var downloadName = $"{Path.GetFileNameWithoutExtension(fileName)}.zip"; + return File(archiveStream, "application/zip", downloadName); + } + + private static async Task AddFileToArchiveAsync( + System.IO.Compression.ZipArchive archive, + string filePath, + string entryPath, + HashSet? addedEntries) + { + if (addedEntries != null && !addedEntries.Add(entryPath)) + { + return; + } + + var entry = archive.CreateEntry(entryPath, System.IO.Compression.CompressionLevel.NoCompression); + await using var entryStream = entry.Open(); + await using var fileStream = System.IO.File.OpenRead(filePath); + await fileStream.CopyToAsync(entryStream); + } + + private static bool IsSupportedAudioFile(string path) + { + return AudioExtensions.Contains(Path.GetExtension(path).ToLowerInvariant()); + } + /// /// Gets all Spotify track mappings (paginated) /// diff --git a/allstarr/Controllers/JellyfinController.Audio.cs b/allstarr/Controllers/JellyfinController.Audio.cs index 85700b7..b109e68 100644 --- a/allstarr/Controllers/JellyfinController.Audio.cs +++ b/allstarr/Controllers/JellyfinController.Audio.cs @@ -1,5 +1,6 @@ using allstarr.Services.Common; using Microsoft.AspNetCore.Mvc; +using System.Net; namespace allstarr.Controllers; @@ -193,23 +194,75 @@ public partial class JellyfinController } catch (Exception ex) { - if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue) - { - _logger.LogError("Failed to stream external song {Provider}:{ExternalId}: {StatusCode}: {ReasonPhrase}", - provider, - externalId, - (int)httpRequestException.StatusCode.Value, - httpRequestException.StatusCode.Value); - _logger.LogDebug(ex, "Detailed streaming failure for external song {Provider}:{ExternalId}", provider, externalId); - } - else - { - _logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId); - } - return StatusCode(500, new { error = "Streaming failed" }); + return HandleExternalStreamFailure(provider, externalId, ex); } } + private IActionResult HandleExternalStreamFailure(string provider, string externalId, Exception ex) + { + if (HttpContext.RequestAborted.IsCancellationRequested && ex is OperationCanceledException) + { + _logger.LogInformation("Client aborted external stream request for {Provider}:{ExternalId}", provider, externalId); + return StatusCode(499); + } + + var (statusCode, errorMessage) = MapExternalStreamException(ex); + + if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue) + { + _logger.LogError("Failed to stream external song {Provider}:{ExternalId}: responding {StatusCode}; upstream returned {UpstreamStatus}: {ReasonPhrase}", + provider, + externalId, + statusCode, + (int)httpRequestException.StatusCode.Value, + httpRequestException.StatusCode.Value); + _logger.LogDebug(ex, "Detailed streaming failure for external song {Provider}:{ExternalId}", provider, externalId); + } + else + { + _logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}: responding {StatusCode}", + provider, externalId, statusCode); + } + + return StatusCode(statusCode, new { error = errorMessage }); + } + + private static (int statusCode, string errorMessage) MapExternalStreamException(Exception ex) + { + if (ex is TimeoutException || ex is TaskCanceledException) + { + return (StatusCodes.Status504GatewayTimeout, "External provider timed out"); + } + + if (ex is HttpRequestException httpRequestException) + { + return httpRequestException.StatusCode switch + { + HttpStatusCode.NotFound => (StatusCodes.Status404NotFound, "External track not found"), + HttpStatusCode.TooManyRequests => (StatusCodes.Status503ServiceUnavailable, "External provider is rate limiting requests"), + HttpStatusCode.BadGateway or + HttpStatusCode.ServiceUnavailable or + HttpStatusCode.GatewayTimeout or + HttpStatusCode.InternalServerError => (StatusCodes.Status503ServiceUnavailable, "External provider is unavailable"), + _ => (StatusCodes.Status502BadGateway, "External provider request failed") + }; + } + + if (ex is InvalidOperationException invalidOperationException && + invalidOperationException.Message.Contains("endpoints", StringComparison.OrdinalIgnoreCase)) + { + return (StatusCodes.Status503ServiceUnavailable, "External provider has no healthy endpoints"); + } + + if (ex.Message.Contains("endpoints failed", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("No SquidWTF endpoints", StringComparison.OrdinalIgnoreCase)) + { + return (StatusCodes.Status503ServiceUnavailable, "External provider has no healthy endpoints"); + } + + return (StatusCodes.Status502BadGateway, "External stream failed"); + } + /// /// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming. /// This is the primary endpoint used by Jellyfin Web and most clients. diff --git a/allstarr/Controllers/JellyfinController.Lyrics.cs b/allstarr/Controllers/JellyfinController.Lyrics.cs index 3787a9a..befb8a4 100644 --- a/allstarr/Controllers/JellyfinController.Lyrics.cs +++ b/allstarr/Controllers/JellyfinController.Lyrics.cs @@ -475,6 +475,8 @@ public partial class JellyfinController return value .Replace(" [S]", "", StringComparison.Ordinal) + .Replace(" [D]", "", StringComparison.Ordinal) + .Replace(" [Q]", "", StringComparison.Ordinal) .Replace(" [E]", "", StringComparison.Ordinal) .Trim(); } diff --git a/allstarr/Controllers/JellyfinController.Spotify.cs b/allstarr/Controllers/JellyfinController.Spotify.cs index 059d082..a3b698d 100644 --- a/allstarr/Controllers/JellyfinController.Spotify.cs +++ b/allstarr/Controllers/JellyfinController.Spotify.cs @@ -9,6 +9,8 @@ namespace allstarr.Controllers; public partial class JellyfinController { + private static readonly string[] KeptAudioExtensions = [".flac", ".mp3", ".m4a", ".opus"]; + #region Spotify Playlist Injection /// @@ -79,6 +81,16 @@ public partial class JellyfinController cachedItems = null; } + if (cachedItems != null && cachedItems.Count > 0 && + InjectedPlaylistItemHelper.ContainsLegacyExternalSourceLabels(cachedItems)) + { + _logger.LogInformation( + "Ignoring Redis playlist cache for {Playlist}: external items still use legacy source labels", + spotifyPlaylistName); + await _cache.DeleteAsync(cacheKey); + cachedItems = null; + } + if (cachedItems != null && cachedItems.Count > 0 && requestNeedsGenreMetadata && InjectedPlaylistItemHelper.ContainsLocalItemsMissingGenreMetadata(cachedItems)) @@ -120,6 +132,15 @@ public partial class JellyfinController fileItems = null; } + if (fileItems != null && fileItems.Count > 0 && + InjectedPlaylistItemHelper.ContainsLegacyExternalSourceLabels(fileItems)) + { + _logger.LogInformation( + "Ignoring file playlist cache for {Playlist}: external items still use legacy source labels", + spotifyPlaylistName); + fileItems = null; + } + if (fileItems != null && fileItems.Count > 0 && requestNeedsGenreMetadata && InjectedPlaylistItemHelper.ContainsLocalItemsMissingGenreMetadata(fileItems)) @@ -480,10 +501,13 @@ public partial class JellyfinController if (Directory.Exists(keptAlbumPath)) { var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title); - var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*"); - if (existingFiles.Length > 0) + var existingAudioFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*") + .Where(IsKeptAudioFile) + .ToArray(); + if (existingAudioFiles.Length > 0) { - _logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]); + _logger.LogInformation("Track already exists in kept folder: {Path}", existingAudioFiles[0]); + await EnsureLyricsSidecarForKeptTrackAsync(existingAudioFiles[0], song, provider, externalId); // Mark as favorited even if we didn't download it await MarkTrackAsFavoritedAsync(itemId, song); return; @@ -491,7 +515,7 @@ public partial class JellyfinController } // Look for the track in cache folder first - var cacheBasePath = "/tmp/allstarr-cache"; + var cacheBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache"); var cacheArtistPath = Path.Combine(cacheBasePath, AdminHelperService.SanitizeFileName(song.Artist)); var cacheAlbumPath = Path.Combine(cacheArtistPath, AdminHelperService.SanitizeFileName(song.Album)); @@ -572,6 +596,7 @@ public partial class JellyfinController { // Race condition - file was created by another request _logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath); + await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId); await MarkTrackAsFavoritedAsync(itemId, song); return; } @@ -589,6 +614,7 @@ public partial class JellyfinController { // Race condition on copy fallback _logger.LogInformation("Track already exists in kept folder (race condition on copy): {Path}", keptFilePath); + await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId); await MarkTrackAsFavoritedAsync(itemId, song); return; } @@ -650,6 +676,8 @@ public partial class JellyfinController } } + await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId); + // Mark as favorited in persistent storage await MarkTrackAsFavoritedAsync(itemId, song); } @@ -903,6 +931,33 @@ public partial class JellyfinController } } + private async Task EnsureLyricsSidecarForKeptTrackAsync(string keptFilePath, Song song, string provider, string externalId) + { + if (_keptLyricsSidecarService == null) + { + return; + } + + try + { + await _keptLyricsSidecarService.EnsureSidecarAsync( + keptFilePath, + song, + provider, + externalId, + CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to create kept lyrics sidecar for {Path}", keptFilePath); + } + } + + private static bool IsKeptAudioFile(string path) + { + return KeptAudioExtensions.Contains(Path.GetExtension(path).ToLowerInvariant()); + } + #endregion /// diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 7e2596b..e1f3840 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -47,6 +47,7 @@ public partial class JellyfinController : ControllerBase private readonly LyricsPlusService? _lyricsPlusService; private readonly LrclibService? _lrclibService; private readonly LyricsOrchestrator? _lyricsOrchestrator; + private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService; private readonly ScrobblingOrchestrator? _scrobblingOrchestrator; private readonly ScrobblingHelper? _scrobblingHelper; private readonly OdesliService _odesliService; @@ -77,6 +78,7 @@ public partial class JellyfinController : ControllerBase LyricsPlusService? lyricsPlusService = null, LrclibService? lrclibService = null, LyricsOrchestrator? lyricsOrchestrator = null, + IKeptLyricsSidecarService? keptLyricsSidecarService = null, ScrobblingOrchestrator? scrobblingOrchestrator = null, ScrobblingHelper? scrobblingHelper = null) { @@ -98,6 +100,7 @@ public partial class JellyfinController : ControllerBase _lyricsPlusService = lyricsPlusService; _lrclibService = lrclibService; _lyricsOrchestrator = lyricsOrchestrator; + _keptLyricsSidecarService = keptLyricsSidecarService; _scrobblingOrchestrator = scrobblingOrchestrator; _scrobblingHelper = scrobblingHelper; _odesliService = odesliService; @@ -817,9 +820,14 @@ public partial class JellyfinController : ControllerBase { try { - var (itemResult, statusCode) = await _proxyService.GetJsonAsyncInternal($"Items/{itemId}"); + var (itemResult, statusCode) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers); if (itemResult == null || statusCode != 200) { + _logger.LogDebug( + "Skipping Jellyfin {ImageType} image tag resolution for Spotify playlist {PlaylistId}: upstream returned {StatusCode}", + imageType, + itemId, + statusCode); return null; } diff --git a/allstarr/Controllers/MappingController.cs b/allstarr/Controllers/MappingController.cs index dd23563..b3633f3 100644 --- a/allstarr/Controllers/MappingController.cs +++ b/allstarr/Controllers/MappingController.cs @@ -64,6 +64,7 @@ public class MappingController : ControllerBase foreach (var mapping in playlistMappings.Values) { + var targets = await BuildExternalTargetsForManualMappingAsync(mapping); allMappings.Add(new { playlist = playlistName, @@ -72,6 +73,7 @@ public class MappingController : ControllerBase jellyfinId = mapping.JellyfinId, externalProvider = mapping.ExternalProvider, externalId = mapping.ExternalId, + externalTargets = targets, createdAt = mapping.CreatedAt }); } @@ -102,7 +104,10 @@ public class MappingController : ControllerBase /// Delete a manual track mapping /// [HttpDelete("mappings/tracks")] - public async Task DeleteTrackMapping([FromQuery] string playlist, [FromQuery] string spotifyId) + public async Task DeleteTrackMapping( + [FromQuery] string playlist, + [FromQuery] string spotifyId, + [FromQuery] string? provider = null) { if (string.IsNullOrEmpty(playlist) || string.IsNullOrEmpty(spotifyId)) { @@ -111,47 +116,34 @@ public class MappingController : ControllerBase try { - var mappingsDir = "/app/cache/mappings"; - var safeName = AdminHelperService.SanitizeFileName(playlist); - var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json"); - - if (!System.IO.File.Exists(filePath)) + var removedPlaylistManual = false; + var removedGlobal = false; + + if (!string.IsNullOrWhiteSpace(provider)) { - return NotFound(new { error = "Mapping file not found for playlist" }); - } - - // Load existing mappings - var json = await System.IO.File.ReadAllTextAsync(filePath); - var mappings = JsonSerializer.Deserialize>(json); - - if (mappings == null || !mappings.ContainsKey(spotifyId)) - { - return NotFound(new { error = "Mapping not found" }); - } - - // Remove the mapping - mappings.Remove(spotifyId); - - // Save back to file (or delete file if empty) - if (mappings.Count == 0) - { - System.IO.File.Delete(filePath); - _logger.LogInformation("🗑️ Deleted empty mapping file for playlist {Playlist}", playlist); + removedGlobal = await _mappingService.RemoveExternalProviderAsync(spotifyId, provider); + removedPlaylistManual = await TryRemovePlaylistManualProviderAsync( + playlist, + spotifyId, + provider); } else { - var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true }); - await System.IO.File.WriteAllTextAsync(filePath, updatedJson); - _logger.LogInformation("🗑️ Deleted mapping: {Playlist} - {SpotifyId}", playlist, spotifyId); - } - - // Also remove from Redis cache - var cacheKey = $"manual:mapping:{playlist}:{spotifyId}"; - await _cache.DeleteAsync(cacheKey); + removedPlaylistManual = await TryRemovePlaylistManualMappingAsync(playlist, spotifyId); + if (removedPlaylistManual) + { + var cacheKey = $"manual:mapping:{playlist}:{spotifyId}"; + await _cache.DeleteAsync(cacheKey); + } + + removedGlobal = await _mappingService.DeleteMappingAsync(spotifyId); + } + + if (!removedPlaylistManual && !removedGlobal) + { + return NotFound(new { error = "Mapping not found" }); + } - // Keep global Spotify mapping index in sync as well. - await _mappingService.DeleteMappingAsync(spotifyId); - return Ok(new { success = true, message = "Mapping deleted successfully" }); } catch (Exception ex) @@ -160,6 +152,120 @@ public class MappingController : ControllerBase return StatusCode(500, new { error = "Failed to delete track mapping" }); } } + + private async Task> BuildExternalTargetsForManualMappingAsync(ManualMappingEntry mapping) + { + var targets = new List(); + var seenProviders = new HashSet(StringComparer.OrdinalIgnoreCase); + + void AddTarget(string? provider, string? externalId, string source) + { + if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(externalId)) + { + return; + } + + var key = provider.Trim().ToLowerInvariant(); + if (!seenProviders.Add(key)) + { + return; + } + + targets.Add(new + { + provider, + externalId, + source + }); + } + + var global = await _mappingService.GetMappingAsync(mapping.SpotifyId); + if (global != null) + { + foreach (var external in global.ExternalMappings) + { + AddTarget(external.Provider, external.ExternalId, external.Source); + } + + AddTarget(global.ExternalProvider, global.ExternalId, global.Source); + } + + AddTarget(mapping.ExternalProvider, mapping.ExternalId, "manual"); + + return targets; + } + + private async Task TryRemovePlaylistManualProviderAsync( + string playlist, + string spotifyId, + string provider) + { + var mappingsDir = "/app/cache/mappings"; + var safeName = AdminHelperService.SanitizeFileName(playlist); + var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json"); + + if (!System.IO.File.Exists(filePath)) + { + return false; + } + + var json = await System.IO.File.ReadAllTextAsync(filePath); + var mappings = JsonSerializer.Deserialize>(json); + if (mappings == null || !mappings.TryGetValue(spotifyId, out var entry)) + { + return false; + } + + if (!string.Equals(entry.ExternalProvider, provider, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + mappings.Remove(spotifyId); + await SavePlaylistMappingsFileAsync(filePath, mappings, playlist, spotifyId); + return true; + } + + private async Task TryRemovePlaylistManualMappingAsync(string playlist, string spotifyId) + { + var mappingsDir = "/app/cache/mappings"; + var safeName = AdminHelperService.SanitizeFileName(playlist); + var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json"); + + if (!System.IO.File.Exists(filePath)) + { + return false; + } + + var json = await System.IO.File.ReadAllTextAsync(filePath); + var mappings = JsonSerializer.Deserialize>(json); + if (mappings == null || !mappings.ContainsKey(spotifyId)) + { + return false; + } + + mappings.Remove(spotifyId); + await SavePlaylistMappingsFileAsync(filePath, mappings, playlist, spotifyId); + return true; + } + + private async Task SavePlaylistMappingsFileAsync( + string filePath, + Dictionary mappings, + string playlist, + string spotifyId) + { + if (mappings.Count == 0) + { + System.IO.File.Delete(filePath); + _logger.LogInformation("🗑️ Deleted empty mapping file for playlist {Playlist}", playlist); + return; + } + + var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true }); + await System.IO.File.WriteAllTextAsync(filePath, updatedJson); + _logger.LogInformation("🗑️ Deleted mapping: {Playlist} - {SpotifyId}", playlist, spotifyId); + } /// /// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID diff --git a/allstarr/Controllers/PlaylistController.cs b/allstarr/Controllers/PlaylistController.cs index 4a92d36..df1c5d9 100644 --- a/allstarr/Controllers/PlaylistController.cs +++ b/allstarr/Controllers/PlaylistController.cs @@ -9,6 +9,7 @@ using allstarr.Services.Admin; using allstarr.Services; using allstarr.Filters; using System.Text.Json; +using Microsoft.Extensions.Configuration; namespace allstarr.Controllers; @@ -27,6 +28,7 @@ public class PlaylistController : ControllerBase private readonly HttpClient _jellyfinHttpClient; private readonly AdminHelperService _helperService; private readonly IServiceProvider _serviceProvider; + private readonly IConfiguration _configuration; private const string CacheDirectory = "/app/cache/spotify"; public PlaylistController( @@ -37,6 +39,7 @@ public class PlaylistController : ControllerBase SpotifyMappingService mappingService, RedisCacheService cache, IHttpClientFactory httpClientFactory, + IConfiguration configuration, AdminHelperService helperService, IServiceProvider serviceProvider, SpotifyTrackMatchingService? matchingService = null) @@ -49,6 +52,7 @@ public class PlaylistController : ControllerBase _mappingService = mappingService; _cache = cache; _jellyfinHttpClient = httpClientFactory.CreateClient(); + _configuration = configuration; _helperService = helperService; _serviceProvider = serviceProvider; } @@ -753,7 +757,7 @@ public class PlaylistController : ControllerBase if (string.IsNullOrWhiteSpace(externalProvider) && globalMappingExt?.TargetType == "external") { - externalProvider = NormalizeExternalProviderForDisplay(globalMappingExt.ExternalProvider); + externalProvider = ResolvePreferredExternalProvider(globalMappingExt); } // Fallback 3: derive provider from external item ID prefix (ext-{provider}-...) @@ -864,7 +868,7 @@ public class PlaylistController : ControllerBase else if (globalMapping.TargetType == "external") { isLocal = false; - externalProvider = NormalizeExternalProviderForDisplay(globalMapping.ExternalProvider); + externalProvider = ResolvePreferredExternalProvider(globalMapping); isManualMapping = true; manualMappingType = "external"; manualMappingId = globalMapping.ExternalId; @@ -1766,6 +1770,33 @@ public class PlaylistController : ControllerBase return trimmed; } + private string? ResolvePreferredExternalProvider(SpotifyTrackMapping mapping) + { + var preferredProvider = GetCurrentMusicServiceProvider(); + if (mapping.TryGetExternalTarget(preferredProvider, out var provider, out _)) + { + return NormalizeExternalProviderForDisplay(provider); + } + + return NormalizeExternalProviderForDisplay(mapping.ExternalProvider); + } + + private string? GetCurrentMusicServiceProvider() + { + var backendType = _configuration.GetValue("Backend:Type"); + var musicService = backendType == BackendType.Jellyfin + ? _configuration.GetValue("Jellyfin:MusicService") + : _configuration.GetValue("Subsonic:MusicService"); + + return musicService switch + { + MusicService.Deezer => "deezer", + MusicService.Qobuz => "qobuz", + MusicService.SquidWTF => "squidwtf", + _ => null + }; + } + /// /// Rebuild all playlists from scratch (clear cache, fetch fresh data, re-match). /// This is a manual bulk action across all playlists - used by "Rebuild All Remote" button. diff --git a/allstarr/Controllers/ScrobblingAdminController.cs b/allstarr/Controllers/ScrobblingAdminController.cs index 2392f61..adcf4f6 100644 --- a/allstarr/Controllers/ScrobblingAdminController.cs +++ b/allstarr/Controllers/ScrobblingAdminController.cs @@ -59,8 +59,9 @@ public class ScrobblingAdminController : ControllerBase HasApiKey = hasApiCredentials, HasSessionKey = !string.IsNullOrEmpty(_settings.LastFm.SessionKey), Username = _settings.LastFm.Username, - UsingHardcodedCredentials = hasApiCredentials && - _settings.LastFm.ApiKey == LastFmSettings.DefaultApiKey + UsingHardcodedCredentials = LastFmSettings.IsLegacyJellyfinPluginApiKey(_settings.LastFm.ApiKey), + RequiresOwnApiAccount = hasApiCredentials && + LastFmSettings.IsLegacyJellyfinPluginApiKey(_settings.LastFm.ApiKey) }, ListenBrainz = new { @@ -73,7 +74,7 @@ public class ScrobblingAdminController : ControllerBase /// /// Authenticate with Last.fm using credentials from .env file. - /// Uses hardcoded API credentials from Jellyfin Last.fm plugin for convenience. + /// Requires your own Last.fm API application credentials in .env. /// [HttpPost("lastfm/authenticate")] public async Task AuthenticateLastFm() @@ -87,10 +88,23 @@ public class ScrobblingAdminController : ControllerBase return BadRequest(new { error = "Username and password must be set in .env file (SCROBBLING_LASTFM_USERNAME and SCROBBLING_LASTFM_PASSWORD)" }); } - // Check if API credentials are available + if (LastFmSettings.IsLegacyJellyfinPluginApiKey(_settings.LastFm.ApiKey)) + { + return BadRequest(new + { + error = "The built-in Jellyfin Last.fm API key is suspended by Last.fm. " + + "Create your own application at https://www.last.fm/api/account/create, " + + "set SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET, then authenticate again." + }); + } + if (string.IsNullOrEmpty(_settings.LastFm.ApiKey) || string.IsNullOrEmpty(_settings.LastFm.SharedSecret)) { - return BadRequest(new { error = "Last.fm API credentials not configured. This should not happen - please report this bug." }); + return BadRequest(new + { + error = "Last.fm API credentials are required. Create an application at https://www.last.fm/api/account/create " + + "and set SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET in your .env file." + }); } try diff --git a/allstarr/Controllers/SpotifyAdminController.cs b/allstarr/Controllers/SpotifyAdminController.cs index 3b892a2..eaa828a 100644 --- a/allstarr/Controllers/SpotifyAdminController.cs +++ b/allstarr/Controllers/SpotifyAdminController.cs @@ -560,14 +560,30 @@ public class SpotifyAdminController : ControllerBase /// Deletes a Spotify track mapping /// [HttpDelete("spotify/mappings/{spotifyId}")] - public async Task DeleteSpotifyMapping(string spotifyId) + public async Task DeleteSpotifyMapping( + string spotifyId, + [FromQuery] string? provider = null) { try { - var success = await _mappingService.DeleteMappingAsync(spotifyId); + var success = string.IsNullOrWhiteSpace(provider) + ? await _mappingService.DeleteMappingAsync(spotifyId) + : await _mappingService.RemoveExternalProviderAsync(spotifyId, provider); + if (success) { - _logger.LogInformation("Deleted mapping for {SpotifyId}", spotifyId); + if (string.IsNullOrWhiteSpace(provider)) + { + _logger.LogInformation("Deleted mapping for {SpotifyId}", spotifyId); + } + else + { + _logger.LogInformation( + "Removed provider {Provider} from mapping for {SpotifyId}", + provider, + spotifyId); + } + return Ok(new { success = true }); } diff --git a/allstarr/Controllers/SubSonicController.cs b/allstarr/Controllers/SubSonicController.cs index cc8b364..1504b6e 100644 --- a/allstarr/Controllers/SubSonicController.cs +++ b/allstarr/Controllers/SubSonicController.cs @@ -173,8 +173,35 @@ public class SubsonicController : ControllerBase } catch (Exception ex) { + if (HttpContext.RequestAborted.IsCancellationRequested && ex is OperationCanceledException) + { + _logger.LogInformation("Client aborted external Subsonic stream request for {Id}", id); + return StatusCode(499); + } + + if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue) + { + var statusCode = httpRequestException.StatusCode == System.Net.HttpStatusCode.NotFound ? 404 : 503; + _logger.LogError(ex, "Failed to stream external Subsonic item {Id}: responding {StatusCode}; upstream returned {UpstreamStatus}", + id, statusCode, (int)httpRequestException.StatusCode.Value); + return StatusCode(statusCode, new { error = statusCode == 404 ? "External track not found" : "External provider unavailable" }); + } + + if (ex is TimeoutException || ex is TaskCanceledException) + { + _logger.LogError(ex, "Timed out streaming external Subsonic item {Id}", id); + return StatusCode(504, new { error = "External provider timed out" }); + } + + if (ex is InvalidOperationException invalidOperationException && + invalidOperationException.Message.Contains("endpoints", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError(ex, "No healthy endpoints available for external Subsonic item {Id}", id); + return StatusCode(503, new { error = "External provider has no healthy endpoints" }); + } + _logger.LogError(ex, "Failed to stream external Subsonic item {Id}", id); - return StatusCode(500, new { error = "Failed to stream" }); + return StatusCode(502, new { error = "External stream failed" }); } } diff --git a/allstarr/Models/Domain/Song.cs b/allstarr/Models/Domain/Song.cs index c77fc29..8070561 100644 --- a/allstarr/Models/Domain/Song.cs +++ b/allstarr/Models/Domain/Song.cs @@ -107,7 +107,7 @@ public class Song /// /// Deezer explicit content lyrics value - /// 0 = Naturally clean, 1 = Explicit, 2 = Not applicable, 3 = Clean/edited version, 6/7 = Unknown + /// 0 = Naturally clean, 1 = Explicit, 2 = Unknown, 3 = Clean/edited version, 6/7 = No advice /// public int? ExplicitContentLyrics { get; set; } diff --git a/allstarr/Models/Settings/ScrobblingSettings.cs b/allstarr/Models/Settings/ScrobblingSettings.cs index 5929264..5ba46f7 100644 --- a/allstarr/Models/Settings/ScrobblingSettings.cs +++ b/allstarr/Models/Settings/ScrobblingSettings.cs @@ -40,13 +40,22 @@ public class ScrobblingSettings /// public class LastFmSettings { - // These defaults match the Jellyfin Last.fm plugin credentials. - // Stored base64-encoded to avoid plain-text source exposure. - private const string DefaultApiKeyBase64 = "Y2IzYmRjZDQxNWZjYjQwY2Q1NzJiMTM3YjJiMjU1ZjU="; - private const string DefaultSharedSecretBase64 = "M2EwOGY5ZmFkNmRkYzRjMzViMGRjZTAwNjJjZWNiNWU="; + // Legacy Jellyfin Last.fm plugin credentials (suspended by Last.fm — do not use). + private const string LegacyJellyfinPluginApiKeyBase64 = "Y2IzYmRjZDQxNWZjYjQwY2Q1NzJiMTM3YjJiMjU1ZjU="; + private const string LegacyJellyfinPluginSharedSecretBase64 = "M2EwOGY5ZmFkNmRkYzRjMzViMGRjZTAwNjJjZWNiNWU="; - public static string DefaultApiKey => DecodeBase64(DefaultApiKeyBase64); - public static string DefaultSharedSecret => DecodeBase64(DefaultSharedSecretBase64); + public static string LegacyJellyfinPluginApiKey => DecodeBase64(LegacyJellyfinPluginApiKeyBase64); + public static string LegacyJellyfinPluginSharedSecret => DecodeBase64(LegacyJellyfinPluginSharedSecretBase64); + + [Obsolete("Use LegacyJellyfinPluginApiKey. The shared Jellyfin plugin key is suspended by Last.fm.")] + public static string DefaultApiKey => LegacyJellyfinPluginApiKey; + + [Obsolete("Use LegacyJellyfinPluginSharedSecret.")] + public static string DefaultSharedSecret => LegacyJellyfinPluginSharedSecret; + + public static bool IsLegacyJellyfinPluginApiKey(string? apiKey) => + !string.IsNullOrEmpty(apiKey) && + string.Equals(apiKey, LegacyJellyfinPluginApiKey, StringComparison.OrdinalIgnoreCase); /// /// Whether Last.fm scrobbling is enabled. @@ -54,18 +63,15 @@ public class LastFmSettings public bool Enabled { get; set; } /// - /// Last.fm API key (32-character hex string). - /// Uses hardcoded credentials from Jellyfin Last.fm plugin for convenience. - /// Users can override by setting SCROBBLING_LASTFM_API_KEY in .env + /// Last.fm API key (32-character hex string). Required when Last.fm is enabled. + /// Create an application at https://www.last.fm/api/account/create /// - public string ApiKey { get; set; } = DefaultApiKey; + public string ApiKey { get; set; } = string.Empty; /// - /// Last.fm shared secret (32-character hex string). - /// Uses hardcoded credentials from Jellyfin Last.fm plugin for convenience. - /// Users can override by setting SCROBBLING_LASTFM_SHARED_SECRET in .env + /// Last.fm shared secret (32-character hex string). Required when Last.fm is enabled. /// - public string SharedSecret { get; set; } = DefaultSharedSecret; + public string SharedSecret { get; set; } = string.Empty; /// /// Last.fm session key (obtained via Mobile Authentication). diff --git a/allstarr/Models/Spotify/SpotifyTrackMapping.cs b/allstarr/Models/Spotify/SpotifyTrackMapping.cs index 6cf0b56..620be03 100644 --- a/allstarr/Models/Spotify/SpotifyTrackMapping.cs +++ b/allstarr/Models/Spotify/SpotifyTrackMapping.cs @@ -30,6 +30,13 @@ public class SpotifyTrackMapping /// External provider track ID (if TargetType is "external") /// public string? ExternalId { get; set; } + + /// + /// Multi-provider external mappings for this Spotify track. + /// Keeps additional external provider IDs so rematch/rebuild can add + /// provider-specific mappings without replacing existing ones. + /// + public List ExternalMappings { get; set; } = new(); /// /// Track metadata for display purposes @@ -79,6 +86,55 @@ public class SpotifyTrackMapping return false; } + + /// + /// Resolves the best external mapping target, preferring the requested provider when available. + /// + public bool TryGetExternalTarget(string? preferredProvider, out string provider, out string externalId) + { + provider = string.Empty; + externalId = string.Empty; + + if (!string.IsNullOrWhiteSpace(preferredProvider)) + { + var preferred = ExternalMappings.FirstOrDefault(m => + string.Equals(m.Provider, preferredProvider, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(m.ExternalId)); + if (preferred != null) + { + provider = preferred.Provider; + externalId = preferred.ExternalId; + return true; + } + } + + var first = ExternalMappings.FirstOrDefault(m => + !string.IsNullOrWhiteSpace(m.Provider) && !string.IsNullOrWhiteSpace(m.ExternalId)); + if (first != null) + { + provider = first.Provider; + externalId = first.ExternalId; + return true; + } + + if (!string.IsNullOrWhiteSpace(ExternalProvider) && !string.IsNullOrWhiteSpace(ExternalId)) + { + provider = ExternalProvider; + externalId = ExternalId; + return true; + } + + return false; + } +} + +public class ExternalTrackMapping +{ + public required string Provider { get; set; } + public required string ExternalId { get; set; } + public string Source { get; set; } = "auto"; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; set; } } /// diff --git a/allstarr/Program.cs b/allstarr/Program.cs index c53cf49..4a1cc9d 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -12,17 +12,14 @@ using allstarr.Services.Lyrics; using allstarr.Services.Scrobbling; using allstarr.Middleware; using allstarr.Filters; +using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Http; using System.Net; +using System.IO; var builder = WebApplication.CreateBuilder(args); RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out); -// Discover SquidWTF API and streaming endpoints from uptime feeds. -var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync(); -var squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls; -var squidWtfStreamingUrls = squidWtfEndpointCatalog.StreamingUrls; - // Configure forwarded headers for reverse proxy support (nginx, etc.) // Trust should be explicit: set ForwardedHeaders__KnownProxies and/or // ForwardedHeaders__KnownNetworks (comma-separated) in deployment config. @@ -161,6 +158,7 @@ builder.Services.AddControllers() }); builder.Services.AddHttpClient(); +builder.Services.AddHttpClient("SquidWTF"); builder.Services.ConfigureAll(options => { options.HttpMessageHandlerBuilderActions.Add(builder => @@ -198,6 +196,11 @@ builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName) builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddHttpContextAccessor(); +var dataProtectionKeysDirectory = new DirectoryInfo("/app/cache/data-protection"); +dataProtectionKeysDirectory.Create(); +builder.Services.AddDataProtection() + .PersistKeysToFileSystem(dataProtectionKeysDirectory) + .SetApplicationName("allstarr-admin"); // Exception handling builder.Services.AddExceptionHandler(); @@ -526,6 +529,13 @@ else enableExternalPlaylists = builder.Configuration.GetValue("Subsonic:EnableExternalPlaylists", true); } +// Discover SquidWTF endpoints only when SquidWTF is selected as primary music service. +var squidWtfEndpointCatalog = musicService == MusicService.SquidWTF + ? await SquidWtfEndpointDiscovery.DiscoverAsync() + : new SquidWtfEndpointCatalog(new List(), new List()); +var squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls; +var squidWtfStreamingUrls = squidWtfEndpointCatalog.StreamingUrls; + // Business services - shared across backends builder.Services.AddSingleton(squidWtfEndpointCatalog); builder.Services.AddSingleton(); @@ -633,14 +643,17 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => - new SquidWTFStartupValidator( - sp.GetRequiredService>(), - sp.GetRequiredService().CreateClient(), - squidWtfApiUrls, - squidWtfStreamingUrls, - sp.GetRequiredService(), - sp.GetRequiredService>())); +if (musicService == MusicService.SquidWTF) +{ + builder.Services.AddSingleton(sp => + new SquidWTFStartupValidator( + sp.GetRequiredService>(), + sp.GetRequiredService().CreateClient("SquidWTF"), + squidWtfApiUrls, + squidWtfStreamingUrls, + sp.GetRequiredService(), + sp.GetRequiredService>())); +} builder.Services.AddSingleton(); // Register orchestrator as hosted service @@ -712,6 +725,7 @@ builder.Services.AddSingleton(); // Register Lyrics Orchestrator (manages priority-based lyrics fetching) builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Register Spotify mapping service (global Spotify ID → Local/External mappings) builder.Services.AddSingleton(); @@ -945,7 +959,7 @@ app.UseMiddleware(); // Request logging middleware (when DEBUG_LOG_ALL_REQUESTS=true) app.UseMiddleware(); -app.UseExceptionHandler(_ => { }); // Global exception handler +app.UseExceptionHandler(); // Use registered GlobalExceptionHandler // Enable response compression EARLY in the pipeline app.UseResponseCompression(); diff --git a/allstarr/Services/Admin/AdminAuthSessionService.cs b/allstarr/Services/Admin/AdminAuthSessionService.cs index 25b565d..2b60f69 100644 --- a/allstarr/Services/Admin/AdminAuthSessionService.cs +++ b/allstarr/Services/Admin/AdminAuthSessionService.cs @@ -1,5 +1,8 @@ using System.Collections.Concurrent; using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging.Abstractions; namespace allstarr.Services.Admin; @@ -11,27 +14,83 @@ public sealed class AdminAuthSession public required bool IsAdministrator { get; init; } public required string JellyfinAccessToken { get; init; } public string? JellyfinServerId { get; init; } - public required DateTime ExpiresAtUtc { get; init; } + public bool IsPersistent { get; init; } + public required DateTime ExpiresAtUtc { get; set; } public DateTime LastSeenUtc { get; set; } } /// -/// In-memory authenticated admin sessions for the local Web UI. +/// Cookie-backed admin sessions for the local Web UI. +/// Session IDs stay in the browser cookie, while the authenticated Jellyfin +/// session details are protected and persisted on disk so brief app restarts +/// do not force a relogin. /// public class AdminAuthSessionService { public const string SessionCookieName = "allstarr_admin_session"; public const string HttpContextSessionItemKey = "__allstarr_admin_auth_session"; - private static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(12); + public static readonly TimeSpan DefaultSessionLifetime = TimeSpan.FromHours(12); + public static readonly TimeSpan PersistentSessionLifetime = TimeSpan.FromDays(30); + private readonly ConcurrentDictionary _sessions = new(); + private readonly IDataProtector _protector; + private readonly ILogger _logger; + private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + private readonly object _persistLock = new(); + private readonly string _sessionStoreFilePath; + + public AdminAuthSessionService( + IDataProtectionProvider dataProtectionProvider, + ILogger logger) + : this( + dataProtectionProvider, + logger, + "/app/cache/admin-auth/sessions.protected") + { + } + + private AdminAuthSessionService( + IDataProtectionProvider dataProtectionProvider, + ILogger logger, + string sessionStoreFilePath) + { + _protector = dataProtectionProvider.CreateProtector("allstarr.admin.auth.sessions.v1"); + _logger = logger; + _sessionStoreFilePath = sessionStoreFilePath; + + var directory = Path.GetDirectoryName(_sessionStoreFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + LoadSessionsFromDisk(); + } + + public AdminAuthSessionService(ILogger logger) + : this( + CreateFallbackDataProtectionProvider(), + logger, + Path.Combine(Path.GetTempPath(), "allstarr-admin-auth", "sessions.protected")) + { + } + + public AdminAuthSessionService() + : this( + CreateFallbackDataProtectionProvider(), + NullLogger.Instance, + Path.Combine(Path.GetTempPath(), "allstarr-admin-auth", "sessions.protected")) + { + } public AdminAuthSession CreateSession( string userId, string userName, bool isAdministrator, string jellyfinAccessToken, - string? jellyfinServerId) + string? jellyfinServerId, + bool isPersistent = false) { RemoveExpiredSessions(); @@ -44,11 +103,13 @@ public class AdminAuthSessionService IsAdministrator = isAdministrator, JellyfinAccessToken = jellyfinAccessToken, JellyfinServerId = jellyfinServerId, - ExpiresAtUtc = now.Add(SessionLifetime), + IsPersistent = isPersistent, + ExpiresAtUtc = now.Add(isPersistent ? PersistentSessionLifetime : DefaultSessionLifetime), LastSeenUtc = now }; _sessions[session.SessionId] = session; + PersistSessions(); return session; } @@ -69,6 +130,7 @@ public class AdminAuthSessionService if (existing.ExpiresAtUtc <= DateTime.UtcNow) { _sessions.TryRemove(sessionId, out _); + PersistSessions(); return false; } @@ -84,17 +146,117 @@ public class AdminAuthSessionService return; } - _sessions.TryRemove(sessionId, out _); + if (_sessions.TryRemove(sessionId, out _)) + { + PersistSessions(); + } } private void RemoveExpiredSessions() { var now = DateTime.UtcNow; + var removedAny = false; foreach (var kvp in _sessions) { - if (kvp.Value.ExpiresAtUtc <= now) + if (kvp.Value.ExpiresAtUtc <= now && + _sessions.TryRemove(kvp.Key, out _)) { - _sessions.TryRemove(kvp.Key, out _); + removedAny = true; + } + } + + if (removedAny) + { + PersistSessions(); + } + } + + private void LoadSessionsFromDisk() + { + try + { + if (!File.Exists(_sessionStoreFilePath)) + { + return; + } + + var protectedPayload = File.ReadAllText(_sessionStoreFilePath); + if (string.IsNullOrWhiteSpace(protectedPayload)) + { + return; + } + + var json = _protector.Unprotect(protectedPayload); + var sessions = JsonSerializer.Deserialize>(json, _jsonOptions) + ?? []; + + var now = DateTime.UtcNow; + foreach (var persisted in sessions) + { + if (string.IsNullOrWhiteSpace(persisted.SessionId) || + string.IsNullOrWhiteSpace(persisted.UserId) || + string.IsNullOrWhiteSpace(persisted.UserName) || + string.IsNullOrWhiteSpace(persisted.JellyfinAccessToken) || + persisted.ExpiresAtUtc <= now) + { + continue; + } + + _sessions[persisted.SessionId] = new AdminAuthSession + { + SessionId = persisted.SessionId, + UserId = persisted.UserId, + UserName = persisted.UserName, + IsAdministrator = persisted.IsAdministrator, + JellyfinAccessToken = persisted.JellyfinAccessToken, + JellyfinServerId = persisted.JellyfinServerId, + IsPersistent = persisted.IsPersistent, + ExpiresAtUtc = persisted.ExpiresAtUtc, + LastSeenUtc = persisted.LastSeenUtc + }; + } + + if (_sessions.Count > 0) + { + _logger.LogInformation("Loaded {Count} persisted admin auth sessions", _sessions.Count); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load persisted admin auth sessions; starting with an empty session store"); + _sessions.Clear(); + } + } + + private void PersistSessions() + { + lock (_persistLock) + { + try + { + var activeSessions = _sessions.Values + .Where(session => session.ExpiresAtUtc > DateTime.UtcNow) + .Select(session => new PersistedAdminAuthSession + { + SessionId = session.SessionId, + UserId = session.UserId, + UserName = session.UserName, + IsAdministrator = session.IsAdministrator, + JellyfinAccessToken = session.JellyfinAccessToken, + JellyfinServerId = session.JellyfinServerId, + IsPersistent = session.IsPersistent, + ExpiresAtUtc = session.ExpiresAtUtc, + LastSeenUtc = session.LastSeenUtc + }) + .ToList(); + + var json = JsonSerializer.Serialize(activeSessions, _jsonOptions); + var protectedPayload = _protector.Protect(json); + File.WriteAllText(_sessionStoreFilePath, protectedPayload); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to persist admin auth sessions"); } } } @@ -105,4 +267,27 @@ public class AdminAuthSessionService RandomNumberGenerator.Fill(bytes); return Convert.ToHexString(bytes).ToLowerInvariant(); } + + private static IDataProtectionProvider CreateFallbackDataProtectionProvider() + { + var keysDirectory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "allstarr-admin-auth-keys")); + keysDirectory.Create(); + return DataProtectionProvider.Create(keysDirectory, configuration => + { + configuration.SetApplicationName("allstarr-admin"); + }); + } + + private sealed class PersistedAdminAuthSession + { + public required string SessionId { get; init; } + public required string UserId { get; init; } + public required string UserName { get; init; } + public required bool IsAdministrator { get; init; } + public required string JellyfinAccessToken { get; init; } + public string? JellyfinServerId { get; init; } + public required bool IsPersistent { get; init; } + public required DateTime ExpiresAtUtc { get; init; } + public required DateTime LastSeenUtc { get; init; } + } } diff --git a/allstarr/Services/Common/ExplicitContentFilter.cs b/allstarr/Services/Common/ExplicitContentFilter.cs index b54df99..0752965 100644 --- a/allstarr/Services/Common/ExplicitContentFilter.cs +++ b/allstarr/Services/Common/ExplicitContentFilter.cs @@ -27,7 +27,7 @@ public static class ExplicitContentFilter ExplicitFilter.All => true, // ExplicitOnly: Exclude clean/edited versions (value 3) - // Include: 0 (naturally clean), 1 (explicit), 2 (not applicable), 6/7 (unknown) + // Include: 0 (naturally clean), 1 (explicit), 2 (unknown), 6/7 (no advice) ExplicitFilter.ExplicitOnly => song.ExplicitContentLyrics != 3, // CleanOnly: Only show clean content diff --git a/allstarr/Services/Common/InjectedPlaylistItemHelper.cs b/allstarr/Services/Common/InjectedPlaylistItemHelper.cs index eaf0dc9..c651e5b 100644 --- a/allstarr/Services/Common/InjectedPlaylistItemHelper.cs +++ b/allstarr/Services/Common/InjectedPlaylistItemHelper.cs @@ -19,6 +19,11 @@ public static class InjectedPlaylistItemHelper return items.Any(LooksLikeLocalItemMissingGenreMetadata); } + public static bool ContainsLegacyExternalSourceLabels(IEnumerable> items) + { + return items.Any(LooksLikeLegacyExternalSourceLabeledItem); + } + public static bool LooksLikeSyntheticLocalItem(IReadOnlyDictionary item) { var id = GetString(item, "Id"); @@ -42,11 +47,31 @@ public static class InjectedPlaylistItemHelper return !HasNonNullValue(item, "Genres") || !HasNonNullValue(item, "GenreItems"); } + public static bool LooksLikeLegacyExternalSourceLabeledItem(IReadOnlyDictionary item) + { + var id = GetString(item, "Id"); + if (!NeedsProviderSpecificSourceLabel(id)) + { + return false; + } + + var name = GetString(item, "Name"); + return name?.EndsWith(" [S]", StringComparison.Ordinal) == true || + name?.EndsWith(" [S] [E]", StringComparison.Ordinal) == true; + } + private static bool IsExternalItemId(string itemId) { return itemId.StartsWith("ext-", StringComparison.OrdinalIgnoreCase); } + private static bool NeedsProviderSpecificSourceLabel(string? itemId) + { + return !string.IsNullOrWhiteSpace(itemId) && + (itemId.StartsWith("ext-deezer-", StringComparison.OrdinalIgnoreCase) || + itemId.StartsWith("ext-qobuz-", StringComparison.OrdinalIgnoreCase)); + } + private static bool HasNonNullValue(IReadOnlyDictionary item, string key) { if (!item.TryGetValue(key, out var value) || value == null) diff --git a/allstarr/Services/Common/RoundRobinFallbackHelper.cs b/allstarr/Services/Common/RoundRobinFallbackHelper.cs index 0af8ade..0802f6d 100644 --- a/allstarr/Services/Common/RoundRobinFallbackHelper.cs +++ b/allstarr/Services/Common/RoundRobinFallbackHelper.cs @@ -26,17 +26,17 @@ public class RoundRobinFallbackHelper _apiUrls = apiUrls ?? throw new ArgumentNullException(nameof(apiUrls)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _serviceName = serviceName ?? "Service"; - - if (_apiUrls.Count == 0) - { - throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls)); - } - + // Create a dedicated HttpClient for health checks with short timeout _healthCheckClient = new HttpClient { Timeout = TimeSpan.FromSeconds(3) // Quick health check timeout }; + + if (_apiUrls.Count == 0) + { + _logger.LogWarning("{Service} initialized with zero endpoints; external provider is currently unavailable", _serviceName); + } } /// @@ -124,6 +124,11 @@ public class RoundRobinFallbackHelper /// private async Task> GetHealthyEndpointsAsync() { + if (_apiUrls.Count == 0) + { + return new List(); + } + var healthCheckTasks = _apiUrls.Select(async url => new { Url = url, @@ -212,6 +217,11 @@ public class RoundRobinFallbackHelper /// public async Task TryWithFallbackAsync(Func> action) { + if (_apiUrls.Count == 0) + { + throw new InvalidOperationException($"No {_serviceName} endpoints are configured"); + } + // Get healthy endpoints first (with caching to avoid excessive checks) var healthyEndpoints = await GetHealthyEndpointsAsync(); @@ -254,72 +264,78 @@ public class RoundRobinFallbackHelper } /// - /// Races all endpoints in parallel and returns the first successful result. - /// Cancels remaining requests once one succeeds. Great for latency-sensitive operations. + /// Races the top N fastest endpoints in parallel and returns the first successful result. + /// Cancels remaining requests once one succeeds. Used for latency-sensitive operations like search. /// - /// - /// Races the top N fastest endpoints in parallel and returns the first successful result. - /// Cancels remaining requests once one succeeds. Used for latency-sensitive operations like search. - /// - public async Task RaceTopEndpointsAsync(int topN, Func> action, CancellationToken cancellationToken = default) + public async Task RaceTopEndpointsAsync(int topN, Func> action, CancellationToken cancellationToken = default) + { + if (_apiUrls.Count == 0) { - if (_apiUrls.Count == 1 || topN <= 1) - { - // No point racing with one endpoint - use fallback instead - return await TryWithFallbackAsync(baseUrl => action(baseUrl, cancellationToken)); - } - - // Get top N fastest healthy endpoints - var endpointsToRace = _apiUrls.Take(Math.Min(topN, _apiUrls.Count)).ToList(); - - if (endpointsToRace.Count == 1) - { - return await action(endpointsToRace[0], cancellationToken); - } - - using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var tasks = new List>(); - - // Start racing the top N endpoints - foreach (var baseUrl in endpointsToRace) - { - var task = Task.Run(async () => - { - try - { - _logger.LogDebug("🏁 Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl); - var result = await action(baseUrl, raceCts.Token); - return (result, baseUrl, true); - } - catch (Exception ex) - { - _logger.LogDebug("{Service} race failed for endpoint {Endpoint}: {Message}", _serviceName, baseUrl, ex.Message); - return (default(T)!, baseUrl, false); - } - }, raceCts.Token); - - tasks.Add(task); - } - - // Wait for first successful completion - while (tasks.Count > 0) - { - var completedTask = await Task.WhenAny(tasks); - var (result, endpoint, success) = await completedTask; - - if (success) - { - _logger.LogDebug("🏆 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint); - raceCts.Cancel(); // Cancel all other requests - return result; - } - - tasks.Remove(completedTask); - } - - throw new Exception($"All {topN} {_serviceName} endpoints failed in race"); + throw new InvalidOperationException($"No {_serviceName} endpoints are configured"); } + if (_apiUrls.Count == 1 || topN <= 1) + { + // No point racing with one endpoint - use fallback instead + return await TryWithFallbackAsync(baseUrl => action(baseUrl, cancellationToken)); + } + + // Get top N fastest healthy endpoints + var endpointsToRace = _apiUrls.Take(Math.Min(topN, _apiUrls.Count)).ToList(); + + if (endpointsToRace.Count == 1) + { + return await action(endpointsToRace[0], cancellationToken); + } + + _logger.LogInformation("Racing {Count} {Service} endpoints: {Endpoints}", + endpointsToRace.Count, _serviceName, string.Join(", ", endpointsToRace)); + + using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var tasks = new List>(); + + // Start racing the top N endpoints + foreach (var baseUrl in endpointsToRace) + { + var task = Task.Run(async () => + { + try + { + _logger.LogDebug("🏁 Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl); + var result = await action(baseUrl, raceCts.Token); + return (result, baseUrl, true); + } + catch (Exception ex) + { + _logger.LogDebug("{Service} race failed for endpoint {Endpoint}: {Message}", _serviceName, baseUrl, ex.Message); + return (default(T)!, baseUrl, false); + } + }, raceCts.Token); + + tasks.Add(task); + } + + // Wait for first successful completion + while (tasks.Count > 0) + { + var completedTask = await Task.WhenAny(tasks); + var (result, endpoint, success) = await completedTask; + + if (success) + { + _logger.LogInformation("{Service} race won by {Endpoint}", _serviceName, endpoint); + raceCts.Cancel(); // Cancel all other requests + return result; + } + + tasks.Remove(completedTask); + } + + _logger.LogError("All raced {Service} endpoints failed: {Endpoints}", + _serviceName, string.Join(", ", endpointsToRace)); + throw new Exception($"All {topN} {_serviceName} endpoints failed in race"); + } + /// /// Tries the request with the next provider in round-robin, then falls back to others on failure. /// Performs quick health checks first to avoid wasting time on dead endpoints. @@ -327,6 +343,12 @@ public class RoundRobinFallbackHelper /// public async Task TryWithFallbackAsync(Func> action, T defaultValue) { + if (_apiUrls.Count == 0) + { + _logger.LogWarning("No {Service} endpoints are configured, returning default value", _serviceName); + return defaultValue; + } + // Get healthy endpoints first (with caching to avoid excessive checks) var healthyEndpoints = await GetHealthyEndpointsAsync(); @@ -383,6 +405,12 @@ public class RoundRobinFallbackHelper throw new ArgumentNullException(nameof(isAcceptableResult)); } + if (_apiUrls.Count == 0) + { + _logger.LogWarning("No {Service} endpoints are configured, returning default value", _serviceName); + return defaultValue; + } + // Get healthy endpoints first (with caching to avoid excessive checks) var healthyEndpoints = await GetHealthyEndpointsAsync(); @@ -483,6 +511,12 @@ public class RoundRobinFallbackHelper return new List(); } + if (_apiUrls.Count == 0) + { + _logger.LogWarning("No {Service} endpoints are configured, skipping parallel processing", _serviceName); + return new List(); + } + var results = new List(); var resultsLock = new object(); var itemQueue = new Queue(items); diff --git a/allstarr/Services/Common/StreamQualityHelper.cs b/allstarr/Services/Common/StreamQualityHelper.cs index 2b5f810..ee0b7f2 100644 --- a/allstarr/Services/Common/StreamQualityHelper.cs +++ b/allstarr/Services/Common/StreamQualityHelper.cs @@ -62,7 +62,8 @@ public static class StreamQualityHelper return StreamQuality.Original; } - return MapBitRateToQuality((int)(maxBitrate / 1000)); + // MaxStreamingBitrate is reported in bits per second. + return MapBitRateToQuality((int)maxBitrate); } // Check for audioBitRate (lowercase variant used by some clients) diff --git a/allstarr/Services/Deezer/DeezerDownloadService.cs b/allstarr/Services/Deezer/DeezerDownloadService.cs index 531da45..71619a7 100644 --- a/allstarr/Services/Deezer/DeezerDownloadService.cs +++ b/allstarr/Services/Deezer/DeezerDownloadService.cs @@ -181,10 +181,10 @@ public class DeezerDownloadService : BaseDownloadService _ => ".mp3" }; - // Write to transcoded cache directory: {downloads}/transcoded/Artist/Album/song.ext + // Write to transcoded cache directory: {DownloadPath}/transcoded/Artist/Album/song.ext // These files are cleaned up by CacheCleanupService based on CACHE_TRANSCODE_MINUTES TTL var artistForPath = song.AlbumArtist ?? song.Artist; - var basePath = Path.Combine("downloads", "transcoded"); + var basePath = Path.Combine(DownloadPath, "transcoded"); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "deezer", trackId); // Create directories if they don't exist diff --git a/allstarr/Services/Deezer/DeezerMetadataService.cs b/allstarr/Services/Deezer/DeezerMetadataService.cs index cb2085c..cfce1a1 100644 --- a/allstarr/Services/Deezer/DeezerMetadataService.cs +++ b/allstarr/Services/Deezer/DeezerMetadataService.cs @@ -17,24 +17,66 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService private readonly HttpClient _httpClient; private readonly SubsonicSettings _settings; private readonly GenreEnrichmentService? _genreEnrichment; + private readonly SemaphoreSlim _requestLock = new(1, 1); + private readonly int _minRequestIntervalMs; + private DateTime _lastRequestTime = DateTime.MinValue; private const string BaseUrl = "https://api.deezer.com"; + private const string DeezerApiHost = "api.deezer.com"; + private const int MetadataPageSize = 100; public DeezerMetadataService( IHttpClientFactory httpClientFactory, IOptions settings, - GenreEnrichmentService? genreEnrichment = null) + GenreEnrichmentService? genreEnrichment = null, + IOptions? deezerSettings = null) { _httpClient = httpClientFactory.CreateClient(); _settings = settings.Value; _genreEnrichment = genreEnrichment; + _minRequestIntervalMs = Math.Max( + 0, + deezerSettings?.Value.MinRequestIntervalMs ?? new DeezerSettings().MinRequestIntervalMs); } public async Task> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var normalizedLimit = NormalizeSearchLimit(limit); + var allSongs = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var queryVariant in BuildSearchQueryVariants(query)) + { + var songs = await SearchSongsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken); + foreach (var song in songs) + { + var key = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id; + if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key)) + { + continue; + } + + allSongs.Add(song); + if (allSongs.Count >= normalizedLimit) + { + break; + } + } + + if (allSongs.Count >= normalizedLimit) + { + break; + } + } + + return allSongs; + } + + private async Task> SearchSongsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken) { try { - var url = $"{BaseUrl}/search/track?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var url = BuildRankedSearchUrl("track", query, limit); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); @@ -69,18 +111,75 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService return null; } - var results = await SearchSongsAsync(isrc, limit: 5, cancellationToken); - return results.FirstOrDefault(song => - !string.IsNullOrWhiteSpace(song.Isrc) && - song.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase)); + try + { + var normalizedIsrc = isrc.Trim(); + var url = $"{BaseUrl}/track/isrc:{Uri.EscapeDataString(normalizedIsrc)}"; + var response = await GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var result = JsonDocument.Parse(json); + if (result.RootElement.TryGetProperty("error", out _) || + !result.RootElement.TryGetProperty("id", out _)) + { + return null; + } + + var song = ParseDeezerTrackFull(result.RootElement); + return string.Equals(song.Isrc, normalizedIsrc, StringComparison.OrdinalIgnoreCase) + ? song + : null; + } + catch + { + return null; + } } public async Task> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var normalizedLimit = NormalizeSearchLimit(limit); + var allAlbums = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var queryVariant in BuildSearchQueryVariants(query)) + { + var albums = await SearchAlbumsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken); + foreach (var album in albums) + { + var key = !string.IsNullOrWhiteSpace(album.ExternalId) ? album.ExternalId : album.Id; + if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key)) + { + continue; + } + + allAlbums.Add(album); + if (allAlbums.Count >= normalizedLimit) + { + break; + } + } + + if (allAlbums.Count >= normalizedLimit) + { + break; + } + } + + return allAlbums; + } + + private async Task> SearchAlbumsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken) { try { - var url = $"{BaseUrl}/search/album?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var url = BuildRankedSearchUrl("album", query, limit); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); @@ -105,11 +204,44 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService } public async Task> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var normalizedLimit = NormalizeSearchLimit(limit); + var allArtists = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var queryVariant in BuildSearchQueryVariants(query)) + { + var artists = await SearchArtistsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken); + foreach (var artist in artists) + { + var key = !string.IsNullOrWhiteSpace(artist.ExternalId) ? artist.ExternalId : artist.Id; + if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key)) + { + continue; + } + + allArtists.Add(artist); + if (allArtists.Count >= normalizedLimit) + { + break; + } + } + + if (allArtists.Count >= normalizedLimit) + { + break; + } + } + + return allArtists; + } + + private async Task> SearchArtistsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken) { try { - var url = $"{BaseUrl}/search/artist?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var url = BuildRankedSearchUrl("artist", query, limit); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); @@ -133,6 +265,44 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService } } + private static IReadOnlyList BuildSearchQueryVariants(string query) + { + var variants = new List(); + + AddQueryVariant(variants, query); + + if (query.Contains('&')) + { + AddQueryVariant(variants, query.Replace("&", " and ")); + } + + return variants; + } + + private static void AddQueryVariant(List variants, string candidate) + { + var normalized = System.Text.RegularExpressions.Regex.Replace(candidate, @"\s+", " ").Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return; + } + + if (!variants.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + { + variants.Add(normalized); + } + } + + private static int NormalizeSearchLimit(int limit) + { + return Math.Max(1, limit); + } + + private static string BuildRankedSearchUrl(string searchType, string query, int limit) + { + return $"{BaseUrl}/search/{searchType}?q={Uri.EscapeDataString(query)}&limit={limit}&order=RANKING"; + } + public async Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default) { var songsTask = songLimit > 0 @@ -157,10 +327,10 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return null; + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null; var url = $"{BaseUrl}/track/{externalId}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; @@ -180,7 +350,7 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService try { var albumUrl = $"{BaseUrl}/album/{albumId}"; - var albumResponse = await _httpClient.GetAsync(albumUrl, cancellationToken); + var albumResponse = await GetAsync(albumUrl, cancellationToken); if (albumResponse.IsSuccessStatusCode) { var albumJson = await albumResponse.Content.ReadAsStringAsync(cancellationToken); @@ -249,40 +419,67 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return null; + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null; var url = $"{BaseUrl}/album/{externalId}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; var json = await response.Content.ReadAsStringAsync(cancellationToken); - var albumElement = JsonDocument.Parse(json).RootElement; + using var albumDocument = JsonDocument.Parse(json); + var albumElement = albumDocument.RootElement; if (albumElement.TryGetProperty("error", out _)) return null; var album = ParseDeezerAlbum(albumElement); - // Get album songs + var trackIndex = 1; + var embeddedTrackCount = 0; + + void AddTrack(JsonElement track, List songs) + { + // Pass the album artist to ensure proper folder organization + var song = ParseDeezerTrack(track, trackIndex, album.Artist); + + // Ensure album metadata is set (tracks in album response may not have full album object) + song.Album = album.Title; + song.AlbumId = album.Id; + song.AlbumArtist = album.Artist; + + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) + { + songs.Add(song); + } + + trackIndex++; + } + + // Deezer album details embed the first page of tracks. if (albumElement.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("data", out var tracksData)) { - int trackIndex = 1; foreach (var track in tracksData.EnumerateArray()) { - // Pass the album artist to ensure proper folder organization - var song = ParseDeezerTrack(track, trackIndex, album.Artist); + embeddedTrackCount++; + AddTrack(track, album.Songs); + } + } - // Ensure album metadata is set (tracks in album response may not have full album object) - song.Album = album.Title; - song.AlbumId = album.Id; - song.AlbumArtist = album.Artist; + if (album.SongCount.HasValue && embeddedTrackCount < album.SongCount.Value) + { + var pagedSongs = new List(); + trackIndex = 1; - if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) - { - album.Songs.Add(song); - } - trackIndex++; + var pagedTrackCount = await ReadPagedDataAsync( + index => BuildMetadataPageUrl($"album/{Uri.EscapeDataString(externalId)}/tracks", index), + track => AddTrack(track, pagedSongs), + cancellationToken); + + if (pagedTrackCount > embeddedTrackCount) + { + album.Songs.Clear(); + album.Songs.AddRange(pagedSongs); } } @@ -291,10 +488,10 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return null; + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null; var url = $"{BaseUrl}/artist/{externalId}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; @@ -308,34 +505,23 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return new List(); - - var url = $"{BaseUrl}/artist/{externalId}/albums"; - var response = await _httpClient.GetAsync(url, cancellationToken); - - if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(cancellationToken); - var result = JsonDocument.Parse(json); + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return new List(); var albums = new List(); - if (result.RootElement.TryGetProperty("data", out var data)) - { - foreach (var album in data.EnumerateArray()) - { - albums.Add(ParseDeezerAlbum(album)); - } - } + await ReadPagedDataAsync( + index => BuildMetadataPageUrl($"artist/{Uri.EscapeDataString(externalId)}/albums", index), + album => albums.Add(ParseDeezerAlbum(album)), + cancellationToken); return albums; } public async Task> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return new List(); + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return new List(); var url = $"{BaseUrl}/artist/{externalId}/top?limit=50"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); @@ -393,6 +579,9 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService ? cover.GetString() : null, AlbumArtist = albumArtist, + Isrc = track.TryGetProperty("isrc", out var isrc) + ? isrc.GetString() + : null, IsLocal = false, ExternalProvider = "deezer", ExternalId = externalId, @@ -583,11 +772,44 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService } public async Task> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var normalizedLimit = NormalizeSearchLimit(limit); + var allPlaylists = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var queryVariant in BuildSearchQueryVariants(query)) + { + var playlists = await SearchPlaylistsSingleQueryAsync(queryVariant, normalizedLimit, cancellationToken); + foreach (var playlist in playlists) + { + var key = !string.IsNullOrWhiteSpace(playlist.ExternalId) ? playlist.ExternalId : playlist.Id; + if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key)) + { + continue; + } + + allPlaylists.Add(playlist); + if (allPlaylists.Count >= normalizedLimit) + { + break; + } + } + + if (allPlaylists.Count >= normalizedLimit) + { + break; + } + } + + return allPlaylists; + } + + private async Task> SearchPlaylistsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken) { try { - var url = $"{BaseUrl}/search/playlist?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var url = BuildRankedSearchUrl("playlist", query, limit); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); @@ -613,12 +835,12 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task GetPlaylistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return null; + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return null; try { var url = $"{BaseUrl}/playlist/{externalId}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; @@ -637,12 +859,12 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService public async Task> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - if (externalProvider != "deezer") return new List(); + if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return new List(); try { var url = $"{BaseUrl}/playlist/{externalId}"; - var response = await _httpClient.GetAsync(url, cancellationToken); + var response = await GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); @@ -657,28 +879,55 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService var playlistName = playlistElement.TryGetProperty("title", out var titleEl) ? titleEl.GetString() ?? "Unknown Playlist" : "Unknown Playlist"; + var trackIndex = 1; + var embeddedTrackCount = 0; + + void AddTrack(JsonElement track, List tracks) + { + // For playlists, use the track's own artist (not a single album artist) + var song = ParseDeezerTrack(track, trackIndex); + + // Override album name to be the playlist name + song.Album = playlistName; + + // Playlists should not have disc numbers - always set to null. + // This prevents Jellyfin from splitting the playlist into multiple "discs". + song.DiscNumber = null; + + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) + { + tracks.Add(song); + } + + trackIndex++; + } if (playlistElement.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("data", out var tracksData)) { - int trackIndex = 1; foreach (var track in tracksData.EnumerateArray()) { - // For playlists, use the track's own artist (not a single album artist) - var song = ParseDeezerTrack(track, trackIndex); + embeddedTrackCount++; + AddTrack(track, songs); + } + } - // Override album name to be the playlist name - song.Album = playlistName; + if (playlistElement.TryGetProperty("nb_tracks", out var trackCountElement) && + trackCountElement.TryGetInt32(out var trackCount) && + embeddedTrackCount < trackCount) + { + var pagedSongs = new List(); + trackIndex = 1; - // Playlists should not have disc numbers - always set to null - // This prevents Jellyfin from splitting the playlist into multiple "discs" - song.DiscNumber = null; + var pagedTrackCount = await ReadPagedDataAsync( + index => BuildMetadataPageUrl($"playlist/{Uri.EscapeDataString(externalId)}/tracks", index), + track => AddTrack(track, pagedSongs), + cancellationToken); - if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) - { - songs.Add(song); - } - trackIndex++; + if (pagedTrackCount > embeddedTrackCount) + { + songs.Clear(); + songs.AddRange(pagedSongs); } } @@ -690,6 +939,118 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService } } + private async Task ReadPagedDataAsync( + Func buildIndexedPageUrl, + Action addItem, + CancellationToken cancellationToken) + { + string? pageUrl = buildIndexedPageUrl(0); + var itemCount = 0; + var seenPageUrls = new HashSet(StringComparer.OrdinalIgnoreCase); + + while (IsOfficialDeezerApiUrl(pageUrl) && seenPageUrls.Add(pageUrl!)) + { + var response = await GetAsync(pageUrl!, cancellationToken); + if (!response.IsSuccessStatusCode) + { + break; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var result = JsonDocument.Parse(json); + if (result.RootElement.TryGetProperty("error", out _) || + !result.RootElement.TryGetProperty("data", out var data) || + data.ValueKind != JsonValueKind.Array) + { + break; + } + + var pageItemCount = 0; + foreach (var item in data.EnumerateArray()) + { + addItem(item); + itemCount++; + pageItemCount++; + } + + pageUrl = GetNextPageUrl(result.RootElement) ?? + GetIndexedNextPageUrl(result.RootElement, buildIndexedPageUrl, itemCount, pageItemCount); + } + + return itemCount; + } + + private async Task GetAsync(string url, CancellationToken cancellationToken) + { + await _requestLock.WaitAsync(cancellationToken); + try + { + if (_lastRequestTime != DateTime.MinValue && _minRequestIntervalMs > 0) + { + var elapsedMs = (DateTime.UtcNow - _lastRequestTime).TotalMilliseconds; + if (elapsedMs < _minRequestIntervalMs) + { + await Task.Delay((int)(_minRequestIntervalMs - elapsedMs), cancellationToken); + } + } + + _lastRequestTime = DateTime.UtcNow; + return await _httpClient.GetAsync(url, cancellationToken); + } + finally + { + _requestLock.Release(); + } + } + + private static string BuildMetadataPageUrl(string endpoint, int index) + { + return $"{BaseUrl}/{endpoint.TrimStart('/')}?index={index}&limit={MetadataPageSize}"; + } + + private static string? GetNextPageUrl(JsonElement result) + { + if (!result.TryGetProperty("next", out var next) || + next.ValueKind != JsonValueKind.String) + { + return null; + } + + var pageUrl = next.GetString(); + return IsOfficialDeezerApiUrl(pageUrl) ? pageUrl : null; + } + + private static string? GetIndexedNextPageUrl( + JsonElement result, + Func buildIndexedPageUrl, + int itemCount, + int pageItemCount) + { + if (pageItemCount == 0) + { + return null; + } + + if (result.TryGetProperty("total", out var total) && + total.TryGetInt32(out var totalCount)) + { + return itemCount < totalCount + ? buildIndexedPageUrl(itemCount) + : null; + } + + return pageItemCount >= MetadataPageSize + ? buildIndexedPageUrl(itemCount) + : null; + } + + private static bool IsOfficialDeezerApiUrl(string? url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var uri) && + string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + string.Equals(uri.Host, DeezerApiHost, StringComparison.OrdinalIgnoreCase); + } + private ExternalPlaylist ParseDeezerPlaylist(JsonElement playlist) { var externalId = playlist.GetProperty("id").GetInt64().ToString(); diff --git a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs index a01abb1..a46b2b1 100644 --- a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs +++ b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs @@ -134,7 +134,7 @@ public class JellyfinResponseBuilder var albumItem = new Dictionary { ["Id"] = playlist.Id, - ["Name"] = $"{playlist.Name} [S/P]", // Label as playlist + ["Name"] = BuildExternalPlaylistName(playlist.Name, playlist.Provider), ["Type"] = "MusicAlbum", // Must be MusicAlbum for Jellyfin clients ["ServerId"] = "allstarr", ["ChannelId"] = null, @@ -304,21 +304,11 @@ public class JellyfinResponseBuilder { songTitle = BuildExternalSongTitle(song); - // Also add [S] to artist and album names for consistency - if (!string.IsNullOrEmpty(artistName) && !artistName.EndsWith(" [S]")) - { - artistName = $"{artistName} [S]"; - } - - if (!string.IsNullOrEmpty(albumName) && !albumName.EndsWith(" [S]")) - { - albumName = $"{albumName} [S]"; - } - - // Add [S] to all artist names in the list - artistNames = artistNames.Select(a => - !string.IsNullOrEmpty(a) && !a.EndsWith(" [S]") ? $"{a} [S]" : a - ).ToList(); + artistName = AppendExternalSourceLabel(artistName, song.ExternalProvider); + albumName = AppendExternalSourceLabel(albumName, song.ExternalProvider); + artistNames = artistNames + .Select(a => AppendExternalSourceLabel(a, song.ExternalProvider)) + .ToList(); } var item = new Dictionary @@ -506,7 +496,7 @@ public class JellyfinResponseBuilder private static string BuildExternalSongTitle(Song song) { - var title = $"{song.Title} [S]"; + var title = AppendExternalSourceLabel(song.Title, song.ExternalProvider); if (song.ExplicitContentLyrics == 1) { @@ -523,16 +513,49 @@ public class JellyfinResponseBuilder provider.Equals("squidwtf", StringComparison.OrdinalIgnoreCase); } + private static string AppendExternalSourceLabel(string value, string? provider) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + var label = GetExternalSourceLabel(provider); + return value.EndsWith($" {label}", StringComparison.Ordinal) + ? value + : $"{value} {label}"; + } + + private static string BuildExternalPlaylistName(string playlistName, string? provider) + { + return $"{playlistName} [{GetExternalSourceCode(provider)}/P]"; + } + + private static string GetExternalSourceLabel(string? provider) + { + return $"[{GetExternalSourceCode(provider)}]"; + } + + private static string GetExternalSourceCode(string? provider) + { + return provider?.ToLowerInvariant() switch + { + "deezer" => "D", + "qobuz" => "Q", + "squidwtf" => "S", + _ => "S" + }; + } + /// /// Converts an Album domain model to a Jellyfin item. /// public Dictionary ConvertAlbumToJellyfinItem(Album album) { - // Add " [S]" suffix to external album names (S = streaming source) var albumName = album.Title; if (!album.IsLocal) { - albumName = $"{album.Title} [S]"; + albumName = AppendExternalSourceLabel(album.Title, album.ExternalProvider); } var item = new Dictionary @@ -621,11 +644,10 @@ public class JellyfinResponseBuilder /// public Dictionary ConvertArtistToJellyfinItem(Artist artist) { - // Add " [S]" suffix to external artist names (S = streaming source) var artistName = artist.Name; if (!artist.IsLocal) { - artistName = $"{artist.Name} [S]"; + artistName = AppendExternalSourceLabel(artist.Name, artist.ExternalProvider); } var item = new Dictionary @@ -755,7 +777,7 @@ public class JellyfinResponseBuilder var item = new Dictionary { - ["Name"] = $"{playlist.Name} [S/P]", + ["Name"] = BuildExternalPlaylistName(playlist.Name, playlist.Provider), ["ServerId"] = "allstarr", ["Id"] = playlist.Id, ["ChannelId"] = (object?)null, @@ -763,7 +785,7 @@ public class JellyfinResponseBuilder ["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond, ["IsFolder"] = true, ["Type"] = "MusicAlbum", - ["SortName"] = $"{playlist.Name} [S/P]", + ["SortName"] = BuildExternalPlaylistName(playlist.Name, playlist.Provider), ["DateCreated"] = playlist.CreatedDate.HasValue ? playlist.CreatedDate.Value.ToString("o") : "1970-01-01T00:00:00.0000000Z", diff --git a/allstarr/Services/Lyrics/IKeptLyricsSidecarService.cs b/allstarr/Services/Lyrics/IKeptLyricsSidecarService.cs new file mode 100644 index 0000000..76d3ad1 --- /dev/null +++ b/allstarr/Services/Lyrics/IKeptLyricsSidecarService.cs @@ -0,0 +1,15 @@ +using allstarr.Models.Domain; + +namespace allstarr.Services.Lyrics; + +public interface IKeptLyricsSidecarService +{ + string GetSidecarPath(string audioFilePath); + + Task EnsureSidecarAsync( + string audioFilePath, + Song? song = null, + string? externalProvider = null, + string? externalId = null, + CancellationToken cancellationToken = default); +} diff --git a/allstarr/Services/Lyrics/KeptLyricsSidecarService.cs b/allstarr/Services/Lyrics/KeptLyricsSidecarService.cs new file mode 100644 index 0000000..aadb586 --- /dev/null +++ b/allstarr/Services/Lyrics/KeptLyricsSidecarService.cs @@ -0,0 +1,321 @@ +using System.Text.RegularExpressions; +using TagLib; +using allstarr.Models.Domain; +using allstarr.Models.Lyrics; +using allstarr.Models.Settings; +using allstarr.Models.Spotify; +using allstarr.Services.Common; +using Microsoft.Extensions.Options; + +namespace allstarr.Services.Lyrics; + +public class KeptLyricsSidecarService : IKeptLyricsSidecarService +{ + private static readonly Regex ProviderSuffixRegex = new( + @"\[(?[A-Za-z0-9_-]+)-(?[^\]]+)\]$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private readonly LyricsOrchestrator _lyricsOrchestrator; + private readonly RedisCacheService _cache; + private readonly SpotifyImportSettings _spotifySettings; + private readonly OdesliService _odesliService; + private readonly ILogger _logger; + + public KeptLyricsSidecarService( + LyricsOrchestrator lyricsOrchestrator, + RedisCacheService cache, + IOptions spotifySettings, + OdesliService odesliService, + ILogger logger) + { + _lyricsOrchestrator = lyricsOrchestrator; + _cache = cache; + _spotifySettings = spotifySettings.Value; + _odesliService = odesliService; + _logger = logger; + } + + public string GetSidecarPath(string audioFilePath) + { + return Path.ChangeExtension(audioFilePath, ".lrc"); + } + + public async Task EnsureSidecarAsync( + string audioFilePath, + Song? song = null, + string? externalProvider = null, + string? externalId = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(audioFilePath) || !System.IO.File.Exists(audioFilePath)) + { + return null; + } + + var sidecarPath = GetSidecarPath(audioFilePath); + if (System.IO.File.Exists(sidecarPath)) + { + return sidecarPath; + } + + try + { + var inferredExternalRef = ParseExternalReferenceFromPath(audioFilePath); + externalProvider ??= inferredExternalRef.Provider; + externalId ??= inferredExternalRef.ExternalId; + + var metadata = ReadAudioMetadata(audioFilePath); + var artistNames = ResolveArtists(song, metadata); + var title = FirstNonEmpty( + StripTrackDecorators(song?.Title), + StripTrackDecorators(metadata.Title), + GetFallbackTitleFromPath(audioFilePath)); + var album = FirstNonEmpty( + StripTrackDecorators(song?.Album), + StripTrackDecorators(metadata.Album)); + var durationSeconds = song?.Duration ?? metadata.DurationSeconds; + + if (string.IsNullOrWhiteSpace(title) || artistNames.Count == 0) + { + _logger.LogDebug("Skipping lyrics sidecar generation for {Path}: missing title or artist metadata", audioFilePath); + return null; + } + + var spotifyTrackId = FirstNonEmpty(song?.SpotifyId); + if (string.IsNullOrWhiteSpace(spotifyTrackId) && + !string.IsNullOrWhiteSpace(externalProvider) && + !string.IsNullOrWhiteSpace(externalId)) + { + spotifyTrackId = await ResolveSpotifyTrackIdAsync(externalProvider, externalId, cancellationToken); + } + + var lyrics = await _lyricsOrchestrator.GetLyricsAsync( + trackName: title, + artistNames: artistNames.ToArray(), + albumName: album, + durationSeconds: durationSeconds, + spotifyTrackId: spotifyTrackId); + + if (lyrics == null) + { + return null; + } + + var lrcContent = BuildLrcContent( + lyrics, + title, + artistNames, + album, + durationSeconds); + + if (string.IsNullOrWhiteSpace(lrcContent)) + { + return null; + } + + await System.IO.File.WriteAllTextAsync(sidecarPath, lrcContent, cancellationToken); + _logger.LogInformation("Saved lyrics sidecar: {SidecarPath}", sidecarPath); + return sidecarPath; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to create lyrics sidecar for {Path}", audioFilePath); + return null; + } + } + + private async Task ResolveSpotifyTrackIdAsync( + string externalProvider, + string externalId, + CancellationToken cancellationToken) + { + var spotifyId = await FindSpotifyIdFromMatchedTracksAsync(externalProvider, externalId); + if (!string.IsNullOrWhiteSpace(spotifyId)) + { + return spotifyId; + } + + return externalProvider.ToLowerInvariant() switch + { + "squidwtf" => await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, cancellationToken), + "deezer" => await _odesliService.ConvertUrlToSpotifyIdAsync($"https://www.deezer.com/track/{externalId}", cancellationToken), + "qobuz" => await _odesliService.ConvertUrlToSpotifyIdAsync($"https://www.qobuz.com/us-en/album/-/-/{externalId}", cancellationToken), + _ => null + }; + } + + private async Task FindSpotifyIdFromMatchedTracksAsync(string externalProvider, string externalId) + { + if (_spotifySettings.Playlists == null || _spotifySettings.Playlists.Count == 0) + { + return null; + } + + foreach (var playlist in _spotifySettings.Playlists) + { + var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name); + var matchedTracks = await _cache.GetAsync>(cacheKey); + + var match = matchedTracks?.FirstOrDefault(track => + track.MatchedSong != null && + string.Equals(track.MatchedSong.ExternalProvider, externalProvider, StringComparison.OrdinalIgnoreCase) && + string.Equals(track.MatchedSong.ExternalId, externalId, StringComparison.Ordinal)); + + if (match != null && !string.IsNullOrWhiteSpace(match.SpotifyId)) + { + return match.SpotifyId; + } + } + + return null; + } + + private static (string? Provider, string? ExternalId) ParseExternalReferenceFromPath(string audioFilePath) + { + var baseName = Path.GetFileNameWithoutExtension(audioFilePath); + var match = ProviderSuffixRegex.Match(baseName); + if (!match.Success) + { + return (null, null); + } + + return ( + match.Groups["provider"].Value, + match.Groups["externalId"].Value); + } + + private static AudioMetadata ReadAudioMetadata(string audioFilePath) + { + try + { + using var tagFile = TagLib.File.Create(audioFilePath); + return new AudioMetadata + { + Title = tagFile.Tag.Title, + Album = tagFile.Tag.Album, + Artists = tagFile.Tag.Performers?.Where(value => !string.IsNullOrWhiteSpace(value)).ToList() ?? new List(), + DurationSeconds = (int)Math.Round(tagFile.Properties.Duration.TotalSeconds) + }; + } + catch + { + return new AudioMetadata(); + } + } + + private static List ResolveArtists(Song? song, AudioMetadata metadata) + { + var artists = new List(); + + if (song?.Artists != null && song.Artists.Count > 0) + { + artists.AddRange(song.Artists.Where(value => !string.IsNullOrWhiteSpace(value))); + } + else if (!string.IsNullOrWhiteSpace(song?.Artist)) + { + artists.Add(song.Artist); + } + + if (artists.Count == 0 && metadata.Artists.Count > 0) + { + artists.AddRange(metadata.Artists); + } + + return artists + .Select(StripTrackDecorators) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string BuildLrcContent( + LyricsInfo lyrics, + string fallbackTitle, + IReadOnlyList fallbackArtists, + string? fallbackAlbum, + int fallbackDurationSeconds) + { + var title = FirstNonEmpty(lyrics.TrackName, fallbackTitle); + var artist = FirstNonEmpty(lyrics.ArtistName, string.Join(", ", fallbackArtists)); + var album = FirstNonEmpty(lyrics.AlbumName, fallbackAlbum); + var durationSeconds = lyrics.Duration > 0 ? lyrics.Duration : fallbackDurationSeconds; + + var body = FirstNonEmpty( + NormalizeLineEndings(lyrics.SyncedLyrics), + NormalizeLineEndings(lyrics.PlainLyrics)); + + if (string.IsNullOrWhiteSpace(body)) + { + return string.Empty; + } + + var headerLines = new List(); + if (!string.IsNullOrWhiteSpace(artist)) + { + headerLines.Add($"[ar:{artist}]"); + } + + if (!string.IsNullOrWhiteSpace(album)) + { + headerLines.Add($"[al:{album}]"); + } + + if (!string.IsNullOrWhiteSpace(title)) + { + headerLines.Add($"[ti:{title}]"); + } + + if (durationSeconds > 0) + { + var duration = TimeSpan.FromSeconds(durationSeconds); + headerLines.Add($"[length:{(int)duration.TotalMinutes}:{duration.Seconds:D2}]"); + } + + return headerLines.Count == 0 + ? body + : $"{string.Join('\n', headerLines)}\n\n{body}"; + } + + private static string? GetFallbackTitleFromPath(string audioFilePath) + { + var baseName = Path.GetFileNameWithoutExtension(audioFilePath); + baseName = ProviderSuffixRegex.Replace(baseName, string.Empty).Trim(); + baseName = Regex.Replace(baseName, @"^\d+\s*-\s*", string.Empty); + return baseName.Trim(); + } + + private static string FirstNonEmpty(params string?[] values) + { + return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; + } + + private static string NormalizeLineEndings(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Replace("\r\n", "\n").Replace('\r', '\n').Trim(); + } + + private static string StripTrackDecorators(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + return value + .Replace(" [S]", "", StringComparison.Ordinal) + .Replace(" [D]", "", StringComparison.Ordinal) + .Replace(" [Q]", "", StringComparison.Ordinal) + .Replace(" [E]", "", StringComparison.Ordinal) + .Trim(); + } + + private sealed class AudioMetadata + { + public string? Title { get; init; } + public string? Album { get; init; } + public List Artists { get; init; } = new(); + public int DurationSeconds { get; init; } + } +} diff --git a/allstarr/Services/Scrobbling/LastFmScrobblingService.cs b/allstarr/Services/Scrobbling/LastFmScrobblingService.cs index 3c0199c..78fdd65 100644 --- a/allstarr/Services/Scrobbling/LastFmScrobblingService.cs +++ b/allstarr/Services/Scrobbling/LastFmScrobblingService.cs @@ -22,9 +22,10 @@ public class LastFmScrobblingService : IScrobblingService private readonly ILogger _logger; public string ServiceName => "Last.fm"; - public bool IsEnabled => _settings.Enabled && - !string.IsNullOrEmpty(_settings.ApiKey) && - !string.IsNullOrEmpty(_settings.SharedSecret) && + public bool IsEnabled => _settings.Enabled && + !string.IsNullOrEmpty(_settings.ApiKey) && + !string.IsNullOrEmpty(_settings.SharedSecret) && + !LastFmSettings.IsLegacyJellyfinPluginApiKey(_settings.ApiKey) && !string.IsNullOrEmpty(_settings.SessionKey); public LastFmScrobblingService( @@ -37,10 +38,31 @@ public class LastFmScrobblingService : IScrobblingService _httpClient = httpClientFactory.CreateClient("LastFm"); _logger = logger; - if (IsEnabled) + if (_settings.Enabled) { - _logger.LogInformation("🎵 Last.fm scrobbling enabled for user: {Username}", - _settings.Username ?? "Unknown"); + if (LastFmSettings.IsLegacyJellyfinPluginApiKey(_settings.ApiKey)) + { + _logger.LogError( + "Last.fm is enabled but uses the suspended shared Jellyfin plugin API key. " + + "Register your own app at https://www.last.fm/api/account/create, set " + + "SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET, then re-authenticate in Admin → Scrobbling."); + } + else if (string.IsNullOrEmpty(_settings.ApiKey) || string.IsNullOrEmpty(_settings.SharedSecret)) + { + _logger.LogError( + "Last.fm is enabled but API credentials are missing. Set SCROBBLING_LASTFM_API_KEY and " + + "SCROBBLING_LASTFM_SHARED_SECRET from https://www.last.fm/api/account/create"); + } + else if (string.IsNullOrEmpty(_settings.SessionKey)) + { + _logger.LogWarning( + "Last.fm API credentials are set but SCROBBLING_LASTFM_SESSION_KEY is missing — authenticate in Admin → Scrobbling"); + } + else + { + _logger.LogInformation("🎵 Last.fm scrobbling enabled for user: {Username}", + _settings.Username ?? "Unknown"); + } } } @@ -340,7 +362,12 @@ public class LastFmScrobblingService : IScrobblingService { _logger.LogError("❌ Last.fm session key is invalid - please re-authenticate"); } - + + if (IsSuspendedApiKeyError(errorMessage)) + { + LogSuspendedApiKeyGuidance(); + } + return ScrobbleResult.CreateError(errorMessage, errorCode, shouldRetry); } @@ -387,7 +414,12 @@ public class LastFmScrobblingService : IScrobblingService var errorCode = int.Parse(errorElement?.Attribute("code")?.Value ?? "0"); var errorMessage = errorElement?.Value ?? "Unknown error"; var shouldRetry = errorCode == 11 || errorCode == 16; - + + if (IsSuspendedApiKeyError(errorMessage)) + { + LogSuspendedApiKeyGuidance(); + } + return Enumerable.Repeat(ScrobbleResult.CreateError(errorMessage, errorCode, shouldRetry), expectedCount).ToList(); } @@ -453,6 +485,18 @@ public class LastFmScrobblingService : IScrobblingService return result; } - + + private static bool IsSuspendedApiKeyError(string errorMessage) => + errorMessage.Contains("suspended", StringComparison.OrdinalIgnoreCase) || + errorMessage.Contains("not allowed to make requests", StringComparison.OrdinalIgnoreCase); + + private void LogSuspendedApiKeyGuidance() + { + _logger.LogError( + "Last.fm rejected requests because the API key is suspended. Register your own app at " + + "https://www.last.fm/api/account/create, set SCROBBLING_LASTFM_API_KEY and " + + "SCROBBLING_LASTFM_SHARED_SECRET, restart Allstarr, then re-authenticate in Admin → Scrobbling."); + } + #endregion } diff --git a/allstarr/Services/Spotify/SpotifyMappingService.cs b/allstarr/Services/Spotify/SpotifyMappingService.cs index dec7bdf..0c40233 100644 --- a/allstarr/Services/Spotify/SpotifyMappingService.cs +++ b/allstarr/Services/Spotify/SpotifyMappingService.cs @@ -31,6 +31,7 @@ public class SpotifyMappingService if (mapping != null) { + EnsureExternalMappingsConsistency(mapping); _logger.LogDebug("Found mapping for Spotify ID {SpotifyId}: {TargetType}", spotifyId, mapping.TargetType); } @@ -44,6 +45,8 @@ public class SpotifyMappingService /// public async Task SaveMappingAsync(SpotifyTrackMapping mapping) { + EnsureExternalMappingsConsistency(mapping); + // Validate mapping if (string.IsNullOrEmpty(mapping.SpotifyId)) { @@ -58,9 +61,9 @@ public class SpotifyMappingService } if (mapping.TargetType == "external" && - (string.IsNullOrEmpty(mapping.ExternalProvider) || string.IsNullOrEmpty(mapping.ExternalId))) + !mapping.TryGetExternalTarget(preferredProvider: null, out _, out _)) { - _logger.LogWarning("Cannot save external mapping: ExternalProvider and ExternalId are required"); + _logger.LogWarning("Cannot save external mapping: at least one external provider/id is required"); return false; } @@ -69,6 +72,37 @@ public class SpotifyMappingService // Check if mapping already exists var existingMapping = await GetMappingAsync(mapping.SpotifyId); + // For external mappings, merge provider-specific mappings so rematch/rebuild + // can retain multiple sources (e.g. SquidWTF + Deezer) for the same Spotify ID. + if (mapping.TargetType == "external") + { + if (existingMapping != null && existingMapping.TargetType == "local") + { + var localMappingWithExternalAlternatives = existingMapping; + MergeExternalMappings(localMappingWithExternalAlternatives, mapping); + localMappingWithExternalAlternatives.UpdatedAt = DateTime.UtcNow; + + var saveLocalWithAlternatives = await _cache.SetAsync(key, localMappingWithExternalAlternatives, expiry: null); + if (saveLocalWithAlternatives) + { + await AddToAllMappingsSetAsync(localMappingWithExternalAlternatives.SpotifyId); + await InvalidateAllPlaylistStatsCachesAsync(); + _logger.LogInformation( + "Saved external fallback for local mapping: Spotify {SpotifyId} -> {Provider}:{ExternalId}", + mapping.SpotifyId, + mapping.ExternalProvider, + mapping.ExternalId); + } + + return saveLocalWithAlternatives; + } + + if (existingMapping != null && existingMapping.TargetType == "external") + { + MergeExternalMappings(mapping, existingMapping); + } + } + // RULE 1: Never overwrite manual mappings with auto mappings if (existingMapping != null && existingMapping.Source == "manual" && @@ -84,6 +118,7 @@ public class SpotifyMappingService mapping.TargetType == "local") { _logger.LogInformation("🎉 UPGRADING: External → Local for {SpotifyId}", mapping.SpotifyId); + MergeExternalMappings(mapping, existingMapping); // Allow the upgrade to proceed } @@ -106,8 +141,14 @@ public class SpotifyMappingService if (existingMapping != null) { mapping.CreatedAt = existingMapping.CreatedAt; + if (mapping.TargetType == "local" || mapping.TargetType == "external") + { + MergeExternalMappings(mapping, existingMapping); + } } + EnsureExternalMappingsConsistency(mapping); + // Save mapping (permanent - no TTL) var success = await _cache.SetAsync(key, mapping, expiry: null); @@ -168,6 +209,16 @@ public class SpotifyMappingService TargetType = "external", ExternalProvider = externalProvider, ExternalId = externalId, + ExternalMappings = new List + { + new() + { + Provider = externalProvider, + ExternalId = externalId, + Source = "auto", + CreatedAt = DateTime.UtcNow + } + }, Metadata = metadata, Source = "auto", CreatedAt = DateTime.UtcNow @@ -194,6 +245,20 @@ public class SpotifyMappingService LocalId = localId, ExternalProvider = externalProvider, ExternalId = externalId, + ExternalMappings = targetType == "external" && + !string.IsNullOrWhiteSpace(externalProvider) && + !string.IsNullOrWhiteSpace(externalId) + ? new List + { + new() + { + Provider = externalProvider, + ExternalId = externalId, + Source = "manual", + CreatedAt = DateTime.UtcNow + } + } + : new List(), Metadata = metadata, Source = "manual", CreatedAt = DateTime.UtcNow, @@ -220,6 +285,65 @@ public class SpotifyMappingService return success; } + /// + /// Removes a single external provider mapping for a Spotify track ID. + /// Deletes the entire mapping when no external targets remain on an external-only mapping. + /// + public async Task RemoveExternalProviderAsync(string spotifyId, string provider) + { + if (string.IsNullOrWhiteSpace(spotifyId) || string.IsNullOrWhiteSpace(provider)) + { + return false; + } + + var mapping = await GetMappingAsync(spotifyId); + if (mapping == null) + { + return false; + } + + var normalizedProvider = provider.Trim().ToLowerInvariant(); + var removedCount = mapping.ExternalMappings.RemoveAll(m => + string.Equals(m.Provider, normalizedProvider, StringComparison.OrdinalIgnoreCase)); + + var removedLegacy = + string.Equals(mapping.ExternalProvider, normalizedProvider, StringComparison.OrdinalIgnoreCase); + if (removedLegacy) + { + mapping.ExternalProvider = null; + mapping.ExternalId = null; + removedCount++; + } + + if (removedCount == 0) + { + return false; + } + + EnsureExternalMappingsConsistency(mapping); + + if (mapping.TargetType == "external" && + !mapping.TryGetExternalTarget(preferredProvider: null, out _, out _)) + { + return await DeleteMappingAsync(spotifyId); + } + + mapping.UpdatedAt = DateTime.UtcNow; + var key = CacheKeyBuilder.BuildSpotifyGlobalMappingKey(spotifyId); + var success = await _cache.SetAsync(key, mapping, expiry: null); + + if (success) + { + await InvalidateAllPlaylistStatsCachesAsync(); + _logger.LogInformation( + "Removed external provider {Provider} from Spotify mapping {SpotifyId}", + normalizedProvider, + spotifyId); + } + + return success; + } + /// /// Gets all Spotify IDs that have mappings. /// @@ -377,6 +501,87 @@ public class SpotifyMappingService _logger.LogWarning(ex, "Failed to invalidate playlist stats caches"); } } + + private static void MergeExternalMappings(SpotifyTrackMapping target, SpotifyTrackMapping source) + { + if (source.TryGetExternalTarget(preferredProvider: null, out var sourceProvider, out var sourceExternalId)) + { + UpsertExternalMapping( + target.ExternalMappings, + sourceProvider, + sourceExternalId, + source.Source); + } + + foreach (var mapping in source.ExternalMappings) + { + if (string.IsNullOrWhiteSpace(mapping.Provider) || string.IsNullOrWhiteSpace(mapping.ExternalId)) + { + continue; + } + + UpsertExternalMapping( + target.ExternalMappings, + mapping.Provider, + mapping.ExternalId, + mapping.Source); + } + } + + private static void UpsertExternalMapping( + List externalMappings, + string provider, + string externalId, + string source) + { + var normalizedProvider = provider.Trim().ToLowerInvariant(); + var existing = externalMappings.FirstOrDefault(m => + string.Equals(m.Provider, normalizedProvider, StringComparison.OrdinalIgnoreCase)); + + if (existing == null) + { + externalMappings.Add(new ExternalTrackMapping + { + Provider = normalizedProvider, + ExternalId = externalId, + Source = source, + CreatedAt = DateTime.UtcNow + }); + return; + } + + if (!string.Equals(existing.ExternalId, externalId, StringComparison.Ordinal)) + { + existing.ExternalId = externalId; + existing.UpdatedAt = DateTime.UtcNow; + } + + if (!string.IsNullOrWhiteSpace(source)) + { + existing.Source = source; + } + } + + private static void EnsureExternalMappingsConsistency(SpotifyTrackMapping mapping) + { + mapping.ExternalMappings ??= new List(); + + if (!string.IsNullOrWhiteSpace(mapping.ExternalProvider) && !string.IsNullOrWhiteSpace(mapping.ExternalId)) + { + UpsertExternalMapping( + mapping.ExternalMappings, + mapping.ExternalProvider, + mapping.ExternalId, + mapping.Source); + } + + if (mapping.ExternalMappings.Count > 0) + { + var first = mapping.ExternalMappings[0]; + mapping.ExternalProvider = first.Provider; + mapping.ExternalId = first.ExternalId; + } + } } /// diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index 2e1581e..ce99af6 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -4,6 +4,7 @@ using allstarr.Models.Spotify; using allstarr.Services.Common; using allstarr.Services.Jellyfin; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using System.Text.Json; using Cronos; @@ -36,6 +37,7 @@ public class SpotifyTrackMatchingService : BackgroundService private readonly SpotifyMappingValidationService _validationService; private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; + private readonly IConfiguration _configuration; 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 static readonly TimeSpan ExternalProviderSearchTimeout = TimeSpan.FromSeconds(30); @@ -51,6 +53,7 @@ public class SpotifyTrackMatchingService : BackgroundService SpotifyMappingService mappingService, SpotifyMappingValidationService validationService, IServiceProvider serviceProvider, + IConfiguration configuration, ILogger logger) { _spotifySettings = spotifySettings.Value; @@ -59,6 +62,7 @@ public class SpotifyTrackMatchingService : BackgroundService _mappingService = mappingService; _validationService = validationService; _serviceProvider = serviceProvider; + _configuration = configuration; _logger = logger; } @@ -803,13 +807,13 @@ public class SpotifyTrackMatchingService : BackgroundService if (globalMapping != null && globalMapping.TargetType == "external") { Song? mappedSong = null; + var preferredProvider = GetCurrentMusicServiceProvider(); - if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) && - !string.IsNullOrEmpty(globalMapping.ExternalId)) + if (globalMapping.TryGetExternalTarget(preferredProvider, out var mappedProvider, out var mappedExternalId)) { mappedSong = await metadataService.GetSongAsync( - globalMapping.ExternalProvider, - globalMapping.ExternalId, + mappedProvider, + mappedExternalId, trackCancellationToken); } @@ -2036,4 +2040,20 @@ public class SpotifyTrackMatchingService : BackgroundService return song; } + + private string? GetCurrentMusicServiceProvider() + { + var backendType = _configuration.GetValue("Backend:Type"); + var musicService = backendType == BackendType.Jellyfin + ? _configuration.GetValue("Jellyfin:MusicService") + : _configuration.GetValue("Subsonic:MusicService"); + + return musicService switch + { + MusicService.Deezer => "deezer", + MusicService.Qobuz => "qobuz", + MusicService.SquidWTF => "squidwtf", + _ => null + }; + } } diff --git a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs index 37ee0a3..2f9e6b3 100644 --- a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs @@ -73,7 +73,7 @@ public class SquidWTFDownloadService : BaseDownloadService List apiUrls) : base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger) { - _httpClient = httpClientFactory.CreateClient(); + _httpClient = httpClientFactory.CreateClient("SquidWTF"); _squidwtfSettings = SquidWTFSettings.Value; _odesliService = odesliService; _fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF"); @@ -99,87 +99,96 @@ public class SquidWTFDownloadService : BaseDownloadService private async Task RunDownloadWithFallbackAsync(string trackId, Song song, string quality, string basePath, CancellationToken cancellationToken) { - return await _fallbackHelper.TryWithFallbackAsync(async baseUrl => + var songId = BuildTrackedSongId(trackId); + var raceCount = Math.Min(3, _fallbackHelper.EndpointCount); + + if (raceCount > 1) { - var songId = BuildTrackedSongId(trackId); - var downloadInfo = await FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken); - Logger.LogInformation( - "Track download info resolved via {Endpoint} (Format: {Format}, Quality: {Quality})", - downloadInfo.Endpoint, downloadInfo.MimeType, downloadInfo.AudioQuality); - Logger.LogDebug("Resolved SquidWTF CDN download URL: {Url}", downloadInfo.DownloadUrl); + "Racing top {EndpointCount} SquidWTF endpoints for track {TrackId} manifest resolution", + raceCount, trackId); + } - var extension = downloadInfo.MimeType?.ToLower() switch - { - "audio/flac" => ".flac", "audio/mpeg" => ".mp3", "audio/mp4" => ".m4a", _ => ".flac" - }; + var downloadInfo = await _fallbackHelper.RaceTopEndpointsAsync( + Math.Max(1, raceCount), + (baseUrl, ct) => FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, ct), + cancellationToken); - var artistForPath = song.AlbumArtist ?? song.Artist; - var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId); - - var albumFolder = Path.GetDirectoryName(outputPath)!; - EnsureDirectoryExists(albumFolder); - - if (basePath.EndsWith("transcoded") && IOFile.Exists(outputPath)) - { - IOFile.SetLastWriteTime(outputPath, DateTime.UtcNow); - Logger.LogInformation("Quality override cache hit: {Path}", outputPath); - return outputPath; - } - - outputPath = PathHelper.ResolveUniquePath(outputPath); + Logger.LogInformation( + "Track download info resolved via {Endpoint} (Format: {Format}, Quality: {Quality})", + downloadInfo.Endpoint, downloadInfo.MimeType, downloadInfo.AudioQuality); + Logger.LogDebug("Resolved SquidWTF CDN download URL: {Url}", downloadInfo.DownloadUrl); - using var req = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl); - req.Headers.Add("User-Agent", "Mozilla/5.0"); - req.Headers.Add("Accept", "*/*"); - var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - res.EnsureSuccessStatusCode(); + var extension = downloadInfo.MimeType?.ToLower() switch + { + "audio/flac" => ".flac", "audio/mpeg" => ".mp3", "audio/mp4" => ".m4a", _ => ".flac" + }; - await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken); - await using var outputFile = IOFile.Create(outputPath); - var totalBytes = res.Content.Headers.ContentLength; - var buffer = new byte[81920]; - long totalBytesRead = 0; + var artistForPath = song.AlbumArtist ?? song.Artist; + var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId); - while (true) - { - var bytesRead = await responseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); - if (bytesRead <= 0) - { - break; - } + var albumFolder = Path.GetDirectoryName(outputPath)!; + EnsureDirectoryExists(albumFolder); - await outputFile.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); - totalBytesRead += bytesRead; - - if (totalBytes.HasValue && totalBytes.Value > 0) - { - SetDownloadProgress(songId, (double)totalBytesRead / totalBytes.Value); - } - } - - await outputFile.DisposeAsync(); - SetDownloadProgress(songId, 1.0); - - _ = Task.Run(async () => - { - try - { - var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None); - if (!string.IsNullOrEmpty(spotifyId)) - { - Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId); - } - } - catch (Exception ex) - { - Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId); - } - }); - - await WriteMetadataAsync(outputPath, song, cancellationToken); + if (basePath.EndsWith("transcoded") && IOFile.Exists(outputPath)) + { + IOFile.SetLastWriteTime(outputPath, DateTime.UtcNow); + Logger.LogInformation("Quality override cache hit: {Path}", outputPath); return outputPath; + } + + outputPath = PathHelper.ResolveUniquePath(outputPath); + + using var req = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl); + req.Headers.Add("User-Agent", "Mozilla/5.0"); + req.Headers.Add("Accept", "*/*"); + var res = await _httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + res.EnsureSuccessStatusCode(); + + await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken); + await using var outputFile = IOFile.Create(outputPath); + var totalBytes = res.Content.Headers.ContentLength; + var buffer = new byte[81920]; + long totalBytesRead = 0; + + while (true) + { + var bytesRead = await responseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + if (bytesRead <= 0) + { + break; + } + + await outputFile.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + totalBytesRead += bytesRead; + + if (totalBytes.HasValue && totalBytes.Value > 0) + { + SetDownloadProgress(songId, (double)totalBytesRead / totalBytes.Value); + } + } + + await outputFile.DisposeAsync(); + SetDownloadProgress(songId, 1.0); + + _ = Task.Run(async () => + { + try + { + var spotifyId = await _odesliService.ConvertTidalToSpotifyIdAsync(trackId, CancellationToken.None); + if (!string.IsNullOrEmpty(spotifyId)) + { + Logger.LogDebug("Background Spotify ID obtained for Tidal/{TrackId}: {SpotifyId}", trackId, spotifyId); + } + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Background Spotify ID conversion failed for Tidal/{TrackId}", trackId); + } }); + + await WriteMetadataAsync(outputPath, song, cancellationToken); + return outputPath; } protected override async Task DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken) @@ -315,7 +324,9 @@ public class SquidWTFDownloadService : BaseDownloadService { var url = $"{baseUrl}/track/?id={trackId}&quality={quality}"; - Logger.LogDebug("Fetching track download info from: {Url}", url); + Logger.LogInformation("Requesting SquidWTF track manifest for track {TrackId} from {Endpoint} at quality {Quality}", + trackId, baseUrl, quality); + Logger.LogDebug("Fetching SquidWTF track download info from: {Url}", url); using var response = await _httpClient.GetAsync(url, cancellationToken); @@ -356,7 +367,10 @@ public class SquidWTFDownloadService : BaseDownloadService var audioQuality = data.TryGetProperty("audioQuality", out var audioQualityEl) ? audioQualityEl.GetString() : quality; - + + Logger.LogInformation("SquidWTF track manifest resolved for track {TrackId} via {Endpoint} (mimeType={MimeType}, audioQuality={AudioQuality})", + trackId, baseUrl, mimeType ?? "audio/flac", audioQuality ?? quality); + return new DownloadResult { Endpoint = baseUrl, diff --git a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs index a98f67c..4930d8c 100644 --- a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs @@ -76,7 +76,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService List apiUrls, GenreEnrichmentService? genreEnrichment = null) { - _httpClient = httpClientFactory.CreateClient(); + _httpClient = httpClientFactory.CreateClient("SquidWTF"); _settings = settings.Value; _logger = logger; _cache = cache; @@ -583,50 +583,78 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService { if (externalProvider != "squidwtf") return null; - return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => + var raceCount = Math.Min(3, _fallbackHelper.EndpointCount); + + if (raceCount > 1) { - // Per hifi-api spec: GET /info/?id={trackId} returns track metadata - var url = $"{baseUrl}/info/?id={externalId}"; + _logger.LogInformation( + "Racing top {EndpointCount} SquidWTF endpoints for track {TrackId} metadata resolution", + raceCount, + externalId); - var response = await _httpClient.GetAsync(url, cancellationToken); - if (!response.IsSuccessStatusCode) + try { - throw new HttpRequestException($"HTTP {response.StatusCode}"); + return await _fallbackHelper.RaceTopEndpointsAsync( + raceCount, + async (baseUrl, ct) => await FetchSongAsync(baseUrl, externalId, ct), + cancellationToken); } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Raced SquidWTF metadata lookup failed for track {TrackId}; falling back to sequential failover", + externalId); + } + } - var json = await response.Content.ReadAsStringAsync(cancellationToken); - var result = JsonDocument.Parse(json); + return await _fallbackHelper.TryWithFallbackAsync( + async baseUrl => await FetchSongAsync(baseUrl, externalId, cancellationToken), + null); + } - // Per hifi-api spec: response is { "version": "2.0", "data": { track object } } - if (!result.RootElement.TryGetProperty("data", out var track)) - { - throw new InvalidOperationException($"SquidWTF /info response for track {externalId} did not contain data"); - } + private async Task FetchSongAsync(string baseUrl, string externalId, CancellationToken cancellationToken) + { + var url = $"{baseUrl}/info/?id={externalId}"; - var song = ParseTidalTrackFull(track); + _logger.LogInformation( + "Requesting SquidWTF track metadata for track {TrackId} from {Endpoint}", + externalId, + baseUrl); + _logger.LogDebug("Fetching SquidWTF track metadata from: {Url}", url); - // Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres) - if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre)) - { - // Fire-and-forget: don't block the response waiting for genre enrichment - _ = Task.Run(async () => - { - try - { - await _genreEnrichment.EnrichSongGenreAsync(song); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to enrich genre for {Title}", song.Title); - } - }); - } + using var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"HTTP {response.StatusCode}", null, response.StatusCode); + } - // NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService) - // This avoids redundant conversions and ensures it's done in parallel with the download + var json = await response.Content.ReadAsStringAsync(cancellationToken); + using var result = JsonDocument.Parse(json); - return song; - }, (Song?)null); + if (!result.RootElement.TryGetProperty("data", out var track)) + { + throw new InvalidOperationException($"SquidWTF /info response for track {externalId} did not contain data"); + } + + var song = ParseTidalTrackFull(track); + + if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre)) + { + _ = Task.Run(async () => + { + try + { + await _genreEnrichment.EnrichSongGenreAsync(song); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to enrich genre for {Title}", song.Title); + } + }); + } + + return song; } public async Task> GetTrackRecommendationsAsync(string externalId, int limit = 20, CancellationToken cancellationToken = default) diff --git a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs index 75e1ae7..9fd0dfd 100644 --- a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs +++ b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs @@ -57,50 +57,79 @@ public class SquidWTFStartupValidator : BaseStartupValidator WriteStatus("SquidWTF API Endpoints", _apiUrls.Count.ToString(), ConsoleColor.Cyan); WriteStatus("SquidWTF Streaming Endpoints", _streamingUrls.Count.ToString(), ConsoleColor.Cyan); - await BenchmarkEndpointPoolAsync("API", _apiUrls, _apiFallbackHelper, cancellationToken); - await BenchmarkEndpointPoolAsync("streaming", _streamingUrls, _streamingFallbackHelper, cancellationToken); + if (_apiUrls.Count == 0) + { + WriteStatus("SquidWTF API", "UNAVAILABLE", ConsoleColor.Yellow); + WriteDetail("No API endpoints were discovered from the uptime feeds"); + } + else + { + await BenchmarkEndpointPoolAsync("API", _apiUrls, _apiFallbackHelper, cancellationToken); + } + + if (_streamingUrls.Count == 0) + { + WriteStatus("SquidWTF Streaming", "UNAVAILABLE", ConsoleColor.Yellow); + WriteDetail("No streaming endpoints were discovered from the uptime feeds"); + } + else + { + await BenchmarkEndpointPoolAsync("streaming", _streamingUrls, _streamingFallbackHelper, cancellationToken); + } + + if (_apiUrls.Count == 0 && _streamingUrls.Count == 0) + { + return ValidationResult.Failure( + "UNAVAILABLE", + "SquidWTF uptime feeds did not return any usable endpoints", + ConsoleColor.Yellow); + } // Validate API endpoints and search functionality. - var apiResult = await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) => - { - var response = await _httpClient.GetAsync(baseUrl, cancellationToken); - - if (response.IsSuccessStatusCode) + var apiResult = _apiUrls.Count == 0 + ? ValidationResult.Failure("-1", "No SquidWTF API endpoints are currently available", ConsoleColor.Yellow) + : await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) => { - WriteStatus("SquidWTF API", $"REACHABLE ({baseUrl})", ConsoleColor.Green); - WriteDetail("No authentication required - powered by Tidal"); - - // Try a test search to verify functionality - await ValidateSearchFunctionality(baseUrl, cancellationToken); - - return ValidationResult.Success("SquidWTF validation completed"); - } - else - { - throw new HttpRequestException($"HTTP {(int)response.StatusCode}"); - } - }, ValidationResult.Failure("-1", "All SquidWTF API endpoints failed")); + var response = await _httpClient.GetAsync(baseUrl, cancellationToken); - if (!apiResult.IsValid) + if (response.IsSuccessStatusCode) + { + WriteStatus("SquidWTF API", $"REACHABLE ({baseUrl})", ConsoleColor.Green); + WriteDetail("No authentication required - powered by Tidal"); + + // Try a test search to verify functionality + await ValidateSearchFunctionality(baseUrl, cancellationToken); + + return ValidationResult.Success("SquidWTF validation completed"); + } + else + { + throw new HttpRequestException($"HTTP {(int)response.StatusCode}"); + } + }, ValidationResult.Failure("-1", "All SquidWTF API endpoints failed")); + + if (_apiUrls.Count > 0 && !apiResult.IsValid) { return apiResult; } // Validate streaming endpoints independently to avoid API-only endpoints for streaming. - var streamingResult = await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) => - { - var response = await _httpClient.GetAsync(baseUrl, cancellationToken); - - if (response.IsSuccessStatusCode) + var streamingResult = _streamingUrls.Count == 0 + ? ValidationResult.Failure("-2", "No SquidWTF streaming endpoints are currently available", ConsoleColor.Yellow) + : await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) => { - WriteStatus("SquidWTF Streaming", $"REACHABLE ({baseUrl})", ConsoleColor.Green); - return ValidationResult.Success("SquidWTF streaming endpoint validation completed"); - } + var response = await _httpClient.GetAsync(baseUrl, cancellationToken); - throw new HttpRequestException($"HTTP {(int)response.StatusCode}"); - }, ValidationResult.Failure("-2", "All SquidWTF streaming endpoints failed")); + if (response.IsSuccessStatusCode) + { + WriteStatus("SquidWTF Streaming", $"REACHABLE ({baseUrl})", ConsoleColor.Green); + return ValidationResult.Success("SquidWTF streaming endpoint validation completed"); + } - if (!streamingResult.IsValid) + throw new HttpRequestException($"HTTP {(int)response.StatusCode}"); + }, ValidationResult.Failure("-2", "All SquidWTF streaming endpoints failed")); + + if (_streamingUrls.Count > 0 && !streamingResult.IsValid) { return streamingResult; } diff --git a/allstarr/Services/SquidWTF/SquidWtfEndpointCatalog.cs b/allstarr/Services/SquidWTF/SquidWtfEndpointCatalog.cs index 4e98a66..cc3abf1 100644 --- a/allstarr/Services/SquidWTF/SquidWtfEndpointCatalog.cs +++ b/allstarr/Services/SquidWTF/SquidWtfEndpointCatalog.cs @@ -19,16 +19,6 @@ public sealed class SquidWtfEndpointCatalog throw new ArgumentNullException(nameof(streamingUrls)); } - if (apiUrls.Count == 0) - { - throw new ArgumentException("API URL list cannot be empty.", nameof(apiUrls)); - } - - if (streamingUrls.Count == 0) - { - throw new ArgumentException("Streaming URL list cannot be empty.", nameof(streamingUrls)); - } - ApiUrls = apiUrls; StreamingUrls = streamingUrls; LoadedAtUtc = DateTime.UtcNow; diff --git a/allstarr/Services/SquidWTF/SquidWtfEndpointDiscovery.cs b/allstarr/Services/SquidWTF/SquidWtfEndpointDiscovery.cs index 14673d3..586f45e 100644 --- a/allstarr/Services/SquidWTF/SquidWtfEndpointDiscovery.cs +++ b/allstarr/Services/SquidWTF/SquidWtfEndpointDiscovery.cs @@ -6,9 +6,9 @@ public static class SquidWtfEndpointDiscovery { public static readonly IReadOnlyList SourceUrls = new[] { + "https://tidal-uptime.geeked.wtf/", "https://tidal-uptime.jiffy-puffs-1j.workers.dev/", - "https://tidal-uptime.props-76styles.workers.dev/", - "https://tidal-uptime.geeked.wtf/" + "https://tidal-uptime.props-76styles.workers.dev/" }; public static async Task DiscoverAsync(CancellationToken cancellationToken = default) @@ -24,8 +24,12 @@ public static class SquidWtfEndpointDiscovery { try { + Console.WriteLine($"Loading SquidWTF uptime feed: {sourceUrl}"); var json = await httpClient.GetStringAsync(sourceUrl, cancellationToken); - feeds.Add(ParseFeed(json)); + var feed = ParseFeed(json); + feeds.Add(feed); + Console.WriteLine( + $"Loaded SquidWTF uptime feed {sourceUrl}: api={feed.ApiUrls.Count}, streaming={feed.StreamingUrls.Count}, down={feed.DownUrls.Count}, lastUpdated={feed.LastUpdated:O}"); } catch (Exception ex) { @@ -35,7 +39,9 @@ public static class SquidWtfEndpointDiscovery if (feeds.Count == 0) { - throw new InvalidOperationException("Could not load SquidWTF endpoint feeds from any source URL."); + Console.WriteLine( + "⚠️ No SquidWTF uptime feeds could be loaded. Starting with SquidWTF external features unavailable; local Jellyfin content will still work."); + return new SquidWtfEndpointCatalog(new List(), new List()); } var orderedFeeds = feeds @@ -61,12 +67,12 @@ public static class SquidWtfEndpointDiscovery if (apiUrls.Count == 0) { - throw new InvalidOperationException("SquidWTF endpoint feed returned zero API endpoints."); + Console.WriteLine("⚠️ SquidWTF uptime feeds returned zero API endpoints."); } if (streamingUrls.Count == 0) { - throw new InvalidOperationException("SquidWTF endpoint feed returned zero streaming endpoints."); + Console.WriteLine("⚠️ SquidWTF uptime feeds returned zero streaming endpoints."); } Console.WriteLine($"Loaded SquidWTF endpoints from uptime feeds: api={apiUrls.Count}, streaming={streamingUrls.Count}"); diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 83ba574..c188481 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -28,6 +28,12 @@ + + Use only on a device you trust. + @@ -65,16 +71,27 @@
-
-

- Allstarr Loading... -

-
-
- - - Loading... - -
-
-
- @@ -316,7 +321,7 @@ Playlist Spotify ID Type - Target + Target IDs Created Actions @@ -371,6 +376,63 @@
+ +
+
+

+ Song Migration +
+ + +
+

+

+ Tracks from your injected Spotify playlists that are not in your Jellyfin library. + This includes tracks matched through external providers (SquidWTF, Deezer, Qobuz) and tracks that + are still missing. Use the CSV export to download these tracks with your preferred tool. +

+
+
+
+ Playlists: + 0 +
+
+ External: + 0 +
+
+ Missing: + 0 +
+
+ Total to Migrate: + 0 +
+
+
+ + + + + + + + + + + + + + +
NameNon-Local TracksStatus...
+ Loading playlists... +
+
+
+
+
@@ -456,7 +518,7 @@

Last.fm

- Scrobble to Last.fm. Enter your Last.fm username and password below, then click "Authenticate & Save" to generate a session key. + Scrobble to Last.fm. Set SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET in your .env (from last.fm/api/account/create), restart, then enter username/password and click "Authenticate & Save".

@@ -900,6 +962,82 @@
+ +
+
+

Report Issues

+
+ ℹ️ +
+
Draft a GitHub issue from inside Allstarr.
+
Allstarr includes only safe diagnostics here. Sensitive values stay redacted, and the final submit still happens on GitHub.
+
+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+
+ +
+
+ Safe diagnostics only: version, runtime config, service state, and a concise client summary. Sensitive values stay redacted. +
+ +
+ + +
+ +
+ GitHub drafts that exceed the URL size limit will open with a shorter body. The full report will also be copied to your clipboard. +
+
+
+
+
+
diff --git a/allstarr/wwwroot/js/api.js b/allstarr/wwwroot/js/api.js index fc06730..abf34ff 100644 --- a/allstarr/wwwroot/js/api.js +++ b/allstarr/wwwroot/js/api.js @@ -56,10 +56,10 @@ export async function fetchAdminSession() { ); } -export async function loginAdminSession(username, password) { +export async function loginAdminSession(username, password, rememberMe = false) { return requestJson( "/api/admin/auth/login", - asJsonBody({ username, password }), + asJsonBody({ username, password, rememberMe }), "Authentication failed", ); } @@ -100,9 +100,17 @@ export async function fetchTrackMappings() { ); } -export async function deleteTrackMapping(playlist, spotifyId) { +export async function deleteTrackMapping(playlist, spotifyId, provider = null) { + const params = new URLSearchParams({ + playlist, + spotifyId, + }); + if (provider) { + params.append("provider", provider); + } + return requestJson( - `/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`, + `/api/admin/mappings/tracks?${params.toString()}`, { method: "DELETE" }, "Failed to remove mapping", ); diff --git a/allstarr/wwwroot/js/auth-session.js b/allstarr/wwwroot/js/auth-session.js index 5da26ad..57ec000 100644 --- a/allstarr/wwwroot/js/auth-session.js +++ b/allstarr/wwwroot/js/auth-session.js @@ -72,6 +72,7 @@ function applyAuthorizationScope() { "kept", "scrobbling", "config", + "report-issues", "endpoints", ]; @@ -196,9 +197,11 @@ function wireLoginForm() { const usernameInput = document.getElementById("auth-username"); const passwordInput = document.getElementById("auth-password"); + const rememberMeInput = document.getElementById("auth-remember-me"); const authError = document.getElementById("auth-error"); const username = usernameInput?.value?.trim() || ""; const password = passwordInput?.value || ""; + const rememberMe = Boolean(rememberMeInput?.checked); if (!username || !password) { if (authError) { @@ -212,7 +215,7 @@ function wireLoginForm() { authError.textContent = ""; } - const result = await API.loginAdminSession(username, password); + const result = await API.loginAdminSession(username, password, rememberMe); if (passwordInput) { passwordInput.value = ""; } diff --git a/allstarr/wwwroot/js/dashboard-data.js b/allstarr/wwwroot/js/dashboard-data.js index 29417af..7208cd6 100644 --- a/allstarr/wwwroot/js/dashboard-data.js +++ b/allstarr/wwwroot/js/dashboard-data.js @@ -15,6 +15,7 @@ let onCookieNeedsInit = async () => {}; let setCurrentConfigState = () => {}; let syncConfigUiExtras = () => {}; let loadScrobblingConfig = () => {}; +let injectedPlaylistRequestToken = 0; let jellyfinPlaylistRequestToken = 0; async function fetchStatus() { @@ -39,10 +40,20 @@ async function fetchStatus() { } async function fetchPlaylists(silent = false) { + const requestToken = ++injectedPlaylistRequestToken; + try { const data = await API.fetchPlaylists(); + if (requestToken !== injectedPlaylistRequestToken) { + return; + } + UI.updatePlaylistsUI(data); } catch (error) { + if (requestToken !== injectedPlaylistRequestToken) { + return; + } + if (!silent) { console.error("Failed to fetch playlists:", error); showToast("Failed to fetch playlists", "error"); @@ -371,6 +382,15 @@ function startDashboardRefresh() { fetchDownloads(); } + const songMigrationTab = document.getElementById("tab-song-migration"); + if ( + songMigrationTab && + songMigrationTab.classList.contains("active") && + typeof window.fetchSongMigration === "function" + ) { + window.fetchSongMigration(); + } + const endpointsTab = document.getElementById("tab-endpoints"); if (endpointsTab && endpointsTab.classList.contains("active")) { fetchEndpointUsage(); @@ -407,13 +427,15 @@ async function loadDashboardData() { function startDownloadActivityStream() { if (!isAdminSession()) return; - + if (downloadActivityEventSource) { downloadActivityEventSource.close(); } - downloadActivityEventSource = new EventSource("/api/admin/downloads/activity"); - + downloadActivityEventSource = new EventSource( + "/api/admin/downloads/activity", + ); + downloadActivityEventSource.onmessage = (event) => { try { const downloads = JSON.parse(event.data); @@ -439,40 +461,50 @@ function renderDownloadActivity(downloads) { } const statusIcons = { - 0: '⏳', // NotStarted + 0: "⏳", // NotStarted 1: ' Downloading', // InProgress - 2: '✅ Completed', // Completed - 3: '❌ Failed' // Failed + 2: "✅ Completed", // Completed + 3: "❌ Failed", // Failed }; - const html = downloads.map(d => { - const downloadProgress = clampProgress(d.progress); - const playbackProgress = clampProgress(d.playbackProgress); + const html = downloads + .map((d) => { + const downloadProgress = clampProgress(d.progress); + const playbackProgress = clampProgress(d.playbackProgress); - // Determine elapsed/duration text - let timeText = ""; - if (d.startedAt) { - const start = new Date(d.startedAt); - const end = d.completedAt ? new Date(d.completedAt) : new Date(); - const diffSecs = Math.floor((end.getTime() - start.getTime()) / 1000); - timeText = diffSecs < 60 ? `${diffSecs}s` : `${Math.floor(diffSecs/60)}m ${diffSecs%60}s`; - } + // Determine elapsed/duration text + let timeText = ""; + if (d.startedAt) { + const start = new Date(d.startedAt); + const end = d.completedAt ? new Date(d.completedAt) : new Date(); + const diffSecs = Math.floor((end.getTime() - start.getTime()) / 1000); + timeText = + diffSecs < 60 + ? `${diffSecs}s` + : `${Math.floor(diffSecs / 60)}m ${diffSecs % 60}s`; + } - const progressMeta = []; - if (typeof d.durationSeconds === "number" && typeof d.playbackPositionSeconds === "number") { - progressMeta.push(`${formatSeconds(d.playbackPositionSeconds)} / ${formatSeconds(d.durationSeconds)}`); - } else if (typeof d.durationSeconds === "number") { - progressMeta.push(formatSeconds(d.durationSeconds)); - } - if (d.requestedForStreaming) { - progressMeta.push("stream"); - } + const 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 - ? `
${progressMeta.map(escapeHtml).join(" • ")}
` - : ""; + const progressMetaText = + progressMeta.length > 0 + ? `
${progressMeta.map(escapeHtml).join(" • ")}
` + : ""; - const progressBar = ` + const progressBar = ` +
+

Existing external mappings

+
+
+