v2.0.2: fix SquidWTF nullable metadata warning
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled

This commit is contained in:
2026-05-24 23:35:06 -04:00
parent a87102c8d8
commit fac1ffeda5
61 changed files with 5388 additions and 724 deletions
+3 -3
View File
@@ -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=
+29 -19
View File
@@ -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.
<details>
## Safe diagnostics from Allstarr
<summary>Please paste your docker-compose.yaml in between the tickmarks</summary>
- 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
```
</details>
<details>
<summary>Please paste your .env file REDACTED OF ALL PASSWORDS in between the tickmarks:</summary>
## .env (redacted, optional)
```env
```
</details>
**Additional context**
Add any other context about the problem here.
+5
View File
@@ -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.
+27 -6
View File
@@ -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]
+20 -8
View File
@@ -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
+354 -1
View File
@@ -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<string>();
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<string>();
SetupHttpResponse(request =>
{
lock (requests)
{
requests.Add(request.RequestUri!.PathAndQuery);
}
return CreateJsonResponse(JsonSerializer.Serialize(new { data = Array.Empty<object>() }));
});
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<string>();
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<string>();
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<string>();
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<HttpRequestMessage, HttpResponseMessage> responseFactory)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.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<string>();
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<string>();
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()
{
@@ -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<FileStreamResult>(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<FileStreamResult>(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<OkObjectResult>(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<string, string?>
{
["Library:DownloadPath"] = downloadsRoot
})
.Build();
return new DownloadsController(
NullLogger<DownloadsController>.Instance,
config,
keptLyricsSidecarService)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
}
private static HashSet<string> 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<string?> 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<string?>(sidecarPath);
}
return Task.FromResult<string?>(null);
}
}
}
@@ -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<BadRequestObjectResult>(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<FileStreamResult>(result);
}
@@ -97,7 +97,13 @@ public class DownloadsControllerPathSecurityTests
return new DownloadsController(
NullLogger<DownloadsController>.Instance,
config);
config)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext()
}
};
}
private static string CreateTestRoot()
@@ -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<string, object?>
{
["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<string, object?>
{
["Id"] = id,
["Name"] = name
};
Assert.False(InjectedPlaylistItemHelper.LooksLikeLegacyExternalSourceLabeledItem(item));
}
}
+95 -2
View File
@@ -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<string> { "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<string[]>(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<string[]>(result["Artists"]);
Assert.Equal(["Matched Artist [D]"], artists);
var artistItems = Assert.IsType<Dictionary<string, object?>[]>(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"]);
}
@@ -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<BadRequestObjectResult>(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
+1 -1
View File
@@ -9,5 +9,5 @@ public static class AppVersion
/// <summary>
/// Current application version.
/// </summary>
public const string Version = "1.5.4";
public const string Version = "2.0.2";
}
+5 -1
View File
@@ -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
+88 -15
View File
@@ -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<DownloadsController> _logger;
private readonly IConfiguration _configuration;
private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService;
public DownloadsController(
ILogger<DownloadsController> 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
/// </summary>
[HttpGet("downloads/file")]
public IActionResult DownloadFile([FromQuery] string path)
public async Task<IActionResult> 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
/// </summary>
[HttpGet("downloads/all")]
public IActionResult DownloadAllFiles()
public async Task<IActionResult> 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<string>(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<string> 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<IActionResult> 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<string>? 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());
}
/// <summary>
/// Gets all Spotify track mappings (paginated)
/// </summary>
@@ -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");
}
/// <summary>
/// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming.
/// This is the primary endpoint used by Jellyfin Web and most clients.
@@ -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();
}
@@ -9,6 +9,8 @@ namespace allstarr.Controllers;
public partial class JellyfinController
{
private static readonly string[] KeptAudioExtensions = [".flac", ".mp3", ".m4a", ".opus"];
#region Spotify Playlist Injection
/// <summary>
@@ -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
/// <summary>
+9 -1
View File
@@ -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;
}
+143 -37
View File
@@ -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
/// </summary>
[HttpDelete("mappings/tracks")]
public async Task<IActionResult> DeleteTrackMapping([FromQuery] string playlist, [FromQuery] string spotifyId)
public async Task<IActionResult> 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<Dictionary<string, ManualMappingEntry>>(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<List<object>> BuildExternalTargetsForManualMappingAsync(ManualMappingEntry mapping)
{
var targets = new List<object>();
var seenProviders = new HashSet<string>(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<bool> 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<Dictionary<string, ManualMappingEntry>>(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<bool> 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<Dictionary<string, ManualMappingEntry>>(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<string, ManualMappingEntry> 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);
}
/// <summary>
/// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID
+33 -2
View File
@@ -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<BackendType>("Backend:Type");
var musicService = backendType == BackendType.Jellyfin
? _configuration.GetValue<MusicService>("Jellyfin:MusicService")
: _configuration.GetValue<MusicService>("Subsonic:MusicService");
return musicService switch
{
MusicService.Deezer => "deezer",
MusicService.Qobuz => "qobuz",
MusicService.SquidWTF => "squidwtf",
_ => null
};
}
/// <summary>
/// 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.
@@ -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
/// <summary>
/// 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.
/// </summary>
[HttpPost("lastfm/authenticate")]
public async Task<IActionResult> 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
+19 -3
View File
@@ -560,14 +560,30 @@ public class SpotifyAdminController : ControllerBase
/// Deletes a Spotify track mapping
/// </summary>
[HttpDelete("spotify/mappings/{spotifyId}")]
public async Task<IActionResult> DeleteSpotifyMapping(string spotifyId)
public async Task<IActionResult> 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 });
}
+28 -1
View File
@@ -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" });
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ public class Song
/// <summary>
/// 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
/// </summary>
public int? ExplicitContentLyrics { get; set; }
+20 -14
View File
@@ -40,13 +40,22 @@ public class ScrobblingSettings
/// </summary>
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);
/// <summary>
/// Whether Last.fm scrobbling is enabled.
@@ -54,18 +63,15 @@ public class LastFmSettings
public bool Enabled { get; set; }
/// <summary>
/// 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
/// </summary>
public string ApiKey { get; set; } = DefaultApiKey;
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// 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.
/// </summary>
public string SharedSecret { get; set; } = DefaultSharedSecret;
public string SharedSecret { get; set; } = string.Empty;
/// <summary>
/// Last.fm session key (obtained via Mobile Authentication).
@@ -30,6 +30,13 @@ public class SpotifyTrackMapping
/// External provider track ID (if TargetType is "external")
/// </summary>
public string? ExternalId { get; set; }
/// <summary>
/// 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.
/// </summary>
public List<ExternalTrackMapping> ExternalMappings { get; set; } = new();
/// <summary>
/// Track metadata for display purposes
@@ -79,6 +86,55 @@ public class SpotifyTrackMapping
return false;
}
/// <summary>
/// Resolves the best external mapping target, preferring the requested provider when available.
/// </summary>
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; }
}
/// <summary>
+28 -14
View File
@@ -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<HttpClientFactoryOptions>(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<GlobalExceptionHandler>();
@@ -526,6 +529,13 @@ else
enableExternalPlaylists = builder.Configuration.GetValue<bool>("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<string>(), new List<string>());
var squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls;
var squidWtfStreamingUrls = squidWtfEndpointCatalog.StreamingUrls;
// Business services - shared across backends
builder.Services.AddSingleton(squidWtfEndpointCatalog);
builder.Services.AddSingleton<RedisCacheService>();
@@ -633,14 +643,17 @@ builder.Services.AddSingleton<EndpointBenchmarkService>();
builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>();
builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
builder.Services.AddSingleton<IStartupValidator>(sp =>
new SquidWTFStartupValidator(
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
squidWtfApiUrls,
squidWtfStreamingUrls,
sp.GetRequiredService<EndpointBenchmarkService>(),
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
if (musicService == MusicService.SquidWTF)
{
builder.Services.AddSingleton<IStartupValidator>(sp =>
new SquidWTFStartupValidator(
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient("SquidWTF"),
squidWtfApiUrls,
squidWtfStreamingUrls,
sp.GetRequiredService<EndpointBenchmarkService>(),
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
}
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
// Register orchestrator as hosted service
@@ -712,6 +725,7 @@ builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
builder.Services.AddSingleton<allstarr.Services.Lyrics.IKeptLyricsSidecarService, allstarr.Services.Lyrics.KeptLyricsSidecarService>();
// Register Spotify mapping service (global Spotify ID → Local/External mappings)
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyMappingService>();
@@ -945,7 +959,7 @@ app.UseMiddleware<BotProbeBlockMiddleware>();
// Request logging middleware (when DEBUG_LOG_ALL_REQUESTS=true)
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseExceptionHandler(_ => { }); // Global exception handler
app.UseExceptionHandler(); // Use registered GlobalExceptionHandler
// Enable response compression EARLY in the pipeline
app.UseResponseCompression();
@@ -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; }
}
/// <summary>
/// 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.
/// </summary>
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<string, AdminAuthSession> _sessions = new();
private readonly IDataProtector _protector;
private readonly ILogger<AdminAuthSessionService> _logger;
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private readonly object _persistLock = new();
private readonly string _sessionStoreFilePath;
public AdminAuthSessionService(
IDataProtectionProvider dataProtectionProvider,
ILogger<AdminAuthSessionService> logger)
: this(
dataProtectionProvider,
logger,
"/app/cache/admin-auth/sessions.protected")
{
}
private AdminAuthSessionService(
IDataProtectionProvider dataProtectionProvider,
ILogger<AdminAuthSessionService> 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<AdminAuthSessionService> logger)
: this(
CreateFallbackDataProtectionProvider(),
logger,
Path.Combine(Path.GetTempPath(), "allstarr-admin-auth", "sessions.protected"))
{
}
public AdminAuthSessionService()
: this(
CreateFallbackDataProtectionProvider(),
NullLogger<AdminAuthSessionService>.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<List<PersistedAdminAuthSession>>(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; }
}
}
@@ -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
@@ -19,6 +19,11 @@ public static class InjectedPlaylistItemHelper
return items.Any(LooksLikeLocalItemMissingGenreMetadata);
}
public static bool ContainsLegacyExternalSourceLabels(IEnumerable<Dictionary<string, object?>> items)
{
return items.Any(LooksLikeLegacyExternalSourceLabeledItem);
}
public static bool LooksLikeSyntheticLocalItem(IReadOnlyDictionary<string, object?> 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<string, object?> 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<string, object?> item, string key)
{
if (!item.TryGetValue(key, out var value) || value == null)
@@ -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);
}
}
/// <summary>
@@ -124,6 +124,11 @@ public class RoundRobinFallbackHelper
/// </summary>
private async Task<List<string>> GetHealthyEndpointsAsync()
{
if (_apiUrls.Count == 0)
{
return new List<string>();
}
var healthCheckTasks = _apiUrls.Select(async url => new
{
Url = url,
@@ -212,6 +217,11 @@ public class RoundRobinFallbackHelper
/// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> 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
}
/// <summary>
/// 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.
/// </summary>
/// <summary>
/// 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.
/// </summary>
public async Task<T> RaceTopEndpointsAsync<T>(int topN, Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
public async Task<T> RaceTopEndpointsAsync<T>(int topN, Func<string, CancellationToken, Task<T>> 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<Task<(T result, string endpoint, bool success)>>();
// 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<Task<(T result, string endpoint, bool success)>>();
// 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");
}
/// <summary>
/// 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
/// </summary>
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> 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<TResult>();
}
if (_apiUrls.Count == 0)
{
_logger.LogWarning("No {Service} endpoints are configured, skipping parallel processing", _serviceName);
return new List<TResult>();
}
var results = new List<TResult>();
var resultsLock = new object();
var itemQueue = new Queue<TItem>(items);
@@ -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)
@@ -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
+430 -69
View File
@@ -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<SubsonicSettings> settings,
GenreEnrichmentService? genreEnrichment = null)
GenreEnrichmentService? genreEnrichment = null,
IOptions<DeezerSettings>? deezerSettings = null)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
_genreEnrichment = genreEnrichment;
_minRequestIntervalMs = Math.Max(
0,
deezerSettings?.Value.MinRequestIntervalMs ?? new DeezerSettings().MinRequestIntervalMs);
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeSearchLimit(limit);
var allSongs = new List<Song>();
var seenIds = new HashSet<string>(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<List<Song>> 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<Song>();
@@ -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<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeSearchLimit(limit);
var allAlbums = new List<Album>();
var seenIds = new HashSet<string>(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<List<Album>> 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<Album>();
@@ -105,11 +204,44 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeSearchLimit(limit);
var allArtists = new List<Artist>();
var seenIds = new HashSet<string>(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<List<Artist>> 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<Artist>();
@@ -133,6 +265,44 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
}
}
private static IReadOnlyList<string> BuildSearchQueryVariants(string query)
{
var variants = new List<string>();
AddQueryVariant(variants, query);
if (query.Contains('&'))
{
AddQueryVariant(variants, query.Replace("&", " and "));
}
return variants;
}
private static void AddQueryVariant(List<string> 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<SearchResult> 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<Song?> 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<Album?> 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<Song> 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<Song>();
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<Artist?> 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<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "deezer") return new List<Album>();
var url = $"{BaseUrl}/artist/{externalId}/albums";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode) return new List<Album>();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return new List<Album>();
var albums = new List<Album>();
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<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "deezer") return new List<Song>();
if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return new List<Song>();
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<Song>();
@@ -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<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var normalizedLimit = NormalizeSearchLimit(limit);
var allPlaylists = new List<ExternalPlaylist>();
var seenIds = new HashSet<string>(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<List<ExternalPlaylist>> 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<ExternalPlaylist>();
@@ -613,12 +835,12 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
public async Task<ExternalPlaylist?> 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<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "deezer") return new List<Song>();
if (!string.Equals(externalProvider, "deezer", StringComparison.OrdinalIgnoreCase)) return new List<Song>();
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<Song>();
@@ -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<Song> 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<Song>();
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<int> ReadPagedDataAsync(
Func<int, string> buildIndexedPageUrl,
Action<JsonElement> addItem,
CancellationToken cancellationToken)
{
string? pageUrl = buildIndexedPageUrl(0);
var itemCount = 0;
var seenPageUrls = new HashSet<string>(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<HttpResponseMessage> 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<int, string> 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();
@@ -134,7 +134,7 @@ public class JellyfinResponseBuilder
var albumItem = new Dictionary<string, object?>
{
["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<string, object?>
@@ -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"
};
}
/// <summary>
/// Converts an Album domain model to a Jellyfin item.
/// </summary>
public Dictionary<string, object?> 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<string, object?>
@@ -621,11 +644,10 @@ public class JellyfinResponseBuilder
/// </summary>
public Dictionary<string, object?> 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<string, object?>
@@ -755,7 +777,7 @@ public class JellyfinResponseBuilder
var item = new Dictionary<string, object?>
{
["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",
@@ -0,0 +1,15 @@
using allstarr.Models.Domain;
namespace allstarr.Services.Lyrics;
public interface IKeptLyricsSidecarService
{
string GetSidecarPath(string audioFilePath);
Task<string?> EnsureSidecarAsync(
string audioFilePath,
Song? song = null,
string? externalProvider = null,
string? externalId = null,
CancellationToken cancellationToken = default);
}
@@ -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(
@"\[(?<provider>[A-Za-z0-9_-]+)-(?<externalId>[^\]]+)\]$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly LyricsOrchestrator _lyricsOrchestrator;
private readonly RedisCacheService _cache;
private readonly SpotifyImportSettings _spotifySettings;
private readonly OdesliService _odesliService;
private readonly ILogger<KeptLyricsSidecarService> _logger;
public KeptLyricsSidecarService(
LyricsOrchestrator lyricsOrchestrator,
RedisCacheService cache,
IOptions<SpotifyImportSettings> spotifySettings,
OdesliService odesliService,
ILogger<KeptLyricsSidecarService> 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<string?> 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<string?> 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<string?> 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<List<MatchedTrack>>(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<string>(),
DurationSeconds = (int)Math.Round(tagFile.Properties.Duration.TotalSeconds)
};
}
catch
{
return new AudioMetadata();
}
}
private static List<string> ResolveArtists(Song? song, AudioMetadata metadata)
{
var artists = new List<string>();
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<string> 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<string>();
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<string> Artists { get; init; } = new();
public int DurationSeconds { get; init; }
}
}
@@ -22,9 +22,10 @@ public class LastFmScrobblingService : IScrobblingService
private readonly ILogger<LastFmScrobblingService> _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
}
@@ -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
/// </summary>
public async Task<bool> 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<ExternalTrackMapping>
{
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<ExternalTrackMapping>
{
new()
{
Provider = externalProvider,
ExternalId = externalId,
Source = "manual",
CreatedAt = DateTime.UtcNow
}
}
: new List<ExternalTrackMapping>(),
Metadata = metadata,
Source = "manual",
CreatedAt = DateTime.UtcNow,
@@ -220,6 +285,65 @@ public class SpotifyMappingService
return success;
}
/// <summary>
/// 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.
/// </summary>
public async Task<bool> 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;
}
/// <summary>
/// Gets all Spotify IDs that have mappings.
/// </summary>
@@ -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<ExternalTrackMapping> 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<ExternalTrackMapping>();
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;
}
}
}
/// <summary>
@@ -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<SpotifyTrackMatchingService> _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<SpotifyTrackMatchingService> 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<BackendType>("Backend:Type");
var musicService = backendType == BackendType.Jellyfin
? _configuration.GetValue<MusicService>("Jellyfin:MusicService")
: _configuration.GetValue<MusicService>("Subsonic:MusicService");
return musicService switch
{
MusicService.Deezer => "deezer",
MusicService.Qobuz => "qobuz",
MusicService.SquidWTF => "squidwtf",
_ => null
};
}
}
@@ -73,7 +73,7 @@ public class SquidWTFDownloadService : BaseDownloadService
List<string> 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<string> 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<string> 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,
@@ -76,7 +76,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
List<string> 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<Song?>(
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<Song?>(
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<Song> 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<List<Song>> GetTrackRecommendationsAsync(string externalId, int limit = 20, CancellationToken cancellationToken = default)
@@ -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;
}
@@ -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;
@@ -6,9 +6,9 @@ public static class SquidWtfEndpointDiscovery
{
public static readonly IReadOnlyList<string> 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<SquidWtfEndpointCatalog> 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<string>(), new List<string>());
}
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}");
+155 -17
View File
@@ -28,6 +28,12 @@
<label for="auth-password">Password</label>
<input id="auth-password" type="password" required>
<label class="auth-checkbox" for="auth-remember-me">
<input id="auth-remember-me" type="checkbox">
<span>Keep me signed in for 30 days on this browser</span>
</label>
<small class="auth-note">Use only on a device you trust.</small>
<button class="primary" type="submit">Sign In</button>
<div class="auth-error" id="auth-error" role="alert"></div>
</form>
@@ -65,16 +71,27 @@
<div class="app-shell">
<aside class="sidebar" aria-label="Admin navigation">
<div class="sidebar-brand">
<div class="sidebar-title">Allstarr</div>
<div class="sidebar-title">
<a class="title-link" href="https://github.com/SoPat712/allstarr" target="_blank"
rel="noopener noreferrer">Allstarr</a>
</div>
<div class="sidebar-subtitle" id="sidebar-version">Loading...</div>
<div class="sidebar-status" id="status-indicator">
<span class="status-badge" id="spotify-status">
<span class="status-dot"></span>
<span>Loading...</span>
</span>
</div>
</div>
<nav class="sidebar-nav">
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button>
<button class="sidebar-link" type="button" data-tab="jellyfin-playlists">Link Playlists</button>
<button class="sidebar-link" type="button" data-tab="playlists">Injected Playlists</button>
<button class="sidebar-link" type="button" data-tab="song-migration">Song Migration</button>
<button class="sidebar-link" type="button" data-tab="kept">Kept Downloads</button>
<button class="sidebar-link" type="button" data-tab="scrobbling">Scrobbling</button>
<button class="sidebar-link" type="button" data-tab="config">Configuration</button>
<button class="sidebar-link" type="button" data-tab="report-issues">Report Issues</button>
<button class="sidebar-link" type="button" data-tab="endpoints">API Analytics</button>
</nav>
<div class="sidebar-footer">
@@ -86,27 +103,15 @@
</aside>
<main class="app-main">
<header class="app-header">
<h1>
Allstarr <span class="version" id="version">Loading...</span>
</h1>
<div class="header-actions">
<div id="status-indicator">
<span class="status-badge" id="spotify-status">
<span class="status-dot"></span>
<span>Loading...</span>
</span>
</div>
</div>
</header>
<div class="tabs top-tabs" aria-hidden="true">
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
<div class="tab" data-tab="playlists">Injected Playlists</div>
<div class="tab" data-tab="song-migration">Song Migration</div>
<div class="tab" data-tab="kept">Kept Downloads</div>
<div class="tab" data-tab="scrobbling">Scrobbling</div>
<div class="tab" data-tab="config">Configuration</div>
<div class="tab" data-tab="report-issues">Report Issues</div>
<div class="tab" data-tab="endpoints">API Analytics</div>
</div>
@@ -316,7 +321,7 @@
<th>Playlist</th>
<th>Spotify ID</th>
<th>Type</th>
<th>Target</th>
<th>Target IDs</th>
<th>Created</th>
<th>Actions</th>
</tr>
@@ -371,6 +376,63 @@
</div>
</div>
<!-- Song Migration Tab -->
<div class="tab-content" id="tab-song-migration">
<div class="card">
<h2>
Song Migration
<div class="actions">
<button class="primary" data-action="downloadSongMigrationCsv"
title="Download all non-Jellyfin tracks (external + missing) across all injected playlists as CSV">Download CSV (All)</button>
<button data-action="fetchSongMigration">Refresh</button>
</div>
</h2>
<p class="text-secondary mb-12">
Tracks from your injected Spotify playlists that are <strong>not</strong> 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.
</p>
<div id="song-migration-guidance" class="guidance-stack"></div>
<div id="song-migration-summary" class="summary-box">
<div>
<span class="summary-label">Playlists:</span>
<span class="summary-value accent" id="song-migration-playlist-count">0</span>
</div>
<div>
<span class="summary-label">External:</span>
<span class="summary-value success" id="song-migration-external-count">0</span>
</div>
<div>
<span class="summary-label">Missing:</span>
<span class="summary-value warning" id="song-migration-missing-count">0</span>
</div>
<div>
<span class="summary-label">Total to Migrate:</span>
<span class="summary-value accent" id="song-migration-total-count">0</span>
</div>
</div>
<div class="table-scroll song-migration-table-scroll">
<table class="playlist-table">
<thead>
<tr>
<th>Name</th>
<th>Non-Local Tracks</th>
<th>Status</th>
<th>...</th>
</tr>
</thead>
<tbody id="song-migration-table-body">
<tr>
<td colspan="4" class="loading">
<span class="spinner"></span> Loading playlists...
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Kept Downloads Tab -->
<div class="tab-content" id="tab-kept">
<div class="card">
@@ -456,7 +518,7 @@
<div class="card">
<h2>Last.fm</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
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 <code>SCROBBLING_LASTFM_API_KEY</code> and <code>SCROBBLING_LASTFM_SHARED_SECRET</code> in your .env (from <a href="https://www.last.fm/api/account/create" target="_blank" rel="noopener" style="color: var(--accent);">last.fm/api/account/create</a>), restart, then enter username/password and click "Authenticate & Save".
</p>
<div class="config-section" style="margin-bottom: 24px;">
@@ -900,6 +962,82 @@
</div>
</div>
<!-- Report Issues Tab -->
<div class="tab-content" id="tab-report-issues">
<div class="card">
<h2>Report Issues</h2>
<div class="guidance-banner info mb-16">
<span></span>
<div class="guidance-content">
<div class="guidance-title">Draft a GitHub issue from inside Allstarr.</div>
<div class="guidance-detail">Allstarr includes only safe diagnostics here. Sensitive values stay redacted, and the final submit still happens on GitHub.</div>
</div>
</div>
<div class="report-issue-layout">
<div class="report-issue-panel">
<div class="form-group">
<label for="issue-report-type">Report Type</label>
<select id="issue-report-type">
<option value="bug">Bug Report</option>
<option value="feature">Feature Request</option>
</select>
</div>
<div class="form-group">
<label for="issue-report-title">Title</label>
<input type="text" id="issue-report-title" placeholder="Short summary of the issue">
</div>
<div class="form-group">
<label for="issue-report-primary" id="issue-report-primary-label">Describe the bug</label>
<textarea id="issue-report-primary" rows="5"
placeholder="What happened? What looked wrong?"></textarea>
</div>
<div class="form-group">
<label for="issue-report-secondary" id="issue-report-secondary-label">To Reproduce</label>
<textarea id="issue-report-secondary" rows="5"
placeholder="List the steps needed to reproduce the issue"></textarea>
</div>
<div class="form-group">
<label for="issue-report-tertiary" id="issue-report-tertiary-label">Expected behavior</label>
<textarea id="issue-report-tertiary" rows="4"
placeholder="What did you expect to happen instead?"></textarea>
</div>
<div class="form-group">
<label for="issue-report-context" id="issue-report-context-label">Additional context</label>
<textarea id="issue-report-context" rows="4"
placeholder="Anything else that might help, including screenshots or surrounding context"></textarea>
</div>
<div class="card-actions-row">
<button class="primary" type="button" id="open-github-issue-btn">Open Bug Report on GitHub</button>
<button type="button" id="copy-issue-report-btn">Copy Report</button>
<button type="button" id="clear-issue-report-btn">Clear Report</button>
</div>
</div>
<div class="report-preview-panel">
<div class="guidance-banner compact">
Safe diagnostics only: version, runtime config, service state, and a concise client summary. Sensitive values stay redacted.
</div>
<div class="form-group">
<label for="issue-report-preview">GitHub Issue Preview</label>
<textarea id="issue-report-preview" rows="22" readonly></textarea>
</div>
<div class="report-preview-help" id="issue-report-preview-help">
GitHub drafts that exceed the URL size limit will open with a shorter body. The full report will also be copied to your clipboard.
</div>
</div>
</div>
</div>
</div>
<!-- API Analytics Tab -->
<div class="tab-content" id="tab-endpoints">
<div class="card">
+12 -4
View File
@@ -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",
);
+4 -1
View File
@@ -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 = "";
}
+82 -43
View File
@@ -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: '<span class="spinner" style="border-width:2px; height:12px; width:12px; display:inline-block; margin-right:4px;"></span> Downloading', // InProgress
2: '✅ Completed', // Completed
3: '❌ Failed' // Failed
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
? `<div class="download-progress-meta">${progressMeta.map(escapeHtml).join(" • ")}</div>`
: "";
const progressMetaText =
progressMeta.length > 0
? `<div class="download-progress-meta">${progressMeta.map(escapeHtml).join(" • ")}</div>`
: "";
const progressBar = `
const progressBar = `
<div class="download-progress-bar" aria-hidden="true">
<div class="download-progress-buffer" style="width:${downloadProgress * 100}%"></div>
<div class="download-progress-playback" style="width:${playbackProgress * 100}%"></div>
@@ -480,17 +512,19 @@ function renderDownloadActivity(downloads) {
${progressMetaText}
`;
const title = d.title || 'Unknown Title';
const artist = d.artist || 'Unknown Artist';
const errorText = d.errorMessage ? `<div style="color:var(--error); font-size:0.8rem; margin-top:4px;">${escapeHtml(d.errorMessage)}</div>` : '';
const streamBadge = d.requestedForStreaming
? '<span class="download-queue-badge">Stream</span>'
: '';
const playingBadge = d.isPlaying
? '<span class="download-queue-badge is-playing">Playing</span>'
: '';
const title = d.title || "Unknown Title";
const artist = d.artist || "Unknown Artist";
const errorText = d.errorMessage
? `<div style="color:var(--error); font-size:0.8rem; margin-top:4px;">${escapeHtml(d.errorMessage)}</div>`
: "";
const streamBadge = d.requestedForStreaming
? '<span class="download-queue-badge">Stream</span>'
: "";
const playingBadge = d.isPlaying
? '<span class="download-queue-badge is-playing">Playing</span>'
: "";
return `
return `
<div class="download-queue-item">
<div class="download-queue-info">
<div class="download-queue-title">${escapeHtml(title)}</div>
@@ -504,12 +538,13 @@ function renderDownloadActivity(downloads) {
${errorText}
</div>
<div class="download-queue-status">
<span style="font-size:0.85rem;">${statusIcons[d.status] || 'Unknown'}</span>
<span style="font-size:0.85rem;">${statusIcons[d.status] || "Unknown"}</span>
<span class="download-queue-time">${timeText}</span>
</div>
</div>
`;
}).join('');
})
.join("");
container.innerHTML = html;
}
@@ -523,7 +558,11 @@ function clampProgress(value) {
}
function formatSeconds(totalSeconds) {
if (typeof totalSeconds !== "number" || Number.isNaN(totalSeconds) || totalSeconds < 0) {
if (
typeof totalSeconds !== "number" ||
Number.isNaN(totalSeconds) ||
totalSeconds < 0
) {
return "0:00";
}
+501
View File
@@ -0,0 +1,501 @@
import { showToast } from "./utils.js";
const GITHUB_NEW_ISSUE_URL = "https://github.com/SoPat712/allstarr/issues/new";
const MAX_PREFILL_URL_LENGTH = 6500;
const ISSUE_TEMPLATES = {
bug: {
template: "bug-report.md",
titlePrefix: "[BUG] ",
openLabel: "Open Bug Report on GitHub",
primaryLabel: "Describe the bug",
primaryPlaceholder: "What happened? What looked wrong?",
secondaryLabel: "To Reproduce",
secondaryPlaceholder: "List the steps needed to reproduce the issue",
tertiaryLabel: "Expected behavior",
tertiaryPlaceholder: "What did you expect to happen instead?",
contextLabel: "Additional context",
contextPlaceholder:
"Anything else that might help, including screenshots or surrounding context",
},
feature: {
template: "feature-request.md",
titlePrefix: "[FEATURE] ",
openLabel: "Open Feature Request on GitHub",
primaryLabel: "Problem to solve",
primaryPlaceholder: "What problem are you trying to solve?",
secondaryLabel: "Solution you'd like",
secondaryPlaceholder: "What should Allstarr do instead?",
tertiaryLabel: "Alternatives considered",
tertiaryPlaceholder: "What alternatives or workarounds have you considered?",
contextLabel: "Additional context",
contextPlaceholder:
"Extra examples, mockups, or screenshots that explain the request",
},
};
const DIAGNOSTIC_SOURCE_IDS = [
"sidebar-version",
"backend-type",
"spotify-status",
"jellyfin-url",
"config-music-service",
"config-storage-mode",
"config-download-mode",
"config-redis-enabled",
"config-spotify-import-enabled",
"config-deezer-quality",
"config-squid-quality",
"config-qobuz-quality",
"scrobbling-enabled-value",
];
function getElement(id) {
return document.getElementById(id);
}
function normalizeText(value, fallback = "Unavailable") {
const normalized = String(value ?? "").trim();
if (!normalized || normalized === "-" || /^loading/i.test(normalized)) {
return fallback;
}
return normalized;
}
function getIssueType() {
return getElement("issue-report-type")?.value === "feature" ? "feature" : "bug";
}
function getIssueConfig(type = getIssueType()) {
return ISSUE_TEMPLATES[type] || ISSUE_TEMPLATES.bug;
}
function sanitizeTitle(title, type) {
const prefix = getIssueConfig(type).titlePrefix;
const trimmed = String(title ?? "").trim();
if (!trimmed) {
return prefix + (type === "feature" ? "Please add a short request title" : "Please add a short bug title");
}
if (trimmed.toUpperCase().startsWith(prefix.trim())) {
return trimmed;
}
return prefix + trimmed;
}
function getElementText(id, fallback = "Unavailable") {
return normalizeText(getElement(id)?.textContent, fallback);
}
function getMusicServiceQuality(musicService) {
const normalized = String(musicService ?? "").trim().toLowerCase();
if (normalized === "deezer") {
return getElementText("config-deezer-quality");
}
if (normalized === "qobuz") {
return getElementText("config-qobuz-quality");
}
if (normalized === "squidwtf") {
return getElementText("config-squid-quality");
}
return "";
}
function getClientSummary() {
const ua = String(window.navigator?.userAgent ?? "");
const browser =
ua.match(/Firefox\/(\d+)/)?.[0]?.replace("/", " ") ||
ua.match(/Edg\/(\d+)/)?.[0]?.replace("/", " ") ||
ua.match(/Chrome\/(\d+)/)?.[0]?.replace("/", " ") ||
(ua.includes("Safari/") && ua.match(/Version\/(\d+)/)?.[0]?.replace("/", " ")) ||
"Unknown browser";
let platform = "Unknown OS";
if (/Mac OS X/i.test(ua)) {
platform = "macOS";
} else if (/Windows/i.test(ua)) {
platform = "Windows";
} else if (/Android/i.test(ua)) {
platform = "Android";
} else if (/iPhone|iPad|iPod/i.test(ua)) {
platform = "iOS";
} else if (/Linux/i.test(ua)) {
platform = "Linux";
}
return `${browser} on ${platform}`;
}
function getRedactedUrlState() {
const jellyfinUrl = normalizeText(getElement("jellyfin-url")?.textContent, "");
return jellyfinUrl ? "Configured (redacted)" : "Not configured";
}
function getDiagnostics() {
const timezone =
Intl.DateTimeFormat().resolvedOptions().timeZone || "Unavailable";
const musicService = getElementText("config-music-service");
return {
version: getElementText("sidebar-version"),
backendType: normalizeText(
getElement("backend-type")?.textContent ||
getElement("config-backend-type")?.textContent,
),
musicService,
musicServiceQuality: getMusicServiceQuality(musicService),
storageMode: getElementText("config-storage-mode"),
downloadMode: getElementText("config-download-mode"),
redisEnabled: getElementText("config-redis-enabled"),
spotifyImportEnabled: getElementText("config-spotify-import-enabled"),
scrobblingEnabled: getElementText("scrobbling-enabled-value"),
spotifyStatus: getElementText("spotify-status"),
jellyfinUrl: getRedactedUrlState(),
client: getClientSummary(),
generatedAt: new Date().toISOString(),
timezone,
};
}
function getReportState() {
const type = getIssueType();
return {
type,
titleInput: String(getElement("issue-report-title")?.value ?? "").trim(),
primary: String(getElement("issue-report-primary")?.value ?? "").trim(),
secondary: String(getElement("issue-report-secondary")?.value ?? "").trim(),
tertiary: String(getElement("issue-report-tertiary")?.value ?? "").trim(),
context: String(getElement("issue-report-context")?.value ?? "").trim(),
};
}
function renderIssueBody(state, includeDiagnostics = true) {
const diagnostics = getDiagnostics();
const diagnosticsLines = [
"- Sensitive values stay redacted in this block.",
`- Allstarr Version: ${diagnostics.version}`,
`- Backend Type: ${diagnostics.backendType}`,
`- Music Service: ${diagnostics.musicService}`,
diagnostics.musicServiceQuality
? `- Music Service Quality: ${diagnostics.musicServiceQuality}`
: null,
`- Storage Mode: ${diagnostics.storageMode}`,
`- Download Mode: ${diagnostics.downloadMode}`,
`- Redis Enabled: ${diagnostics.redisEnabled}`,
`- Spotify Import Enabled: ${diagnostics.spotifyImportEnabled}`,
`- Scrobbling Enabled: ${diagnostics.scrobblingEnabled}`,
`- Spotify Status: ${diagnostics.spotifyStatus}`,
`- Jellyfin URL: ${diagnostics.jellyfinUrl}`,
`- Client: ${diagnostics.client}`,
`- Generated At (UTC): ${diagnostics.generatedAt}`,
`- Browser Time Zone: ${diagnostics.timezone}`,
];
const diagnosticsMarkdown = diagnosticsLines.filter(Boolean).join("\n");
if (state.type === "feature") {
const sections = [
[
"## Problem to solve",
state.primary || "_Please describe the problem you want to solve._",
],
[
"## Solution you'd like",
state.secondary || "_Please describe the solution you want._",
],
[
"## Alternatives considered",
state.tertiary || "_Please describe alternatives or workarounds you've considered._",
],
[
"## Additional context",
state.context || "_Add any other context, screenshots, or examples here._",
],
];
if (includeDiagnostics) {
sections.push(["## Safe diagnostics from Allstarr", diagnosticsMarkdown]);
}
return sections.map(([heading, content]) => `${heading}\n${content}`).join("\n\n");
}
const sections = [
[
"## Describe the bug",
state.primary || "_Please describe the bug._",
],
[
"## To Reproduce",
state.secondary ||
"_Please list the steps needed to reproduce the issue._",
],
[
"## Expected behavior",
state.tertiary || "_Please describe what you expected to happen._",
],
[
"## Additional context",
state.context || "_Add any other context, screenshots, or examples here._",
],
];
if (includeDiagnostics) {
sections.push(["## Safe diagnostics from Allstarr", diagnosticsMarkdown]);
}
return sections.map(([heading, content]) => `${heading}\n${content}`).join("\n\n");
}
function buildIssuePayload() {
const state = getReportState();
const config = getIssueConfig(state.type);
const title = sanitizeTitle(state.titleInput, state.type);
const fullBody = renderIssueBody(state, true);
const fullUrl = new URL(GITHUB_NEW_ISSUE_URL);
fullUrl.searchParams.set("template", config.template);
fullUrl.searchParams.set("title", title);
fullUrl.searchParams.set("body", fullBody);
if (fullUrl.toString().length <= MAX_PREFILL_URL_LENGTH) {
return {
title,
fullBody,
url: fullUrl.toString(),
truncated: false,
};
}
const shortenedBody = [
renderIssueBody(state, false),
"> Full safe diagnostics were copied to your clipboard by Allstarr.",
"> Paste them below if GitHub opens with a shorter draft.",
].join("\n\n");
const shortenedUrl = new URL(GITHUB_NEW_ISSUE_URL);
shortenedUrl.searchParams.set("template", config.template);
shortenedUrl.searchParams.set("title", title);
shortenedUrl.searchParams.set("body", shortenedBody);
return {
title,
fullBody,
url: shortenedUrl.toString(),
truncated: true,
};
}
async function copyTextToClipboard(text) {
if (!text) {
return false;
}
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall back to a hidden textarea if direct clipboard access fails.
}
}
const helper = document.createElement("textarea");
helper.value = text;
helper.setAttribute("readonly", "");
helper.style.position = "absolute";
helper.style.left = "-9999px";
document.body.appendChild(helper);
helper.select();
let copied = false;
try {
copied = document.execCommand("copy");
} catch {
copied = false;
}
document.body.removeChild(helper);
return copied;
}
async function copyIssueReport({ silent = false } = {}) {
const payload = buildIssuePayload();
const copied = await copyTextToClipboard(`${payload.title}\n\n${payload.fullBody}`);
if (!silent) {
showToast(
copied
? "Issue draft copied to clipboard"
: "Could not copy the report. You can still copy it from the preview.",
copied ? "success" : "warning",
4000,
);
}
return copied;
}
function clearIssueReport() {
const titleInput = getElement("issue-report-title");
const primaryInput = getElement("issue-report-primary");
const secondaryInput = getElement("issue-report-secondary");
const tertiaryInput = getElement("issue-report-tertiary");
const contextInput = getElement("issue-report-context");
const hasDraft = [
titleInput?.value,
primaryInput?.value,
secondaryInput?.value,
tertiaryInput?.value,
contextInput?.value,
].some((value) => String(value ?? "").trim().length > 0);
if (hasDraft && !window.confirm("Clear the current report draft?")) {
return;
}
if (titleInput) titleInput.value = "";
if (primaryInput) primaryInput.value = "";
if (secondaryInput) secondaryInput.value = "";
if (tertiaryInput) tertiaryInput.value = "";
if (contextInput) contextInput.value = "";
refreshIssueReportPreview();
titleInput?.focus();
showToast("Report draft cleared", "success", 2500);
}
function validateTitle() {
const titleInput = getElement("issue-report-title");
if (!titleInput?.value?.trim()) {
titleInput?.focus();
showToast("Add a short title before opening the GitHub draft.", "warning");
return false;
}
return true;
}
async function openGithubIssueDraft() {
if (!validateTitle()) {
return;
}
const copied = await copyIssueReport({ silent: true });
const payload = buildIssuePayload();
const openedWindow = window.open(payload.url, "_blank", "noopener,noreferrer");
if (!openedWindow) {
showToast(
"GitHub draft popup was blocked. Allow popups for this site, then try again.",
"warning",
5000,
);
return;
}
const message = payload.truncated
? "Opened a shorter GitHub draft and copied the full report to your clipboard."
: copied
? "Opened the GitHub draft and copied the report to your clipboard."
: "Opened the GitHub draft. If anything is missing, use Copy Report.";
showToast(message, payload.truncated ? "warning" : "success", 5000);
}
function updateIssueReporterCopy() {
const type = getIssueType();
const config = getIssueConfig(type);
getElement("issue-report-primary-label").textContent = config.primaryLabel;
getElement("issue-report-primary").placeholder = config.primaryPlaceholder;
getElement("issue-report-secondary-label").textContent = config.secondaryLabel;
getElement("issue-report-secondary").placeholder = config.secondaryPlaceholder;
getElement("issue-report-tertiary-label").textContent = config.tertiaryLabel;
getElement("issue-report-tertiary").placeholder = config.tertiaryPlaceholder;
getElement("issue-report-context-label").textContent = config.contextLabel;
getElement("issue-report-context").placeholder = config.contextPlaceholder;
getElement("open-github-issue-btn").textContent = config.openLabel;
getElement("issue-report-title").placeholder =
type === "feature"
? "Short summary of the feature request"
: "Short summary of the issue";
}
export function refreshIssueReportPreview() {
const preview = getElement("issue-report-preview");
const previewHelp = getElement("issue-report-preview-help");
if (!preview || !previewHelp) {
return;
}
updateIssueReporterCopy();
const payload = buildIssuePayload();
preview.value = `${payload.title}\n\n${payload.fullBody}`;
previewHelp.textContent = payload.truncated
? "This report is long enough that Allstarr will open GitHub with a shorter draft and copy the full report to your clipboard."
: "This draft fits in a normal GitHub issue URL. Allstarr will still copy the full report to your clipboard when you open it.";
}
export function initIssueReporter() {
const typeSelect = getElement("issue-report-type");
const titleInput = getElement("issue-report-title");
const primaryInput = getElement("issue-report-primary");
const secondaryInput = getElement("issue-report-secondary");
const tertiaryInput = getElement("issue-report-tertiary");
const contextInput = getElement("issue-report-context");
const copyButton = getElement("copy-issue-report-btn");
const clearButton = getElement("clear-issue-report-btn");
const openButton = getElement("open-github-issue-btn");
if (
!typeSelect ||
!titleInput ||
!primaryInput ||
!secondaryInput ||
!tertiaryInput ||
!contextInput ||
!copyButton ||
!clearButton ||
!openButton
) {
return;
}
[typeSelect, titleInput, primaryInput, secondaryInput, tertiaryInput, contextInput].forEach(
(input) => {
input.addEventListener("input", refreshIssueReportPreview);
input.addEventListener("change", refreshIssueReportPreview);
},
);
copyButton.addEventListener("click", () => {
copyIssueReport();
});
clearButton.addEventListener("click", () => {
clearIssueReport();
});
openButton.addEventListener("click", () => {
openGithubIssueDraft();
});
const diagnosticsObserver = new MutationObserver(() => {
refreshIssueReportPreview();
});
DIAGNOSTIC_SOURCE_IDS.forEach((id) => {
const source = getElement(id);
if (!source) {
return;
}
diagnosticsObserver.observe(source, {
childList: true,
subtree: true,
characterData: true,
});
});
window.addEventListener("hashchange", refreshIssueReportPreview);
refreshIssueReportPreview();
}
+31 -2
View File
@@ -32,11 +32,16 @@ import {
initPlaylistAdmin,
resetPlaylistAdminState,
} from "./playlist-admin.js";
import {
initSongMigration,
resetSongMigrationState,
} from "./song-migration.js";
import { initScrobblingAdmin } from "./scrobbling-admin.js";
import { initAuthSession } from "./auth-session.js";
import { initActionDispatcher } from "./action-dispatcher.js";
import { initNavigationView } from "./views/navigation-view.js";
import { initScrobblingView } from "./views/scrobbling-view.js";
import { initIssueReporter } from "./issue-reporter.js";
let cookieDateInitialized = false;
let restartRequired = false;
@@ -78,6 +83,13 @@ window.switchTab = function (tabName) {
if (tabName === "kept" && typeof window.fetchDownloads === "function") {
window.fetchDownloads();
}
if (
tabName === "song-migration" &&
typeof window.fetchSongMigration === "function"
) {
window.fetchSongMigration();
}
}
};
@@ -137,12 +149,19 @@ initPlaylistAdmin({
fetchJellyfinPlaylists: dashboard.fetchJellyfinPlaylists,
});
initSongMigration({
isAdminSession: () => authSession?.isAdminSession() ?? false,
});
initIssueReporter();
const authSession = initAuthSession({
stopDashboardRefresh: dashboard.stopDashboardRefresh,
loadDashboardData: dashboard.loadDashboardData,
switchTab: window.switchTab,
onUnauthenticated: () => {
resetPlaylistAdminState();
resetSongMigrationState();
setCurrentConfigState(null);
},
});
@@ -175,7 +194,9 @@ document.addEventListener("DOMContentLoaded", () => {
window.switchTab(tab);
}
});
dispatcher.register("logoutAdminSession", () => window.logoutAdminSession?.());
dispatcher.register("logoutAdminSession", () =>
window.logoutAdminSession?.(),
);
dispatcher.register("dismissRestartBanner", () =>
window.dismissRestartBanner?.(),
);
@@ -189,7 +210,9 @@ document.addEventListener("DOMContentLoaded", () => {
dispatcher.register("toggleDetailsRow", ({ event, args }) =>
window.toggleDetailsRow?.(event, args?.detailsRowId),
);
dispatcher.register("viewTracks", ({ args }) => viewTracks(args?.playlistName));
dispatcher.register("viewTracks", ({ args }) =>
viewTracks(args?.playlistName),
);
dispatcher.register("refreshPlaylist", ({ args }) =>
window.refreshPlaylist?.(args?.playlistName),
);
@@ -244,6 +267,12 @@ document.addEventListener("DOMContentLoaded", () => {
dispatcher.register("deleteDownload", ({ args }) =>
window.deleteDownload?.(args?.path),
);
dispatcher.register("fetchSongMigration", () =>
window.fetchSongMigration?.(),
);
dispatcher.register("downloadSongMigrationCsv", () =>
window.downloadSongMigrationCsv?.(),
);
initNavigationView({ switchTab: window.switchTab });
+87
View File
@@ -0,0 +1,87 @@
// Shared helpers for displaying Spotify/global external mapping targets.
import { escapeHtml, capitalizeProvider } from "./utils.js";
/**
* Normalizes external targets from API payloads (camelCase or PascalCase).
*/
export function collectExternalTargets(mapping) {
const targets = [];
const seenProviders = new Set();
const addTarget = (provider, externalId, source) => {
if (!provider || !externalId) {
return;
}
const key = String(provider).toLowerCase();
if (seenProviders.has(key)) {
return;
}
seenProviders.add(key);
targets.push({
provider: String(provider),
externalId: String(externalId),
source: source || "",
});
};
const externalTargets =
mapping.externalTargets ||
mapping.ExternalTargets ||
mapping.externalMappings ||
mapping.ExternalMappings ||
[];
for (const ext of externalTargets) {
addTarget(
ext.provider ?? ext.Provider,
ext.externalId ?? ext.ExternalId,
ext.source ?? ext.Source,
);
}
addTarget(
mapping.externalProvider ?? mapping.ExternalProvider,
mapping.externalId ?? mapping.ExternalId,
mapping.source ?? mapping.Source ?? "manual",
);
return targets;
}
/**
* Renders a stacked list of provider targets for dashboard tables.
*/
export function renderExternalTargetsHtml(targets, options = {}) {
const { showRemove = false, playlist = "", spotifyId = "" } = options;
if (!Array.isArray(targets) || targets.length === 0) {
return '<span style="color:var(--text-secondary);">—</span>';
}
return `<div class="target-list">${targets
.map((target) => {
const label = capitalizeProvider(target.provider) || target.provider;
const removeBtn = showRemove
? `<button type="button" class="target-remove-btn delete-mapping-provider-btn"
data-playlist="${escapeHtml(playlist)}"
data-spotify-id="${escapeHtml(spotifyId)}"
data-provider="${escapeHtml(target.provider)}"
title="Remove ${escapeHtml(label)} mapping">×</button>`
: "";
const sourceHint = target.source
? `<span class="target-source">${escapeHtml(target.source)}</span>`
: "";
return `<div class="target-item">
<span class="status-pill info">${escapeHtml(label)}</span>
<span class="mono target-id">${escapeHtml(target.externalId)}</span>
${sourceHint}
${removeBtn}
</div>`;
})
.join("")}</div>`;
}
+7 -4
View File
@@ -112,13 +112,16 @@ async function deleteDownload(path) {
}
}
async function deleteTrackMapping(playlist, spotifyId) {
const confirmMessage = `Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\n• The track may be re-matched with potentially better results\n\nThis action cannot be undone.`;
async function deleteTrackMapping(playlist, spotifyId, provider = null) {
const providerLabel = provider ? ` (${provider})` : "";
const confirmMessage = provider
? `Remove the ${provider} mapping for ${spotifyId} in playlist "${playlist}"?\n\nOther provider mappings for this track will be kept.`
: `Remove all mappings for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Remove the global Spotify mapping\n• Allow the track to be matched automatically again\n\nThis action cannot be undone.`;
const result = await runAction({
confirmMessage,
task: () => API.deleteTrackMapping(playlist, spotifyId),
success: "Mapping removed successfully",
task: () => API.deleteTrackMapping(playlist, spotifyId, provider),
success: `Mapping${providerLabel} removed successfully`,
error: (err) => err.message || "Failed to remove mapping",
});
+21 -10
View File
@@ -58,7 +58,10 @@ function parseBoolean(value) {
async function loadScrobblingConfig() {
try {
const data = await API.fetchConfig();
const [data, status] = await Promise.all([
API.fetchConfig(),
API.fetchScrobblingStatus(),
]);
document.getElementById("scrobbling-enabled-value").textContent = data
.scrobbling.enabled
@@ -118,10 +121,18 @@ async function loadScrobblingConfig() {
const hasSessionKey =
sessionKey && sessionKey !== "(not set)" && sessionKey.length > 0;
let status = "";
if (data.scrobbling.lastFm.enabled && hasSessionKey) {
status =
const lastFmStatus = status?.lastFm;
const usingLegacyKey = lastFmStatus?.usingHardcodedCredentials === true;
let statusHtml = "";
if (usingLegacyKey) {
statusHtml =
'<span style="color: var(--error);">✗ Suspended API key — set SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET in .env</span>';
} else if (data.scrobbling.lastFm.enabled && hasApiKey && hasSecret && hasSessionKey) {
statusHtml =
'<span style="color: var(--success);">✓ Configured & Enabled</span>';
} else if (data.scrobbling.lastFm.enabled && hasSessionKey && (!hasApiKey || !hasSecret)) {
statusHtml =
'<span style="color: var(--error);">✗ Missing API key/secret in .env — add SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET</span>';
} else if (
hasApiKey &&
hasSecret &&
@@ -129,18 +140,18 @@ async function loadScrobblingConfig() {
hasPassword &&
!hasSessionKey
) {
status =
statusHtml =
'<span style="color: var(--warning);">⚠️ Ready to Authenticate</span>';
} else if (hasApiKey && hasSecret && (!hasUsername || !hasPassword)) {
status =
statusHtml =
'<span style="color: var(--warning);">⚠️ Needs Username & Password</span>';
} else if (!hasApiKey || !hasSecret) {
status =
'<span style="color: var(--success);">✓ Using hardcoded credentials</span>';
statusHtml =
'<span style="color: var(--warning);">⚠️ Set SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET in .env</span>';
} else {
status = '<span style="color: var(--muted);">○ Not Configured</span>';
statusHtml = '<span style="color: var(--muted);">○ Not Configured</span>';
}
document.getElementById("lastfm-status-value").innerHTML = status;
document.getElementById("lastfm-status-value").innerHTML = statusHtml;
document.getElementById("listenbrainz-enabled-value").textContent = data
.scrobbling.listenBrainz.enabled
+622
View File
@@ -0,0 +1,622 @@
// Song Migration view module.
//
// Renders a list of all injected Spotify playlists where each playlist can be
// expanded to show every track that is NOT in the local Jellyfin library.
// That means tracks matched via external providers (SquidWTF, Deezer, Qobuz)
// and tracks that are still missing. A CSV download is provided so users can
// grab all non-Jellyfin tracks and feed them into their preferred download tool.
import { escapeHtml, showToast, capitalizeProvider } from "./utils.js";
import * as API from "./api.js";
let isAdminSession = () => false;
let songMigrationRequestToken = 0;
// Cache of playlist name -> tracks array, to avoid re-fetching for CSV export
// after the table has already been populated.
const trackCache = new Map();
// Tracks which playlist rows are currently expanded so refreshes preserve
// expansion state.
const expandedSongMigrationPlaylists = new Set();
// Tracks which playlists have been kicked off fetching so we don't double-fetch.
const inFlightTrackFetches = new Map();
function isNonLocalTrack(track) {
// A track is "non-local" if it is not confirmed local in Jellyfin.
// isLocal === true -> local (Jellyfin) : excluded
// isLocal === false -> external match : included
// isLocal === null/undefined -> missing : included
return track && track.isLocal !== true;
}
function summarizeTracks(tracks) {
let external = 0;
let missing = 0;
for (const track of tracks) {
if (!track) continue;
if (track.isLocal === false) {
external += 1;
} else if (track.isLocal === null || track.isLocal === undefined) {
missing += 1;
}
}
return { external, missing, total: external + missing };
}
async function fetchTracksForPlaylist(playlistName) {
if (trackCache.has(playlistName)) {
return trackCache.get(playlistName);
}
if (inFlightTrackFetches.has(playlistName)) {
return inFlightTrackFetches.get(playlistName);
}
const promise = (async () => {
try {
const data = await API.fetchPlaylistTracks(playlistName);
const tracks = Array.isArray(data?.tracks) ? data.tracks : [];
trackCache.set(playlistName, tracks);
return tracks;
} catch (error) {
console.error(
`Failed to fetch tracks for playlist "${playlistName}":`,
error,
);
return [];
} finally {
inFlightTrackFetches.delete(playlistName);
}
})();
inFlightTrackFetches.set(playlistName, promise);
return promise;
}
function formatDuration(ms) {
if (typeof ms !== "number" || Number.isNaN(ms) || ms < 0) {
return "-";
}
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function renderNonLocalTrackRow(track, index) {
const artists = Array.isArray(track.artists) ? track.artists.join(", ") : "";
const isMissing = track.isLocal === null || track.isLocal === undefined;
const providerLabel = isMissing
? '<span class="status-pill warning">Missing</span>'
: `<span class="status-pill info">${escapeHtml(
capitalizeProvider(track.externalProvider) ||
track.externalProvider ||
"External",
)}</span>`;
const spotifyUrl = track.spotifyId
? `https://open.spotify.com/track/${encodeURIComponent(track.spotifyId)}`
: null;
const spotifyLink = spotifyUrl
? `<a href="${escapeHtml(
spotifyUrl,
)}" target="_blank" rel="noopener" class="mono" style="color:var(--accent);text-decoration:underline;">${escapeHtml(
track.spotifyId,
)}</a>`
: "-";
const isrcCell = track.isrc
? `<span class="mono">${escapeHtml(track.isrc)}</span>`
: "-";
return `
<tr>
<td style="color:var(--text-secondary);">${index + 1}</td>
<td><strong>${escapeHtml(track.title || "-")}</strong></td>
<td>${escapeHtml(artists || "-")}</td>
<td style="color:var(--text-secondary);">${escapeHtml(track.album || "-")}</td>
<td>${providerLabel}</td>
<td>${isrcCell}</td>
<td>${spotifyLink}</td>
<td style="color:var(--text-secondary);">${escapeHtml(formatDuration(track.durationMs))}</td>
</tr>
`;
}
function renderNonLocalTracksPanel(tracks) {
if (!Array.isArray(tracks) || tracks.length === 0) {
return `
<div class="details-panel">
<p class="text-secondary" style="margin:0;padding:16px;">
🎉 Every track in this playlist is already in your Jellyfin library.
</p>
</div>
`;
}
return `
<div class="details-panel song-migration-tracks-panel">
<div class="table-scroll">
<table class="playlist-table song-migration-tracks-table">
<thead>
<tr>
<th style="width:40px;">#</th>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Status</th>
<th>ISRC</th>
<th>Spotify</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
${tracks.map(renderNonLocalTrackRow).join("")}
</tbody>
</table>
</div>
</div>
`;
}
function renderGuidance(playlists, totals) {
const container = document.getElementById("song-migration-guidance");
if (!container) return;
const messages = [];
if (!playlists || playlists.length === 0) {
messages.push({
tone: "info",
title: "No injected playlists yet.",
detail:
"Link a Jellyfin playlist to Spotify and run a match before migrating songs.",
});
} else if (totals.total === 0) {
messages.push({
tone: "success",
title: "Every injected track is already in your Jellyfin library.",
detail: "Nothing to migrate right now.",
});
} else {
messages.push({
tone: "info",
title: `${totals.total} tracks across ${playlists.length} playlists are not in Jellyfin.`,
detail:
"Expand a playlist to review its non-Jellyfin tracks, or use Download CSV to grab the whole list for bulk downloading.",
});
if (totals.missing > 0) {
messages.push({
tone: "warning",
title: `${totals.missing} tracks still could not be matched to any provider.`,
detail:
"These are labelled Missing in the CSV. You can map them manually from the Injected Playlists tab.",
});
}
}
container.innerHTML = messages
.map((msg) => {
const toneClass = msg.tone || "info";
return `
<div class="guidance-banner ${toneClass}">
<strong>${escapeHtml(msg.title)}</strong>
${msg.detail ? `<div>${escapeHtml(msg.detail)}</div>` : ""}
</div>
`;
})
.join("");
}
function renderPlaylistRow(playlist, index) {
const playlistName = playlist.name || "";
const detailsRowId = `song-migration-details-${index}`;
const detailsKey = playlistName;
const isExpanded = expandedSongMigrationPlaylists.has(detailsKey);
const external = playlist.externalMatched || 0;
const missing = playlist.externalMissing || 0;
const nonLocal = external + missing;
const spotifyTotal = playlist.trackCount || 0;
const escapedPlaylistName = escapeHtml(playlistName);
const statusBadges = [];
if (external > 0) {
statusBadges.push(
`<span class="status-pill info">${external} External</span>`,
);
}
if (missing > 0) {
statusBadges.push(
`<span class="status-pill warning">${missing} Missing</span>`,
);
}
if (statusBadges.length === 0) {
statusBadges.push('<span class="status-pill success">All Local</span>');
}
const detailsLabel = isExpanded ? "Hide" : "Details";
return `
<tr class="compact-row ${isExpanded ? "expanded" : ""}"
data-song-migration-row="${escapedPlaylistName}">
<td>
<div class="name-cell">
<strong>${escapedPlaylistName}</strong>
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
</div>
</td>
<td>
<span class="track-count">${nonLocal}</span>
<div class="meta-text">of ${spotifyTotal} Spotify tracks</div>
</td>
<td>${statusBadges.join(" ")}</td>
<td class="row-controls">
<button class="icon-btn song-migration-details-trigger"
data-song-migration-target="${escapedPlaylistName}"
aria-expanded="${isExpanded ? "true" : "false"}">${detailsLabel}</button>
<button class="icon-btn song-migration-csv-btn"
data-song-migration-csv="${escapedPlaylistName}"
title="Download this playlist's non-Jellyfin tracks as CSV">CSV</button>
</td>
</tr>
<tr id="${detailsRowId}"
class="details-row"
data-song-migration-details-for="${escapedPlaylistName}"
${isExpanded ? "" : "hidden"}>
<td colspan="4">
<div class="song-migration-details-content"
data-song-migration-details-content="${escapedPlaylistName}">
<div class="loading" style="padding:16px;">
<span class="spinner"></span> Loading tracks...
</div>
</div>
</td>
</tr>
`;
}
async function populateDetailsContent(playlistName) {
const container = document.querySelector(
`[data-song-migration-details-content="${CSS.escape(playlistName)}"]`,
);
if (!container) return;
const tracks = await fetchTracksForPlaylist(playlistName);
const nonLocal = tracks.filter(isNonLocalTrack);
container.innerHTML = renderNonLocalTracksPanel(nonLocal);
}
function bindRowEvents(tbody) {
tbody
.querySelectorAll(".song-migration-details-trigger")
.forEach((button) => {
button.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
const playlistName = button.getAttribute("data-song-migration-target");
if (!playlistName) return;
const detailsRow = tbody.querySelector(
`tr[data-song-migration-details-for="${CSS.escape(playlistName)}"]`,
);
const mainRow = tbody.querySelector(
`tr[data-song-migration-row="${CSS.escape(playlistName)}"]`,
);
if (!detailsRow) return;
const isHidden = detailsRow.hasAttribute("hidden");
if (isHidden) {
detailsRow.removeAttribute("hidden");
expandedSongMigrationPlaylists.add(playlistName);
button.setAttribute("aria-expanded", "true");
button.textContent = "Hide";
if (mainRow) mainRow.classList.add("expanded");
await populateDetailsContent(playlistName);
} else {
detailsRow.setAttribute("hidden", "");
expandedSongMigrationPlaylists.delete(playlistName);
button.setAttribute("aria-expanded", "false");
button.textContent = "Details";
if (mainRow) mainRow.classList.remove("expanded");
}
});
});
tbody.querySelectorAll(".song-migration-csv-btn").forEach((button) => {
button.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
const playlistName = button.getAttribute("data-song-migration-csv");
if (!playlistName) return;
await downloadPlaylistCsv(playlistName);
});
});
}
export async function fetchSongMigration() {
if (!isAdminSession()) {
return;
}
const tbody = document.getElementById("song-migration-table-body");
if (!tbody) return;
const requestToken = ++songMigrationRequestToken;
try {
const data = await API.fetchPlaylists();
if (requestToken !== songMigrationRequestToken) return;
const playlists = Array.isArray(data?.playlists) ? data.playlists : [];
// Invalidate caches so expanding rows reflects any fresh match state.
trackCache.clear();
const totalExternal = playlists.reduce(
(sum, playlist) => sum + (playlist.externalMatched || 0),
0,
);
const totalMissing = playlists.reduce(
(sum, playlist) => sum + (playlist.externalMissing || 0),
0,
);
const totalNonLocal = totalExternal + totalMissing;
const playlistCountEl = document.getElementById(
"song-migration-playlist-count",
);
if (playlistCountEl) {
playlistCountEl.textContent = String(playlists.length);
}
const externalCountEl = document.getElementById(
"song-migration-external-count",
);
if (externalCountEl) {
externalCountEl.textContent = String(totalExternal);
}
const missingCountEl = document.getElementById(
"song-migration-missing-count",
);
if (missingCountEl) {
missingCountEl.textContent = String(totalMissing);
}
const totalCountEl = document.getElementById("song-migration-total-count");
if (totalCountEl) {
totalCountEl.textContent = String(totalNonLocal);
}
renderGuidance(playlists, {
external: totalExternal,
missing: totalMissing,
total: totalNonLocal,
});
if (playlists.length === 0) {
tbody.innerHTML =
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
return;
}
tbody.innerHTML = playlists
.map((playlist, index) => renderPlaylistRow(playlist, index))
.join("");
bindRowEvents(tbody);
// Re-populate any previously expanded rows so state survives refresh.
for (const playlistName of expandedSongMigrationPlaylists) {
await populateDetailsContent(playlistName);
}
} catch (error) {
if (requestToken !== songMigrationRequestToken) return;
console.error("Failed to fetch song migration data:", error);
tbody.innerHTML = `
<tr>
<td colspan="4" style="text-align:center;color:var(--error);padding:40px;">
Failed to load playlists: ${escapeHtml(error?.message || "Unknown error")}
</td>
</tr>
`;
}
}
function csvEscape(value) {
if (value === null || value === undefined) {
return "";
}
const str = String(value);
if (/[",\r\n]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function buildCsvRows(entries) {
const headers = [
"Playlist",
"Position",
"Title",
"Artists",
"Album",
"ISRC",
"Spotify ID",
"Spotify URL",
"Duration (ms)",
"Duration",
"Status",
"Provider",
"Manual Mapping ID",
];
const lines = [headers.map(csvEscape).join(",")];
for (const entry of entries) {
const { playlistName, track } = entry;
const status = track.isLocal === false ? "External" : "Missing";
const provider =
track.isLocal === false ? track.externalProvider || "" : "";
const artists = Array.isArray(track.artists)
? track.artists.join(", ")
: "";
const spotifyUrl = track.spotifyId
? `https://open.spotify.com/track/${track.spotifyId}`
: "";
lines.push(
[
playlistName,
track.position ?? "",
track.title ?? "",
artists,
track.album ?? "",
track.isrc ?? "",
track.spotifyId ?? "",
spotifyUrl,
typeof track.durationMs === "number" ? track.durationMs : "",
formatDuration(track.durationMs),
status,
provider,
track.manualMappingId ?? "",
]
.map(csvEscape)
.join(","),
);
}
return lines.join("\r\n");
}
function triggerCsvDownload(filename, csvContent) {
// Prefix BOM so Excel reads UTF-8 correctly.
const blob = new Blob(["\uFEFF" + csvContent], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function sanitizeFilenameSegment(name) {
return (
String(name || "playlist")
.replace(/[^\w\-]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 80) || "playlist"
);
}
async function downloadPlaylistCsv(playlistName) {
try {
showToast(`Preparing CSV for "${playlistName}"...`, "success", 1500);
const tracks = await fetchTracksForPlaylist(playlistName);
const nonLocal = tracks.filter(isNonLocalTrack);
if (nonLocal.length === 0) {
showToast(`No non-Jellyfin tracks in "${playlistName}"`, "warning");
return;
}
const entries = nonLocal.map((track) => ({ playlistName, track }));
const csv = buildCsvRows(entries);
const filename = `song-migration-${sanitizeFilenameSegment(playlistName)}.csv`;
triggerCsvDownload(filename, csv);
showToast(
`Downloaded ${nonLocal.length} tracks from "${playlistName}"`,
"success",
);
} catch (error) {
console.error("Failed to build playlist CSV:", error);
showToast(
`Failed to build CSV: ${error?.message || "Unknown error"}`,
"error",
);
}
}
export async function downloadSongMigrationCsv() {
try {
const data = await API.fetchPlaylists();
const playlists = Array.isArray(data?.playlists) ? data.playlists : [];
if (playlists.length === 0) {
showToast("No injected playlists configured.", "warning");
return;
}
showToast("Building CSV, this may take a moment...", "success", 2000);
const entries = [];
let scanned = 0;
for (const playlist of playlists) {
const tracks = await fetchTracksForPlaylist(playlist.name);
const nonLocal = tracks.filter(isNonLocalTrack);
for (const track of nonLocal) {
entries.push({ playlistName: playlist.name, track });
}
scanned += 1;
}
if (entries.length === 0) {
showToast(
"Every track across all playlists is already in Jellyfin.",
"success",
);
return;
}
const csv = buildCsvRows(entries);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `song-migration-all-${timestamp}.csv`;
triggerCsvDownload(filename, csv);
showToast(
`Downloaded ${entries.length} tracks across ${scanned} playlists.`,
"success",
);
} catch (error) {
console.error("Failed to build combined CSV:", error);
showToast(
`Failed to build CSV: ${error?.message || "Unknown error"}`,
"error",
);
}
}
export function resetSongMigrationState() {
trackCache.clear();
inFlightTrackFetches.clear();
expandedSongMigrationPlaylists.clear();
songMigrationRequestToken = 0;
}
export function initSongMigration(options = {}) {
isAdminSession = options.isAdminSession || (() => false);
// Expose to window so tab-switch hooks and the ActionDispatcher can call
// these without tight-coupling to this module's import path.
window.fetchSongMigration = fetchSongMigration;
window.downloadSongMigrationCsv = downloadSongMigrationCsv;
return {
fetchSongMigration,
downloadSongMigrationCsv,
resetSongMigrationState,
};
}
+402 -88
View File
@@ -1,10 +1,15 @@
// UI updates and DOM manipulation
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
import {
collectExternalTargets,
renderExternalTargetsHtml,
} from "./mapping-targets.js";
let rowMenuHandlersBound = false;
let tableRowHandlersBound = false;
const expandedInjectedPlaylistDetails = new Set();
let openInjectedPlaylistMenuKey = null;
function bindRowMenuHandlers() {
if (rowMenuHandlersBound) {
@@ -57,8 +62,16 @@ function closeAllRowMenus(exceptId = null) {
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
if (!exceptId || menu.id !== exceptId) {
menu.classList.remove("open");
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
if (trigger) {
trigger.setAttribute("aria-expanded", "false");
}
}
});
if (!exceptId) {
openInjectedPlaylistMenuKey = null;
}
}
function closeRowMenu(event, menuId) {
@@ -69,6 +82,13 @@ function closeRowMenu(event, menuId) {
const menu = document.getElementById(menuId);
if (menu) {
menu.classList.remove("open");
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
if (trigger) {
trigger.setAttribute("aria-expanded", "false");
}
if (menu.dataset.menuKey) {
openInjectedPlaylistMenuKey = null;
}
}
}
@@ -85,6 +105,14 @@ function toggleRowMenu(event, menuId) {
const isOpen = menu.classList.contains("open");
closeAllRowMenus(menuId);
menu.classList.toggle("open", !isOpen);
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
if (trigger) {
trigger.setAttribute("aria-expanded", String(!isOpen));
}
if (menu.dataset.menuKey) {
openInjectedPlaylistMenuKey = isOpen ? null : menu.dataset.menuKey;
}
}
function toggleDetailsRow(event, detailsRowId) {
@@ -224,6 +252,275 @@ function getPlaylistStatusSummary(playlist) {
};
}
function syncElementAttributes(target, source) {
if (!target || !source) {
return;
}
const sourceAttributes = new Map(
Array.from(source.attributes || []).map((attribute) => [
attribute.name,
attribute.value,
]),
);
Array.from(target.attributes || []).forEach((attribute) => {
if (!sourceAttributes.has(attribute.name)) {
target.removeAttribute(attribute.name);
}
});
sourceAttributes.forEach((value, name) => {
target.setAttribute(name, value);
});
}
function syncPlaylistRowActionsWrap(existingWrap, nextWrap) {
if (!existingWrap || !nextWrap) {
return;
}
syncElementAttributes(existingWrap, nextWrap);
const activeElement = document.activeElement;
let focusTarget = null;
if (activeElement && existingWrap.contains(activeElement)) {
if (activeElement.classList.contains("menu-trigger")) {
focusTarget = { type: "trigger" };
} else if (activeElement.tagName === "BUTTON") {
focusTarget = {
type: "menu-item",
action: activeElement.getAttribute("data-action") || "",
text: activeElement.textContent || "",
};
}
}
const existingTrigger = existingWrap.querySelector(".menu-trigger");
const nextTrigger = nextWrap.querySelector(".menu-trigger");
if (existingTrigger && nextTrigger) {
syncElementAttributes(existingTrigger, nextTrigger);
existingTrigger.textContent = nextTrigger.textContent;
} else if (nextTrigger && !existingTrigger) {
existingWrap.prepend(nextTrigger.cloneNode(true));
} else if (existingTrigger && !nextTrigger) {
existingTrigger.remove();
}
const existingMenu = existingWrap.querySelector(".row-actions-menu");
const nextMenu = nextWrap.querySelector(".row-actions-menu");
if (existingMenu && nextMenu) {
syncElementAttributes(existingMenu, nextMenu);
existingMenu.replaceChildren(
...Array.from(nextMenu.children).map((child) => child.cloneNode(true)),
);
} else if (nextMenu && !existingMenu) {
existingWrap.append(nextMenu.cloneNode(true));
} else if (existingMenu && !nextMenu) {
existingMenu.remove();
}
if (!focusTarget) {
return;
}
if (focusTarget.type === "trigger") {
existingWrap.querySelector(".menu-trigger")?.focus();
return;
}
const matchingButton =
Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find(
(button) =>
(button.getAttribute("data-action") || "") === focusTarget.action &&
button.textContent === focusTarget.text,
) ||
Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find(
(button) =>
(button.getAttribute("data-action") || "") === focusTarget.action,
);
matchingButton?.focus();
}
function syncPlaylistControlsCell(
existingControlsCell,
nextControlsCell,
preserveOpenMenu = false,
) {
if (!existingControlsCell || !nextControlsCell) {
return;
}
syncElementAttributes(existingControlsCell, nextControlsCell);
if (!preserveOpenMenu) {
existingControlsCell.innerHTML = nextControlsCell.innerHTML;
return;
}
const existingDetailsTrigger =
existingControlsCell.querySelector(".details-trigger");
const nextDetailsTrigger = nextControlsCell.querySelector(".details-trigger");
const existingWrap = existingControlsCell.querySelector(".row-actions-wrap");
const nextWrap = nextControlsCell.querySelector(".row-actions-wrap");
if (
!existingDetailsTrigger ||
!nextDetailsTrigger ||
!existingWrap ||
!nextWrap
) {
existingControlsCell.innerHTML = nextControlsCell.innerHTML;
return;
}
syncElementAttributes(existingDetailsTrigger, nextDetailsTrigger);
existingDetailsTrigger.textContent = nextDetailsTrigger.textContent;
syncPlaylistRowActionsWrap(existingWrap, nextWrap);
}
function syncPlaylistMainRow(
existingMainRow,
nextMainRow,
preserveOpenMenu = false,
) {
if (!existingMainRow || !nextMainRow) {
return;
}
syncElementAttributes(existingMainRow, nextMainRow);
const nextCells = Array.from(nextMainRow.children);
const existingCells = Array.from(existingMainRow.children);
if (!preserveOpenMenu || nextCells.length !== existingCells.length) {
existingMainRow.innerHTML = nextMainRow.innerHTML;
return;
}
nextCells.forEach((nextCell, index) => {
const existingCell = existingCells[index];
if (!existingCell) {
existingMainRow.append(nextCell.cloneNode(true));
return;
}
if (index === nextCells.length - 1) {
syncPlaylistControlsCell(existingCell, nextCell, preserveOpenMenu);
return;
}
existingCell.replaceWith(nextCell.cloneNode(true));
});
while (existingMainRow.children.length > nextCells.length) {
existingMainRow.lastElementChild?.remove();
}
}
function syncPlaylistDetailsRow(existingDetailsRow, nextDetailsRow) {
if (!existingDetailsRow || !nextDetailsRow) {
return;
}
syncElementAttributes(existingDetailsRow, nextDetailsRow);
existingDetailsRow.innerHTML = nextDetailsRow.innerHTML;
}
function renderPlaylistRowPairMarkup(playlist, index) {
const summary = getPlaylistStatusSummary(playlist);
const detailsRowId = `playlist-details-${index}`;
const menuId = `playlist-menu-${index}`;
const detailsKey = `${playlist.id || playlist.name || index}`;
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
const isMenuOpen = openInjectedPlaylistMenuKey === detailsKey;
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
const escapedPlaylistName = escapeHtml(playlist.name);
const escapedSyncSchedule = escapeHtml(syncSchedule);
const escapedDetailsKey = escapeHtml(detailsKey);
const breakdownBadges = [
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
`<span class="status-pill info">${summary.externalMatched} External</span>`,
];
if (summary.externalMissing > 0) {
breakdownBadges.push(
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
);
}
return `
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
<td>
<div class="name-cell">
<strong>${escapeHtml(playlist.name)}</strong>
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
</div>
</td>
<td>
<span class="track-count">${summary.totalPlayable}/${summary.spotifyTotal}</span>
<div class="meta-text">${summary.completionPct}% playable</div>
</td>
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
<td class="row-controls">
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button>
<div class="row-actions-wrap">
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="${isMenuOpen ? "true" : "false"}"
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
<div class="row-actions-menu ${isMenuOpen ? "open" : ""}" id="${menuId}" data-menu-key="${escapedDetailsKey}" role="menu">
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button>
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button>
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button>
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button>
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button>
<hr>
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button>
</div>
</div>
</td>
</tr>
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
<td colspan="4">
<div class="details-panel">
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">Sync Schedule</span>
<span class="detail-value mono">
${escapeHtml(syncSchedule)}
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button>
</span>
</div>
<div class="detail-item">
<span class="detail-label">Cache Age</span>
<span class="detail-value">${escapeHtml(playlist.cacheAge || "-")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Track Breakdown</span>
<span class="detail-value">${breakdownBadges.join(" ")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Completion</span>
<div class="completion-bar">
<div class="completion-fill ${summary.completionClass}" style="width:${Math.max(0, Math.min(summary.completionPct, 100))}%;"></div>
</div>
</div>
</div>
</div>
</td>
</tr>
`;
}
function createPlaylistRowPair(playlist, index) {
const template = document.createElement("template");
template.innerHTML = renderPlaylistRowPairMarkup(playlist, index).trim();
const [mainRow, detailsRow] = template.content.querySelectorAll("tr");
return { mainRow, detailsRow };
}
if (typeof window !== "undefined") {
window.toggleRowMenu = toggleRowMenu;
window.closeRowMenu = closeRowMenu;
@@ -235,9 +532,6 @@ bindRowMenuHandlers();
bindTableRowHandlers();
export function updateStatusUI(data) {
const versionEl = document.getElementById("version");
if (versionEl) versionEl.textContent = "v" + data.version;
const sidebarVersionEl = document.getElementById("sidebar-version");
if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version;
@@ -321,10 +615,15 @@ export function updateStatusUI(data) {
export function updatePlaylistsUI(data) {
const tbody = document.getElementById("playlist-table-body");
if (!tbody) {
return;
}
const playlists = data.playlists || [];
if (playlists.length === 0) {
expandedInjectedPlaylistDetails.clear();
openInjectedPlaylistMenuKey = null;
tbody.innerHTML =
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
renderGuidance("playlists-guidance", [
@@ -378,91 +677,68 @@ export function updatePlaylistsUI(data) {
});
renderGuidance("playlists-guidance", guidance);
tbody.innerHTML = playlists
.map((playlist, index) => {
const summary = getPlaylistStatusSummary(playlist);
const detailsRowId = `playlist-details-${index}`;
const menuId = `playlist-menu-${index}`;
const detailsKey = `${playlist.id || playlist.name || index}`;
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
const escapedPlaylistName = escapeHtml(playlist.name);
const escapedSyncSchedule = escapeHtml(syncSchedule);
const escapedDetailsKey = escapeHtml(detailsKey);
const existingPairs = new Map();
Array.from(
tbody.querySelectorAll("tr.compact-row[data-details-key]"),
).forEach((mainRow) => {
const detailsKey = mainRow.getAttribute("data-details-key");
if (!detailsKey || existingPairs.has(detailsKey)) {
return;
}
const breakdownBadges = [
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
`<span class="status-pill info">${summary.externalMatched} External</span>`,
];
const detailsRowId = mainRow.getAttribute("data-details-row");
const detailsRow =
(detailsRowId && document.getElementById(detailsRowId)) ||
mainRow.nextElementSibling;
if (!detailsRow) {
return;
}
if (summary.externalMissing > 0) {
breakdownBadges.push(
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
);
}
existingPairs.set(detailsKey, { mainRow, detailsRow });
});
return `
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
<td>
<div class="name-cell">
<strong>${escapeHtml(playlist.name)}</strong>
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
</div>
</td>
<td>
<span class="track-count">${summary.totalPlayable}/${summary.spotifyTotal}</span>
<div class="meta-text">${summary.completionPct}% playable</div>
</td>
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
<td class="row-controls">
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button>
<div class="row-actions-wrap">
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
<div class="row-actions-menu" id="${menuId}" role="menu">
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button>
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button>
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button>
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button>
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button>
<hr>
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button>
</div>
</div>
</td>
</tr>
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
<td colspan="4">
<div class="details-panel">
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">Sync Schedule</span>
<span class="detail-value mono">
${escapeHtml(syncSchedule)}
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button>
</span>
</div>
<div class="detail-item">
<span class="detail-label">Cache Age</span>
<span class="detail-value">${escapeHtml(playlist.cacheAge || "-")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Track Breakdown</span>
<span class="detail-value">${breakdownBadges.join(" ")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Completion</span>
<div class="completion-bar">
<div class="completion-fill ${summary.completionClass}" style="width:${Math.max(0, Math.min(summary.completionPct, 100))}%;"></div>
</div>
</div>
</div>
</div>
</td>
</tr>
`;
})
.join("");
const orderedRows = [];
playlists.forEach((playlist, index) => {
const detailsKey = `${playlist.id || playlist.name || index}`;
const { mainRow: nextMainRow, detailsRow: nextDetailsRow } =
createPlaylistRowPair(playlist, index);
const existingPair = existingPairs.get(detailsKey);
if (!existingPair) {
orderedRows.push(nextMainRow, nextDetailsRow);
return;
}
syncPlaylistMainRow(
existingPair.mainRow,
nextMainRow,
detailsKey === openInjectedPlaylistMenuKey,
);
syncPlaylistDetailsRow(existingPair.detailsRow, nextDetailsRow);
orderedRows.push(existingPair.mainRow, existingPair.detailsRow);
existingPairs.delete(detailsKey);
});
const activeRows = new Set(orderedRows);
orderedRows.forEach((row) => {
tbody.append(row);
});
Array.from(tbody.children).forEach((row) => {
if (!activeRows.has(row)) {
row.remove();
}
});
if (
openInjectedPlaylistMenuKey &&
!playlists.some(
(playlist, index) =>
`${playlist.id || playlist.name || index}` === openInjectedPlaylistMenuKey,
)
) {
openInjectedPlaylistMenuKey = null;
}
}
export function updateTrackMappingsUI(data) {
@@ -491,7 +767,12 @@ export function updateTrackMappingsUI(data) {
.map((m) => {
const typeColor = "var(--success)";
const typeBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">external</span>`;
const targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
const targets = collectExternalTargets(m);
const targetDisplay = renderExternalTargetsHtml(targets, {
showRemove: true,
playlist: m.playlist,
spotifyId: m.spotifyId,
});
const createdDate = m.createdAt
? new Date(m.createdAt).toLocaleString()
: "-";
@@ -499,17 +780,50 @@ export function updateTrackMappingsUI(data) {
return `
<tr>
<td><strong>${escapeHtml(m.playlist)}</strong></td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${m.spotifyId}</td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${escapeHtml(m.spotifyId)}</td>
<td>${typeBadge}</td>
<td>${targetDisplay}</td>
<td style="color:var(--text-secondary);font-size:0.85rem;">${createdDate}</td>
<td>
<button class="danger delete-mapping-btn" style="padding:4px 12px;font-size:0.8rem;" data-playlist="${escapeHtml(m.playlist)}" data-spotify-id="${m.spotifyId}">Remove</button>
<button type="button" class="danger delete-mapping-btn" style="padding:4px 12px;font-size:0.8rem;"
data-playlist="${escapeHtml(m.playlist)}" data-spotify-id="${escapeHtml(m.spotifyId)}"
title="Remove all mappings for this track">Remove all</button>
</td>
</tr>
`;
})
.join("");
bindTrackMappingDeleteHandlers(tbody);
}
function bindTrackMappingDeleteHandlers(tbody) {
tbody.querySelectorAll(".delete-mapping-provider-btn").forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const playlist = button.getAttribute("data-playlist");
const spotifyId = button.getAttribute("data-spotify-id");
const provider = button.getAttribute("data-provider");
if (!playlist || !spotifyId || !provider) {
return;
}
window.deleteTrackMapping?.(playlist, spotifyId, provider);
});
});
tbody.querySelectorAll(".delete-mapping-btn").forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const playlist = button.getAttribute("data-playlist");
const spotifyId = button.getAttribute("data-spotify-id");
if (!playlist || !spotifyId) {
return;
}
window.deleteTrackMapping?.(playlist, spotifyId);
});
});
}
export function updateDownloadsUI(data) {
+71 -1
View File
@@ -324,6 +324,71 @@
color: var(--text-secondary);
}
.target-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.target-item {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.target-item .badge {
min-width: 72px;
text-align: center;
text-transform: lowercase;
}
.target-source {
font-size: 0.75rem;
color: var(--text-secondary);
}
.target-empty {
color: var(--text-secondary);
}
.target-remove-btn {
margin-left: auto;
min-width: 28px;
padding: 2px 8px;
font-size: 1rem;
line-height: 1;
border-radius: 6px;
background: rgba(248, 81, 73, 0.12);
border: 1px solid rgba(248, 81, 73, 0.45);
color: var(--error);
cursor: pointer;
}
.target-remove-btn:hover {
background: rgba(248, 81, 73, 0.25);
}
.external-existing-panel {
margin-bottom: 16px;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(13, 17, 23, 0.35);
}
.external-existing-panel h4 {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 10px;
font-weight: 600;
}
.external-existing-empty {
color: var(--text-secondary);
font-size: 0.85rem;
}
.pagination {
display: flex;
justify-content: center;
@@ -642,8 +707,13 @@
<span class="mono" id="external-map-spotify-id"></span>
</div>
<div class="external-existing-panel" id="external-map-existing-panel">
<h4>Existing external mappings</h4>
<div id="external-map-existing"></div>
</div>
<div class="modal-row">
<label for="external-map-provider">Provider</label>
<label for="external-map-provider">Add or update provider</label>
<select id="external-map-provider">
<option value="squidwtf">SquidWTF</option>
<option value="deezer">Deezer</option>
+200 -15
View File
@@ -108,6 +108,108 @@ function updatePagination(pagination) {
document.getElementById("pagination").style.display = "flex";
}
/**
* Collects all target IDs for a mapping (local + per-provider external).
*/
function getMappingTargets(mapping) {
const targets = [];
const seenProviders = new Set();
const addExternal = (provider, externalId, source) => {
if (!provider || !externalId) {
return;
}
const key = String(provider).toLowerCase();
if (seenProviders.has(key)) {
return;
}
seenProviders.add(key);
targets.push({
kind: "external",
label: provider,
value: externalId,
source,
});
};
if (mapping.TargetType === "local" && mapping.LocalId) {
targets.push({
kind: "local",
label: "local",
value: mapping.LocalId,
});
}
const externalMappings = mapping.ExternalMappings || [];
for (const ext of externalMappings) {
addExternal(
ext.Provider ?? ext.provider,
ext.ExternalId ?? ext.externalId,
ext.Source ?? ext.source,
);
}
addExternal(mapping.ExternalProvider, mapping.ExternalId);
return targets;
}
/**
* Renders target IDs (local Jellyfin ID and/or external provider IDs).
*/
function renderMappingTargetsHtml(mapping, options = {}) {
const { showRemove = false, kinds = null } = options;
let targets = getMappingTargets(mapping);
if (Array.isArray(kinds) && kinds.length > 0) {
targets = targets.filter((target) => kinds.includes(target.kind));
}
const escapedSpotifyId = escapeHtml(escapeJs(mapping.SpotifyId || ""));
const escapedTitle = escapeHtml(
escapeJs(mapping.Metadata?.Title || "this track"),
);
if (targets.length === 0) {
return '<span class="mono target-empty">—</span>';
}
return `<div class="target-list">${targets
.map((target) => {
const sourceHint =
target.source && target.kind === "external"
? ` <span class="target-source">${escapeHtml(target.source)}</span>`
: "";
const removeBtn =
showRemove && target.kind === "external"
? `<button type="button" class="target-remove-btn"
onclick="deleteExternalProvider('${escapedSpotifyId}', '${escapeHtml(escapeJs(target.label))}', '${escapedTitle}')"
title="Remove ${escapeHtml(target.label)} mapping">×</button>`
: "";
return `<div class="target-item">
<span class="badge ${target.kind}">${escapeHtml(target.label)}</span>
<span class="mono">${escapeHtml(target.value)}</span>${sourceHint}
${removeBtn}
</div>`;
})
.join("")}</div>`;
}
function renderExternalMapExistingHtml(mapping) {
const html = renderMappingTargetsHtml(mapping, {
showRemove: true,
kinds: ["external"],
});
if (html.includes("target-empty")) {
return '<p class="external-existing-empty">No external provider mappings yet.</p>';
}
return html;
}
/**
* Renders the mappings table
*/
@@ -130,11 +232,6 @@ function renderMappings(mappings) {
const artworkUrl = metadata.ArtworkUrl || "/placeholder.png";
const title = metadata.Title || "Unknown Track";
const artist = metadata.Artist || "Unknown Artist";
const targetInfo =
mapping.TargetType === "local"
? mapping.LocalId
: `${mapping.ExternalProvider}:${mapping.ExternalId}`;
const escapedSpotifyId = escapeHtml(escapeJs(mapping.SpotifyId || ""));
const escapedTitle = escapeHtml(escapeJs(title));
const escapedArtist = escapeHtml(escapeJs(artist));
@@ -158,7 +255,7 @@ function renderMappings(mappings) {
<span class="badge ${mapping.TargetType}">${escapeHtml(mapping.TargetType)}</span>
</td>
<td>
<span class="mono">${escapeHtml(targetInfo)}</span>
${renderMappingTargetsHtml(mapping, { showRemove: true })}
</td>
<td>
<span class="badge ${mapping.Source}">${escapeHtml(mapping.Source)}</span>
@@ -198,7 +295,7 @@ function renderMappings(mappings) {
<th class="sortable" onclick="sortBy('title')">Track${sortIndicator("title")}</th>
<th class="sortable" onclick="sortBy('spotifyid')">Spotify ID${sortIndicator("spotifyid")}</th>
<th class="sortable" onclick="sortBy('type')">Type${sortIndicator("type")}</th>
<th>Target ID</th>
<th>Target IDs</th>
<th class="sortable" onclick="sortBy('source')">Source${sortIndicator("source")}</th>
<th class="sortable" onclick="sortBy('created')">Created${sortIndicator("created")}</th>
<th>Actions</th>
@@ -432,7 +529,7 @@ async function saveLocalMap() {
}
}
function openExternalMapModal(spotifyId, title, artist) {
async function openExternalMapModal(spotifyId, title, artist) {
externalMapContext = { spotifyId, title, artist };
document.getElementById("external-map-title").textContent = title;
@@ -442,7 +539,34 @@ function openExternalMapModal(spotifyId, title, artist) {
document.getElementById("external-map-id").value = "";
document.getElementById("external-map-save-btn").disabled = true;
const existingContainer = document.getElementById("external-map-existing");
existingContainer.innerHTML =
'<p class="external-existing-empty">Loading existing mappings...</p>';
toggleModal("external-map-modal", true);
try {
const response = await fetch(
`/api/admin/spotify/mappings/${encodeURIComponent(spotifyId)}`,
);
if (response.status === 404) {
existingContainer.innerHTML =
'<p class="external-existing-empty">No external provider mappings yet.</p>';
return;
}
if (!response.ok) {
throw new Error(
await readErrorMessage(response, "Failed to load mapping"),
);
}
const mapping = await response.json();
existingContainer.innerHTML = renderExternalMapExistingHtml(mapping);
} catch (error) {
console.error("Failed to load mapping for external modal:", error);
existingContainer.innerHTML = `<p class="external-existing-empty">${escapeHtml(error.message || "Failed to load existing mappings")}</p>`;
}
}
function closeExternalMapModal() {
@@ -498,8 +622,19 @@ async function saveExternalMap() {
);
}
closeExternalMapModal();
showToast(`Mapped to external track: ${provider}:${externalId}`, "success");
document.getElementById("external-map-id").value = "";
validateExternalMapForm();
const existingContainer = document.getElementById("external-map-existing");
const mappingResponse = await fetch(
`/api/admin/spotify/mappings/${encodeURIComponent(externalMapContext.spotifyId)}`,
);
if (mappingResponse.ok) {
const mapping = await mappingResponse.json();
existingContainer.innerHTML = renderExternalMapExistingHtml(mapping);
}
await loadMappings();
} catch (error) {
console.error("Error saving external mapping:", error);
@@ -528,17 +663,67 @@ function escapeJs(text) {
}
/**
* Deletes a Spotify track mapping
* Deletes a single external provider from a Spotify track mapping.
*/
async function deleteMapping(spotifyId, title) {
if (!confirm(`Delete mapping for "${title}"?`)) {
async function deleteExternalProvider(spotifyId, provider, title) {
if (
!confirm(`Remove the ${provider} mapping for "${title}"?\n\nOther provider mappings will be kept.`)
) {
return;
}
try {
const response = await fetch(`/api/admin/spotify/mappings/${spotifyId}`, {
method: "DELETE",
});
const response = await fetch(
`/api/admin/spotify/mappings/${encodeURIComponent(spotifyId)}?provider=${encodeURIComponent(provider)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw new Error(
await readErrorMessage(response, "Failed to remove provider mapping"),
);
}
showToast(`Removed ${provider} mapping for "${title}"`, "success");
if (
externalMapContext &&
externalMapContext.spotifyId === spotifyId &&
document.getElementById("external-map-modal").classList.contains("active")
) {
await openExternalMapModal(
externalMapContext.spotifyId,
externalMapContext.title,
externalMapContext.artist,
);
}
await loadMappings();
} catch (error) {
console.error("Error removing provider mapping:", error);
showToast(error.message || "Failed to remove provider mapping", "error");
}
}
/**
* Deletes a Spotify track mapping (all targets)
*/
async function deleteMapping(spotifyId, title) {
if (
!confirm(
`Delete the entire mapping for "${title}"?\n\nThis removes local and all external provider IDs.`,
)
) {
return;
}
try {
const response = await fetch(
`/api/admin/spotify/mappings/${encodeURIComponent(spotifyId)}`,
{
method: "DELETE",
},
);
if (!response.ok) {
throw new Error(
+190 -36
View File
@@ -58,6 +58,26 @@ body {
gap: 10px;
}
.auth-checkbox {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-secondary);
font-size: 0.88rem;
margin-top: 4px;
}
.auth-checkbox input {
width: auto;
margin: 0;
}
.auth-note {
color: var(--text-secondary);
font-size: 0.8rem;
margin-top: -4px;
}
.auth-card label {
color: var(--text-secondary);
font-size: 0.85rem;
@@ -146,9 +166,9 @@ body {
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
max-width: 1280px;
margin: 0 auto 0 0;
padding: 20px 20px 20px 8px;
}
.app-shell {
@@ -182,6 +202,15 @@ body {
letter-spacing: 0.2px;
}
.title-link {
color: inherit;
text-decoration: none;
}
.title-link:hover {
color: var(--accent-hover);
}
.sidebar-subtitle {
margin-top: 2px;
color: var(--text-secondary);
@@ -190,6 +219,18 @@ body {
monospace;
}
.sidebar-status {
margin-top: 12px;
}
.sidebar-status .status-badge {
display: flex;
width: 100%;
justify-content: center;
padding: 8px 12px;
border-radius: 8px;
}
.sidebar-nav {
display: grid;
gap: 6px;
@@ -232,15 +273,6 @@ body {
min-width: 0;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0 16px;
border-bottom: 1px solid var(--border);
margin-bottom: 18px;
}
.top-tabs,
.tabs.top-tabs {
display: none !important;
@@ -255,21 +287,6 @@ body {
text-align: center;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 30px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.auth-user {
color: var(--text-secondary);
font-size: 0.85rem;
@@ -283,12 +300,6 @@ h1 {
gap: 10px;
}
h1 .version {
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: normal;
}
.status-badge {
display: inline-flex;
align-items: center;
@@ -464,6 +475,13 @@ button.danger:hover {
white-space: nowrap;
}
.table-scroll {
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.playlist-table {
width: 100%;
border-collapse: collapse;
@@ -633,6 +651,83 @@ button.danger:hover {
background: rgba(13, 17, 23, 0.35);
}
#tab-song-migration .card {
min-width: 0;
overflow: hidden;
}
#tab-song-migration .details-row > td {
min-width: 0;
}
#tab-song-migration .song-migration-table-scroll > .playlist-table {
table-layout: fixed;
}
#tab-song-migration .song-migration-details-content {
display: block;
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
#tab-song-migration .song-migration-tracks-panel {
margin: 0;
}
#tab-song-migration .song-migration-tracks-panel .table-scroll {
margin-top: 0;
}
#tab-song-migration .song-migration-tracks-table {
min-width: 880px;
}
#tab-song-migration .song-migration-tracks-table .mono {
word-break: break-all;
}
.target-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.target-item {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.target-item .target-id {
font-family:
ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", monospace;
font-size: 0.85rem;
color: var(--text-secondary);
}
.target-source {
font-size: 0.75rem;
color: var(--text-secondary);
}
.target-remove-btn {
margin-left: auto;
min-width: 28px;
padding: 2px 8px;
font-size: 1rem;
line-height: 1;
border-radius: 6px;
background: rgba(248, 81, 73, 0.12);
border: 1px solid rgba(248, 81, 73, 0.45);
color: var(--error);
}
.target-remove-btn:hover {
background: rgba(248, 81, 73, 0.25);
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
@@ -789,7 +884,8 @@ button.danger:hover {
}
input,
select {
select,
textarea {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
@@ -799,15 +895,24 @@ select {
}
input:focus,
select:focus {
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
}
input::placeholder {
input::placeholder,
textarea::placeholder {
color: var(--text-secondary);
}
textarea {
width: 100%;
resize: vertical;
line-height: 1.5;
font-family: inherit;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr 120px auto;
@@ -1040,6 +1145,10 @@ input::placeholder {
}
@media (max-width: 768px) {
.container {
padding: 12px;
}
.app-shell {
grid-template-columns: 1fr;
gap: 12px;
@@ -1050,6 +1159,14 @@ input::placeholder {
max-height: none;
}
.report-issue-layout {
grid-template-columns: 1fr;
}
.report-preview-panel textarea {
min-height: 360px;
}
.support-badge {
right: 12px;
bottom: 12px;
@@ -1072,6 +1189,43 @@ input::placeholder {
display: block;
}
.report-issue-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.95fr);
gap: 20px;
align-items: start;
}
.report-issue-panel,
.report-preview-panel {
display: grid;
gap: 16px;
}
.report-issue-panel .form-group,
.report-preview-panel .form-group {
margin-bottom: 0;
}
.report-issue-panel label,
.report-preview-panel label {
display: block;
margin-bottom: 6px;
color: var(--text-secondary);
}
.report-preview-panel textarea {
min-height: 520px;
font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
monospace;
font-size: 0.84rem;
}
.report-preview-help {
color: var(--text-secondary);
font-size: 0.84rem;
}
/* Utility classes to reduce inline styles in index.html */
.hidden {
display: none;
+1
View File
@@ -207,6 +207,7 @@ services:
- Scrobbling__LocalTracksEnabled=${SCROBBLING_LOCAL_TRACKS_ENABLED:-false}
- Scrobbling__SyntheticLocalPlayedSignalEnabled=${SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED:-false}
- Scrobbling__LastFm__Enabled=${SCROBBLING_LASTFM_ENABLED:-false}
# Required when Last.fm is enabled — create at https://www.last.fm/api/account/create
- Scrobbling__LastFm__ApiKey=${SCROBBLING_LASTFM_API_KEY:-}
- Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-}
- Scrobbling__LastFm__SessionKey=${SCROBBLING_LASTFM_SESSION_KEY:-}
+1
View File
@@ -140,6 +140,7 @@ services:
- Scrobbling__LocalTracksEnabled=${SCROBBLING_LOCAL_TRACKS_ENABLED:-false}
- Scrobbling__SyntheticLocalPlayedSignalEnabled=${SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED:-false}
- Scrobbling__LastFm__Enabled=${SCROBBLING_LASTFM_ENABLED:-false}
# Required when Last.fm is enabled — create at https://www.last.fm/api/account/create
- Scrobbling__LastFm__ApiKey=${SCROBBLING_LASTFM_API_KEY:-}
- Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-}
- Scrobbling__LastFm__SessionKey=${SCROBBLING_LASTFM_SESSION_KEY:-}