mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-07-02 10:36:42 -04:00
v2.0.2: fix SquidWTF nullable metadata warning
This commit is contained in:
+3
-3
@@ -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=
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
Reference in New Issue
Block a user