mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 12:02:51 -04:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
cc519c7818
|
|||
|
cae8eae509
|
|||
|
3e28af4f4f
|
|||
|
a87102c8d8
|
|||
|
1fb57aadf0
|
|||
|
a840a22cc2
|
|||
|
317369d120
|
|||
|
b23678e95a
|
|||
|
00a6cbc20e
|
|||
|
34d307fd4e
|
|||
|
ca9813f1ea
|
|||
|
dc4e5b907a
|
|||
|
d89dd5e7db
|
|||
|
b715802a4e
|
|||
|
5f817abda2
|
|||
|
69f0c53ade
|
|||
|
8baa8277e0
|
|||
|
baaea5747f
|
|||
|
f8a355f97e
|
|||
|
5a97573e58
|
|||
|
3cd4560406
|
|||
|
993a750008
|
|||
|
6737b2e0f4
|
|||
|
24811909b2
|
|||
|
4dbb3d72e7
|
|||
|
9d80ff65c5
|
|||
|
2eeda9dda0
|
|||
|
3c291d5fac
|
|||
|
2a430a1c38
|
|||
|
fd02ea9167
|
|||
|
1a0f7c0282
|
|||
|
6b89fe548f
|
|||
|
b1ad871632
|
|||
|
c3f6e8e3b7
|
|||
|
eaf256659d
|
|||
|
7fb71d5ccc
|
|||
|
7ef0fd01dc
|
|||
|
f0ccb873a2
|
|||
|
105acb881d
|
|||
|
93213fa335
|
|||
|
b58d466a80
|
|||
|
77614ccfb9
|
|||
|
02d49c1ab6
|
|||
|
3c02988134
|
|||
|
919336b81a
|
|||
|
c59fa2dd11
|
|||
|
a5de24587a
|
|||
|
b8f8fcb1f8
|
|||
|
228e1a7f42
|
|||
|
c2c20cb5b3
|
|||
|
233af5dc8f
|
|||
|
4c1e6979b3
|
|||
|
0738e2d588
|
|||
|
5e8cb13d1a
|
|||
|
efdeef927a
|
|||
|
30f68729fc
|
|||
|
53f7b5e8b3
|
|||
|
da33ba9fbd
|
|||
|
6c95cfd2d6
|
|||
|
50157db484
|
|||
|
2d11d913e8
|
|||
|
f9e5b7f323
|
|||
|
db714fee2d
|
|||
|
efe1660d81
|
|||
|
639070556a
|
|||
|
00a5d152a5
|
|||
|
1ba6135115
|
|||
|
ec994773dd
|
|||
|
39c8f16b59
|
|||
|
a6a423d5a1
|
|||
|
899451d405
|
|||
|
8d6dd7ccf1
|
|||
|
ebdd8d4e2a
|
|||
|
e4599a419e
|
|||
|
86290dff0d
|
|||
|
0a9e528418
|
|||
|
f74728fc73
|
|||
|
87467be61b
|
|||
|
713ecd4ec8
|
|||
|
0ff1e3a428
|
|||
|
cef18b9482
|
|||
|
1bfe30b216
|
|||
|
c9c82a650d
|
|||
|
d0a7dbcc96
|
|||
|
9c9a827a91
|
|||
|
96889738df
|
|||
|
f3c791496e
|
+1
-1
@@ -10,6 +10,6 @@ liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
buy_me_a_coffee: treeman183
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class ImageConditionalRequestHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeStrongETag_SamePayload_ReturnsStableQuotedHash()
|
||||
{
|
||||
var payload = new byte[] { 1, 2, 3, 4 };
|
||||
|
||||
var first = ImageConditionalRequestHelper.ComputeStrongETag(payload);
|
||||
var second = ImageConditionalRequestHelper.ComputeStrongETag(payload);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.StartsWith("\"", first);
|
||||
Assert.EndsWith("\"", first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesIfNoneMatch_WithExactMatch_ReturnsTrue()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["If-None-Match"] = "\"ABC123\""
|
||||
};
|
||||
|
||||
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"ABC123\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesIfNoneMatch_WithMultipleValues_ReturnsTrueForMatchingEntry()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["If-None-Match"] = "\"stale\", \"fresh\""
|
||||
};
|
||||
|
||||
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"fresh\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesIfNoneMatch_WithWildcard_ReturnsTrue()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["If-None-Match"] = "*"
|
||||
};
|
||||
|
||||
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"anything\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesIfNoneMatch_WithoutMatch_ReturnsFalse()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["If-None-Match"] = "\"ABC123\""
|
||||
};
|
||||
|
||||
Assert.False(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"XYZ789\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Reflection;
|
||||
using allstarr.Controllers;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class JellyfinControllerSearchLimitTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, 20, true, 20, 20, 20)]
|
||||
[InlineData("MusicAlbum", 20, true, 0, 20, 0)]
|
||||
[InlineData("Audio", 20, true, 20, 0, 0)]
|
||||
[InlineData("MusicArtist", 20, true, 0, 0, 20)]
|
||||
[InlineData("Playlist", 20, true, 0, 20, 0)]
|
||||
[InlineData("Playlist", 20, false, 0, 0, 0)]
|
||||
[InlineData("Audio,MusicArtist", 15, true, 15, 0, 15)]
|
||||
[InlineData("BoxSet", 10, true, 0, 0, 0)]
|
||||
public void GetExternalSearchLimits_UsesRequestedItemTypes(
|
||||
string? includeItemTypes,
|
||||
int limit,
|
||||
bool includePlaylistsAsAlbums,
|
||||
int expectedSongLimit,
|
||||
int expectedAlbumLimit,
|
||||
int expectedArtistLimit)
|
||||
{
|
||||
var requestedTypes = string.IsNullOrWhiteSpace(includeItemTypes)
|
||||
? null
|
||||
: includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
"GetExternalSearchLimits",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
var result = ((int SongLimit, int AlbumLimit, int ArtistLimit))method!.Invoke(
|
||||
null,
|
||||
new object?[] { requestedTypes, limit, includePlaylistsAsAlbums })!;
|
||||
|
||||
Assert.Equal(expectedSongLimit, result.SongLimit);
|
||||
Assert.Equal(expectedAlbumLimit, result.AlbumLimit);
|
||||
Assert.Equal(expectedArtistLimit, result.ArtistLimit);
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,31 @@ public class SpotifyApiClientTests
|
||||
Assert.Equal(new DateTime(2026, 2, 16, 5, 0, 0, DateTimeKind.Utc), track.AddedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetSpotifyPlaylistItemCount_ParsesAttributesArrayEntries()
|
||||
{
|
||||
// Arrange
|
||||
using var doc = JsonDocument.Parse("""
|
||||
{
|
||||
"attributes": [
|
||||
{ "key": "core:item_count", "value": "42" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var method = typeof(SpotifyApiClient).GetMethod(
|
||||
"TryGetSpotifyPlaylistItemCount",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
// Act
|
||||
var result = (int)method!.Invoke(null, new object?[] { doc.RootElement })!;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(42, result);
|
||||
}
|
||||
|
||||
private static T InvokePrivateMethod<T>(object instance, string methodName, params object?[] args)
|
||||
{
|
||||
var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
@@ -299,6 +299,65 @@ public class SquidWTFMetadataServiceTests
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAllAsync_WithZeroLimits_SkipsUnusedBuckets()
|
||||
{
|
||||
var requestKinds = new List<string>();
|
||||
var handler = new StubHttpMessageHandler(request =>
|
||||
{
|
||||
var trackQuery = GetQueryParameter(request.RequestUri!, "s");
|
||||
var albumQuery = GetQueryParameter(request.RequestUri!, "al");
|
||||
var artistQuery = GetQueryParameter(request.RequestUri!, "a");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(trackQuery))
|
||||
{
|
||||
requestKinds.Add("song");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(1, "Song", "USRC12345678")))
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(albumQuery))
|
||||
{
|
||||
requestKinds.Add("album");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(CreateAlbumSearchResponse())
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artistQuery))
|
||||
{
|
||||
requestKinds.Add("artist");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(CreateArtistSearchResponse())
|
||||
};
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}");
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
new List<string> { "https://test1.example.com" });
|
||||
|
||||
var result = await service.SearchAllAsync("OK Computer", 0, 5, 0);
|
||||
|
||||
Assert.Empty(result.Songs);
|
||||
Assert.Single(result.Albums);
|
||||
Assert.Empty(result.Artists);
|
||||
Assert.Equal(new[] { "album" }, requestKinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitFilter_RespectsSettings()
|
||||
{
|
||||
|
||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
||||
/// <summary>
|
||||
/// Current application version.
|
||||
/// </summary>
|
||||
public const string Version = "1.4.7";
|
||||
public const string Version = "1.5.4";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -139,12 +148,67 @@ public class DownloadsController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE /api/admin/downloads/all
|
||||
/// Deletes all kept audio files and removes empty folders
|
||||
/// </summary>
|
||||
[HttpDelete("downloads/all")]
|
||||
public IActionResult DeleteAllDownloads()
|
||||
{
|
||||
try
|
||||
{
|
||||
var keptPath = Path.GetFullPath(Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"));
|
||||
if (!Directory.Exists(keptPath))
|
||||
{
|
||||
return Ok(new { success = true, deletedCount = 0, message = "No kept downloads found" });
|
||||
}
|
||||
|
||||
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
||||
.Where(IsSupportedAudioFile)
|
||||
.ToList();
|
||||
|
||||
foreach (var filePath in allFiles)
|
||||
{
|
||||
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);
|
||||
foreach (var directory in allDirectories)
|
||||
{
|
||||
if (!Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
deletedCount = allFiles.Count,
|
||||
message = $"Deleted {allFiles.Count} kept download(s)"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete all kept downloads");
|
||||
return StatusCode(500, new { error = "Failed to delete all kept downloads" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/downloads/file
|
||||
/// 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
|
||||
{
|
||||
@@ -166,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)
|
||||
@@ -182,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
|
||||
{
|
||||
@@ -193,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)
|
||||
@@ -209,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,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>
|
||||
|
||||
@@ -245,7 +245,9 @@ public class JellyfinAdminController : ControllerBase
|
||||
/// Get all playlists from the user's Spotify account
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/playlists")]
|
||||
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
|
||||
public async Task<IActionResult> GetJellyfinPlaylists(
|
||||
[FromQuery] string? userId = null,
|
||||
[FromQuery] bool includeStats = true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
@@ -330,13 +332,13 @@ public class JellyfinAdminController : ControllerBase
|
||||
|
||||
var statsUserId = requestedUserId;
|
||||
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
||||
if (isConfigured)
|
||||
if (isConfigured && includeStats)
|
||||
{
|
||||
trackStats = await GetPlaylistTrackStats(id!, session, statsUserId);
|
||||
}
|
||||
|
||||
var actualTrackCount = isConfigured
|
||||
? trackStats.LocalTracks + trackStats.ExternalTracks
|
||||
? (includeStats ? trackStats.LocalTracks + trackStats.ExternalTracks : childCount)
|
||||
: childCount;
|
||||
|
||||
playlists.Add(new
|
||||
@@ -349,6 +351,7 @@ public class JellyfinAdminController : ControllerBase
|
||||
isLinkedByAnotherUser,
|
||||
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
|
||||
allLinkedForPlaylist.FirstOrDefault()?.UserId,
|
||||
statsPending = isConfigured && !includeStats,
|
||||
localTracks = trackStats.LocalTracks,
|
||||
externalTracks = trackStats.ExternalTracks,
|
||||
externalAvailable = trackStats.ExternalAvailable
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services.Common;
|
||||
@@ -304,6 +305,7 @@ public partial class JellyfinController
|
||||
|
||||
// Run local and external searches in parallel
|
||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||
var externalSearchLimits = GetExternalSearchLimits(itemTypes, limit, includePlaylistsAsAlbums: true);
|
||||
var jellyfinTask = GetLocalSearchResultForCurrentRequest(
|
||||
cleanQuery,
|
||||
includeItemTypes,
|
||||
@@ -312,12 +314,29 @@ public partial class JellyfinController
|
||||
recursive,
|
||||
userId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"SEARCH TRACE: external limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
|
||||
cleanQuery,
|
||||
externalSearchLimits.SongLimit,
|
||||
externalSearchLimits.AlbumLimit,
|
||||
externalSearchLimits.ArtistLimit);
|
||||
|
||||
// Use parallel metadata service if available (races providers), otherwise use primary
|
||||
var externalTask = favoritesOnlyRequest
|
||||
? Task.FromResult(new SearchResult())
|
||||
: _parallelMetadataService != null
|
||||
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
|
||||
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
|
||||
? _parallelMetadataService.SearchAllAsync(
|
||||
cleanQuery,
|
||||
externalSearchLimits.SongLimit,
|
||||
externalSearchLimits.AlbumLimit,
|
||||
externalSearchLimits.ArtistLimit,
|
||||
HttpContext.RequestAborted)
|
||||
: _metadataService.SearchAllAsync(
|
||||
cleanQuery,
|
||||
externalSearchLimits.SongLimit,
|
||||
externalSearchLimits.AlbumLimit,
|
||||
externalSearchLimits.ArtistLimit,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
var playlistTask = favoritesOnlyRequest || !_settings.EnableExternalPlaylists
|
||||
? Task.FromResult(new List<ExternalPlaylist>())
|
||||
@@ -672,11 +691,36 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
var cleanQuery = searchTerm.Trim().Trim('"');
|
||||
var requestedTypes = ParseItemTypes(includeItemTypes);
|
||||
var externalSearchLimits = GetExternalSearchLimits(requestedTypes, limit, includePlaylistsAsAlbums: false);
|
||||
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 ||
|
||||
requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
||||
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 ||
|
||||
requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
|
||||
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 ||
|
||||
requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_logger.LogInformation(
|
||||
"SEARCH TRACE: hint limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
|
||||
cleanQuery,
|
||||
externalSearchLimits.SongLimit,
|
||||
externalSearchLimits.AlbumLimit,
|
||||
externalSearchLimits.ArtistLimit);
|
||||
|
||||
// Use parallel metadata service if available (races providers), otherwise use primary
|
||||
var externalTask = _parallelMetadataService != null
|
||||
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
|
||||
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
|
||||
? _parallelMetadataService.SearchAllAsync(
|
||||
cleanQuery,
|
||||
externalSearchLimits.SongLimit,
|
||||
externalSearchLimits.AlbumLimit,
|
||||
externalSearchLimits.ArtistLimit,
|
||||
HttpContext.RequestAborted)
|
||||
: _metadataService.SearchAllAsync(
|
||||
cleanQuery,
|
||||
externalSearchLimits.SongLimit,
|
||||
externalSearchLimits.AlbumLimit,
|
||||
externalSearchLimits.ArtistLimit,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
// Run searches in parallel (local Jellyfin hints + external providers)
|
||||
var jellyfinTask = GetLocalSearchHintsResultForCurrentRequest(cleanQuery, userId);
|
||||
@@ -689,9 +733,15 @@ public partial class JellyfinController
|
||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseSearchHintsResponse(jellyfinResult);
|
||||
|
||||
// NO deduplication - merge all results and take top matches
|
||||
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
|
||||
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
|
||||
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList();
|
||||
var allSongs = includesSongs
|
||||
? localSongs.Concat(externalResult.Songs).Take(limit).ToList()
|
||||
: new List<Song>();
|
||||
var allAlbums = includesAlbums
|
||||
? localAlbums.Concat(externalResult.Albums).Take(limit).ToList()
|
||||
: new List<Album>();
|
||||
var allArtists = includesArtists
|
||||
? localArtists.Concat(externalResult.Artists).Take(limit).ToList()
|
||||
: new List<Artist>();
|
||||
|
||||
return _responseBuilder.CreateSearchHintsResponse(
|
||||
allSongs.Take(limit).ToList(),
|
||||
@@ -742,6 +792,33 @@ public partial class JellyfinController
|
||||
return string.Equals(Request.Query["IsFavorite"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static (int SongLimit, int AlbumLimit, int ArtistLimit) GetExternalSearchLimits(
|
||||
string[]? requestedTypes,
|
||||
int limit,
|
||||
bool includePlaylistsAsAlbums)
|
||||
{
|
||||
if (limit <= 0)
|
||||
{
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
if (requestedTypes == null || requestedTypes.Length == 0)
|
||||
{
|
||||
return (limit, limit, limit);
|
||||
}
|
||||
|
||||
var includeSongs = requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
||||
var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) ||
|
||||
(includePlaylistsAsAlbums &&
|
||||
requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
|
||||
var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return (
|
||||
includeSongs ? limit : 0,
|
||||
includeAlbums ? limit : 0,
|
||||
includeArtists ? limit : 0);
|
||||
}
|
||||
|
||||
private static IActionResult CreateEmptyItemsResponse(int startIndex)
|
||||
{
|
||||
return new JsonResult(new
|
||||
|
||||
@@ -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>
|
||||
@@ -480,10 +482,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;
|
||||
@@ -572,6 +577,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 +595,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 +657,8 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
|
||||
|
||||
// Mark as favorited in persistent storage
|
||||
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||
}
|
||||
@@ -903,6 +912,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;
|
||||
@@ -678,7 +681,7 @@ public partial class JellyfinController : ControllerBase
|
||||
|
||||
if (fallbackBytes != null && fallbackContentType != null)
|
||||
{
|
||||
return File(fallbackBytes, fallbackContentType);
|
||||
return CreateConditionalImageResponse(fallbackBytes, fallbackContentType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -687,7 +690,7 @@ public partial class JellyfinController : ControllerBase
|
||||
return await GetPlaceholderImageAsync();
|
||||
}
|
||||
|
||||
return File(imageBytes, contentType);
|
||||
return CreateConditionalImageResponse(imageBytes, contentType);
|
||||
}
|
||||
|
||||
// Check Redis cache for previously fetched external image
|
||||
@@ -696,7 +699,7 @@ public partial class JellyfinController : ControllerBase
|
||||
if (cachedImageBytes != null)
|
||||
{
|
||||
_logger.LogDebug("Cache hit for external {Type} image: {Provider}/{ExternalId}", type, provider, externalId);
|
||||
return File(cachedImageBytes, "image/jpeg");
|
||||
return CreateConditionalImageResponse(cachedImageBytes, "image/jpeg");
|
||||
}
|
||||
|
||||
// Get external cover art URL
|
||||
@@ -767,7 +770,7 @@ public partial class JellyfinController : ControllerBase
|
||||
|
||||
_logger.LogDebug("Successfully fetched and cached external image from host {Host}, size: {Size} bytes",
|
||||
safeCoverUri.Host, imageBytes.Length);
|
||||
return File(imageBytes, "image/jpeg");
|
||||
return CreateConditionalImageResponse(imageBytes, "image/jpeg");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -789,7 +792,7 @@ public partial class JellyfinController : ControllerBase
|
||||
if (System.IO.File.Exists(placeholderPath))
|
||||
{
|
||||
var imageBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath);
|
||||
return File(imageBytes, "image/png");
|
||||
return CreateConditionalImageResponse(imageBytes, "image/png");
|
||||
}
|
||||
|
||||
// Fallback: Return a 1x1 transparent PNG as minimal placeholder
|
||||
@@ -797,16 +800,34 @@ public partial class JellyfinController : ControllerBase
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
);
|
||||
|
||||
return File(transparentPng, "image/png");
|
||||
return CreateConditionalImageResponse(transparentPng, "image/png");
|
||||
}
|
||||
|
||||
private IActionResult CreateConditionalImageResponse(byte[] imageBytes, string contentType)
|
||||
{
|
||||
var etag = ImageConditionalRequestHelper.ComputeStrongETag(imageBytes);
|
||||
Response.Headers["ETag"] = etag;
|
||||
|
||||
if (ImageConditionalRequestHelper.MatchesIfNoneMatch(Request.Headers, etag))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
return File(imageBytes, contentType);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveCurrentSpotifyPlaylistImageTagAsync(string itemId, string imageType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (itemResult, statusCode) = await _proxyService.GetJsonAsyncInternal($"Items/{itemId}");
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-3
@@ -12,8 +12,10 @@ 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);
|
||||
@@ -161,6 +163,7 @@ builder.Services.AddControllers()
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddHttpClient("SquidWTF");
|
||||
builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
|
||||
{
|
||||
options.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
@@ -176,9 +179,33 @@ builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
|
||||
// but we want to reduce noise in production logs
|
||||
options.SuppressHandlerScope = true;
|
||||
});
|
||||
|
||||
// Register a dedicated named HttpClient for Jellyfin backend with connection pooling.
|
||||
// SocketsHttpHandler reuses TCP connections across the scoped JellyfinProxyService
|
||||
// instances, eliminating per-request TCP/TLS handshake overhead.
|
||||
builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
|
||||
{
|
||||
// Keep up to 20 idle connections to Jellyfin alive at any time
|
||||
MaxConnectionsPerServer = 20,
|
||||
// Recycle pooled connections every 5 minutes to pick up DNS changes
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||
// Close idle connections after 90 seconds to avoid stale sockets
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
|
||||
// Allow HTTP/2 multiplexing when Jellyfin supports it
|
||||
EnableMultipleHttp2Connections = true,
|
||||
// Follow redirects within Jellyfin
|
||||
AllowAutoRedirect = true,
|
||||
MaxAutomaticRedirections = 5
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.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>();
|
||||
@@ -617,7 +644,7 @@ builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
|
||||
builder.Services.AddSingleton<IStartupValidator>(sp =>
|
||||
new SquidWTFStartupValidator(
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient("SquidWTF"),
|
||||
squidWtfApiUrls,
|
||||
squidWtfStreamingUrls,
|
||||
sp.GetRequiredService<EndpointBenchmarkService>(),
|
||||
@@ -693,6 +720,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>();
|
||||
@@ -926,7 +954,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();
|
||||
@@ -946,7 +974,11 @@ if (app.Environment.IsDevelopment())
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
// The admin UI is documented and intended to be reachable directly over HTTP on port 5275.
|
||||
// Keep HTTPS redirection for non-admin traffic only.
|
||||
app.UseWhen(
|
||||
context => context.Connection.LocalPort != 5275,
|
||||
branch => branch.UseHttpsRedirection());
|
||||
|
||||
// Serve static files only on admin port (5275)
|
||||
app.UseMiddleware<allstarr.Middleware.AdminNetworkAllowlistMiddleware>();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
public static class ImageConditionalRequestHelper
|
||||
{
|
||||
public static string ComputeStrongETag(byte[] payload)
|
||||
{
|
||||
var hash = SHA256.HashData(payload);
|
||||
return $"\"{Convert.ToHexString(hash)}\"";
|
||||
}
|
||||
|
||||
public static bool MatchesIfNoneMatch(IHeaderDictionary headers, string etag)
|
||||
{
|
||||
if (!headers.TryGetValue("If-None-Match", out var headerValues))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var headerValue in headerValues)
|
||||
{
|
||||
if (string.IsNullOrEmpty(headerValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var candidate in headerValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (candidate == "*" || string.Equals(candidate, etag, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -27,16 +27,16 @@ public class RoundRobinFallbackHelper
|
||||
_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);
|
||||
|
||||
@@ -14,6 +14,8 @@ public class VersionUpgradeRebuildService : IHostedService
|
||||
private readonly SpotifyTrackMatchingService _matchingService;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly ILogger<VersionUpgradeRebuildService> _logger;
|
||||
private CancellationTokenSource? _backgroundRebuildCts;
|
||||
private Task? _backgroundRebuildTask;
|
||||
|
||||
public VersionUpgradeRebuildService(
|
||||
SpotifyTrackMatchingService matchingService,
|
||||
@@ -53,15 +55,12 @@ public class VersionUpgradeRebuildService : IHostedService
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Triggering full rebuild for all playlists after version upgrade");
|
||||
try
|
||||
{
|
||||
await _matchingService.TriggerRebuildAllAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade");
|
||||
}
|
||||
_logger.LogInformation(
|
||||
"Scheduling full rebuild for all playlists in background after version upgrade");
|
||||
|
||||
_backgroundRebuildCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_backgroundRebuildTask = RunBackgroundRebuildAsync(currentVersion, _backgroundRebuildCts.Token);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -76,7 +75,51 @@ public class VersionUpgradeRebuildService : IHostedService
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return StopBackgroundRebuildAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task RunBackgroundRebuildAsync(string currentVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting background full rebuild for all playlists after version upgrade");
|
||||
await _matchingService.TriggerRebuildAllAsync(cancellationToken);
|
||||
_logger.LogInformation("Background full rebuild after version upgrade completed");
|
||||
await WriteCurrentVersionAsync(currentVersion, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("Background full rebuild after version upgrade was cancelled before completion");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade");
|
||||
await WriteCurrentVersionAsync(currentVersion, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopBackgroundRebuildAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_backgroundRebuildTask == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_backgroundRebuildCts?.Cancel();
|
||||
await _backgroundRebuildTask.WaitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Host shutdown is in progress or the background task observed cancellation.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_backgroundRebuildCts?.Dispose();
|
||||
_backgroundRebuildCts = null;
|
||||
_backgroundRebuildTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ReadPreviousVersionAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -135,10 +135,15 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Execute searches in parallel
|
||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
||||
var songsTask = songLimit > 0
|
||||
? SearchSongsAsync(query, songLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Song>());
|
||||
var albumsTask = albumLimit > 0
|
||||
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Album>());
|
||||
var artistsTask = artistLimit > 0
|
||||
? SearchArtistsAsync(query, artistLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Artist>());
|
||||
|
||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||
|
||||
|
||||
@@ -10,9 +10,17 @@ namespace allstarr.Services.Jellyfin;
|
||||
|
||||
/// <summary>
|
||||
/// Handles proxying requests to the Jellyfin server and authentication.
|
||||
/// Uses a named HttpClient ("JellyfinBackend") with SocketsHttpHandler for
|
||||
/// TCP connection pooling across scoped instances.
|
||||
/// </summary>
|
||||
public class JellyfinProxyService
|
||||
{
|
||||
/// <summary>
|
||||
/// The IHttpClientFactory registration name for the Jellyfin backend client.
|
||||
/// Configured with SocketsHttpHandler for connection pooling in Program.cs.
|
||||
/// </summary>
|
||||
public const string HttpClientName = "JellyfinBackend";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
@@ -31,7 +39,7 @@ public class JellyfinProxyService
|
||||
ILogger<JellyfinProxyService> logger,
|
||||
RedisCacheService cache)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
_settings = settings.Value;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_logger = logger;
|
||||
|
||||
@@ -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,319 @@
|
||||
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(" [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; }
|
||||
}
|
||||
}
|
||||
@@ -160,9 +160,15 @@ public class QobuzMetadataService : TrackParserBase, IMusicMetadataService
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
||||
var songsTask = songLimit > 0
|
||||
? SearchSongsAsync(query, songLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Song>());
|
||||
var albumsTask = albumLimit > 0
|
||||
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Album>());
|
||||
var artistsTask = artistLimit > 0
|
||||
? SearchArtistsAsync(query, artistLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Artist>());
|
||||
|
||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||
|
||||
|
||||
@@ -1026,26 +1026,7 @@ public class SpotifyApiClient : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get track count if available - try multiple possible paths
|
||||
var trackCount = 0;
|
||||
if (playlist.TryGetProperty("content", out var content))
|
||||
{
|
||||
if (content.TryGetProperty("totalCount", out var totalTrackCount))
|
||||
{
|
||||
trackCount = totalTrackCount.GetInt32();
|
||||
}
|
||||
}
|
||||
// Fallback: try attributes.itemCount
|
||||
else if (playlist.TryGetProperty("attributes", out var attributes) &&
|
||||
attributes.TryGetProperty("itemCount", out var itemCountProp))
|
||||
{
|
||||
trackCount = itemCountProp.GetInt32();
|
||||
}
|
||||
// Fallback: try totalCount directly
|
||||
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
|
||||
{
|
||||
trackCount = directTotalCount.GetInt32();
|
||||
}
|
||||
var trackCount = TryGetSpotifyPlaylistItemCount(playlist);
|
||||
|
||||
// Log if we couldn't find track count for debugging
|
||||
if (trackCount == 0)
|
||||
@@ -1057,7 +1038,9 @@ public class SpotifyApiClient : IDisposable
|
||||
// Get owner name
|
||||
string? ownerName = null;
|
||||
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
||||
ownerV2.ValueKind == JsonValueKind.Object &&
|
||||
ownerV2.TryGetProperty("data", out var ownerData) &&
|
||||
ownerData.ValueKind == JsonValueKind.Object &&
|
||||
ownerData.TryGetProperty("username", out var ownerNameProp))
|
||||
{
|
||||
ownerName = ownerNameProp.GetString();
|
||||
@@ -1066,11 +1049,14 @@ public class SpotifyApiClient : IDisposable
|
||||
// Get image URL
|
||||
string? imageUrl = null;
|
||||
if (playlist.TryGetProperty("images", out var images) &&
|
||||
images.ValueKind == JsonValueKind.Object &&
|
||||
images.TryGetProperty("items", out var imageItems) &&
|
||||
imageItems.ValueKind == JsonValueKind.Array &&
|
||||
imageItems.GetArrayLength() > 0)
|
||||
{
|
||||
var firstImage = imageItems[0];
|
||||
if (firstImage.TryGetProperty("sources", out var sources) &&
|
||||
sources.ValueKind == JsonValueKind.Array &&
|
||||
sources.GetArrayLength() > 0)
|
||||
{
|
||||
var firstSource = sources[0];
|
||||
@@ -1165,6 +1151,68 @@ public class SpotifyApiClient : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int TryGetSpotifyPlaylistItemCount(JsonElement playlistElement)
|
||||
{
|
||||
if (playlistElement.TryGetProperty("content", out var content) &&
|
||||
content.ValueKind == JsonValueKind.Object &&
|
||||
content.TryGetProperty("totalCount", out var totalTrackCount) &&
|
||||
TryParseSpotifyIntegerElement(totalTrackCount, out var contentCount))
|
||||
{
|
||||
return contentCount;
|
||||
}
|
||||
|
||||
if (playlistElement.TryGetProperty("attributes", out var attributes))
|
||||
{
|
||||
if (attributes.ValueKind == JsonValueKind.Object &&
|
||||
attributes.TryGetProperty("itemCount", out var itemCountProp) &&
|
||||
TryParseSpotifyIntegerElement(itemCountProp, out var directAttributeCount))
|
||||
{
|
||||
return directAttributeCount;
|
||||
}
|
||||
|
||||
if (attributes.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var attribute in attributes.EnumerateArray())
|
||||
{
|
||||
if (attribute.ValueKind != JsonValueKind.Object ||
|
||||
!attribute.TryGetProperty("key", out var keyProp) ||
|
||||
keyProp.ValueKind != JsonValueKind.String ||
|
||||
!attribute.TryGetProperty("value", out var valueProp))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = keyProp.GetString();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedKey = key.Replace("_", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(":", "", StringComparison.OrdinalIgnoreCase);
|
||||
if (!normalizedKey.Contains("itemcount", StringComparison.OrdinalIgnoreCase) &&
|
||||
!normalizedKey.Contains("trackcount", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseSpotifyIntegerElement(valueProp, out var attributeCount))
|
||||
{
|
||||
return attributeCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (playlistElement.TryGetProperty("totalCount", out var directTotalCount) &&
|
||||
TryParseSpotifyIntegerElement(directTotalCount, out var totalCount))
|
||||
{
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static DateTime? ParseSpotifyDateElement(JsonElement value)
|
||||
{
|
||||
switch (value.ValueKind)
|
||||
@@ -1238,6 +1286,40 @@ public class SpotifyApiClient : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseSpotifyIntegerElement(JsonElement value, out int parsed)
|
||||
{
|
||||
switch (value.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Number:
|
||||
return value.TryGetInt32(out parsed);
|
||||
case JsonValueKind.String:
|
||||
return int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||
case JsonValueKind.Object:
|
||||
if (value.TryGetProperty("value", out var nestedValue) &&
|
||||
TryParseSpotifyIntegerElement(nestedValue, out parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.TryGetProperty("itemCount", out var itemCount) &&
|
||||
TryParseSpotifyIntegerElement(itemCount, out parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.TryGetProperty("totalCount", out var totalCount) &&
|
||||
TryParseSpotifyIntegerElement(totalCount, out parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
parsed = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static DateTime? ParseSpotifyUnixTimestamp(long value)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -38,6 +38,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
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);
|
||||
|
||||
// Track last run time per playlist to prevent duplicate runs
|
||||
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
|
||||
@@ -365,10 +366,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
|
||||
/// This clears caches, fetches fresh data, and re-matches everything immediately.
|
||||
/// </summary>
|
||||
public async Task TriggerRebuildAllAsync()
|
||||
public async Task TriggerRebuildAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Manual full rebuild triggered for all playlists");
|
||||
await RebuildAllPlaylistsAsync(CancellationToken.None);
|
||||
_logger.LogInformation("Full rebuild triggered for all playlists");
|
||||
await RebuildAllPlaylistsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -773,11 +774,28 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList();
|
||||
var batchStart = i + 1;
|
||||
var batchEnd = i + batch.Count;
|
||||
var batchStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting external matching batch for {Playlist}: tracks {Start}-{End}/{Total}",
|
||||
playlistName,
|
||||
batchStart,
|
||||
batchEnd,
|
||||
unmatchedSpotifyTracks.Count);
|
||||
|
||||
var batchTasks = batch.Select(async spotifyTrack =>
|
||||
{
|
||||
var primaryArtist = spotifyTrack.PrimaryArtist;
|
||||
var trackStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(ExternalProviderSearchTimeout);
|
||||
var trackCancellationToken = timeoutCts.Token;
|
||||
|
||||
var candidates = new List<(Song Song, double Score, string MatchType)>();
|
||||
|
||||
// Check global external mapping first
|
||||
@@ -789,12 +807,23 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) &&
|
||||
!string.IsNullOrEmpty(globalMapping.ExternalId))
|
||||
{
|
||||
mappedSong = await metadataService.GetSongAsync(globalMapping.ExternalProvider, globalMapping.ExternalId);
|
||||
mappedSong = await metadataService.GetSongAsync(
|
||||
globalMapping.ExternalProvider,
|
||||
globalMapping.ExternalId,
|
||||
trackCancellationToken);
|
||||
}
|
||||
|
||||
if (mappedSong != null)
|
||||
{
|
||||
candidates.Add((mappedSong, 100.0, "global-mapping-external"));
|
||||
trackStopwatch.Stop();
|
||||
_logger.LogDebug(
|
||||
"External candidate search finished for {Playlist} track #{Position}: {Title} by {Artist} in {ElapsedMs}ms using global mapping",
|
||||
playlistName,
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
primaryArtist,
|
||||
trackStopwatch.ElapsedMilliseconds);
|
||||
return (spotifyTrack, candidates);
|
||||
}
|
||||
}
|
||||
@@ -802,10 +831,31 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Try ISRC match
|
||||
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||
{
|
||||
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||
if (isrcSong != null)
|
||||
try
|
||||
{
|
||||
candidates.Add((isrcSong, 100.0, "isrc"));
|
||||
var isrcSong = await TryMatchByIsrcAsync(
|
||||
spotifyTrack.Isrc,
|
||||
metadataService,
|
||||
trackCancellationToken);
|
||||
|
||||
if (isrcSong != null)
|
||||
{
|
||||
candidates.Add((isrcSong, 100.0, "isrc"));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"ISRC lookup failed for {Playlist} track #{Position}: {Title} by {Artist}",
|
||||
playlistName,
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
primaryArtist);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,7 +863,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
|
||||
spotifyTrack.Title,
|
||||
spotifyTrack.Artists,
|
||||
metadataService);
|
||||
metadataService,
|
||||
trackCancellationToken);
|
||||
|
||||
foreach (var (song, score) in fuzzySongs)
|
||||
{
|
||||
@@ -823,16 +874,48 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
trackStopwatch.Stop();
|
||||
_logger.LogDebug(
|
||||
"External candidate search finished for {Playlist} track #{Position}: {Title} by {Artist} in {ElapsedMs}ms with {CandidateCount} candidates",
|
||||
playlistName,
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
primaryArtist,
|
||||
trackStopwatch.ElapsedMilliseconds,
|
||||
candidates.Count);
|
||||
|
||||
return (spotifyTrack, candidates);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return (spotifyTrack, new List<(Song, double, string)>());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"External candidate search timed out for {Playlist} track #{Position}: {Title} by {Artist} after {TimeoutSeconds}s",
|
||||
playlistName,
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
primaryArtist,
|
||||
ExternalProviderSearchTimeout.TotalSeconds);
|
||||
return (spotifyTrack, new List<(Song, double, string)>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to match track: {Title}", spotifyTrack.Title);
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to match track for {Playlist} track #{Position}: {Title} by {Artist}",
|
||||
playlistName,
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
primaryArtist);
|
||||
return (spotifyTrack, new List<(Song, double, string)>());
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
var batchResults = await Task.WhenAll(batchTasks);
|
||||
batchStopwatch.Stop();
|
||||
|
||||
foreach (var result in batchResults)
|
||||
{
|
||||
@@ -842,6 +925,16 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
var batchCandidateCount = batchResults.Sum(result => result.Item2.Count);
|
||||
_logger.LogInformation(
|
||||
"Finished external matching batch for {Playlist}: tracks {Start}-{End}/{Total} in {ElapsedMs}ms ({CandidateCount} candidates)",
|
||||
playlistName,
|
||||
batchStart,
|
||||
batchEnd,
|
||||
unmatchedSpotifyTracks.Count,
|
||||
batchStopwatch.ElapsedMilliseconds,
|
||||
batchCandidateCount);
|
||||
|
||||
if (i + BatchSize < unmatchedSpotifyTracks.Count)
|
||||
{
|
||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||
@@ -1014,140 +1107,136 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
||||
string title,
|
||||
List<string> artists,
|
||||
IMusicMetadataService metadataService)
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||
var query = $"{titleStripped} {primaryArtist}";
|
||||
|
||||
var allCandidates = new List<(Song Song, double Score)>();
|
||||
|
||||
// STEP 1: Search LOCAL Jellyfin library FIRST
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
if (proxyService != null)
|
||||
{
|
||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||
var query = $"{titleStripped} {primaryArtist}";
|
||||
|
||||
var allCandidates = new List<(Song Song, double Score)>();
|
||||
|
||||
// STEP 1: Search LOCAL Jellyfin library FIRST
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
if (proxyService != null)
|
||||
try
|
||||
{
|
||||
try
|
||||
// Search Jellyfin for local tracks
|
||||
var searchParams = new Dictionary<string, string>
|
||||
{
|
||||
// Search Jellyfin for local tracks
|
||||
var searchParams = new Dictionary<string, string>
|
||||
{
|
||||
["searchTerm"] = query,
|
||||
["includeItemTypes"] = "Audio",
|
||||
["recursive"] = "true",
|
||||
["limit"] = "10"
|
||||
};
|
||||
["searchTerm"] = query,
|
||||
["includeItemTypes"] = "Audio",
|
||||
["recursive"] = "true",
|
||||
["limit"] = "10"
|
||||
};
|
||||
|
||||
var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
|
||||
var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
|
||||
|
||||
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
var localResults = new List<Song>();
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var localResults = new List<Song>();
|
||||
foreach (var item in items.EnumerateArray())
|
||||
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
|
||||
var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
|
||||
var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
localResults.Add(new Song
|
||||
{
|
||||
Id = id,
|
||||
Title = songTitle,
|
||||
Artist = artist,
|
||||
IsLocal = true
|
||||
});
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
if (localResults.Count > 0)
|
||||
localResults.Add(new Song
|
||||
{
|
||||
// Score local results
|
||||
var scoredLocal = localResults
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.Where(x =>
|
||||
x.TotalScore >= 40 ||
|
||||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||
x.TitleScore >= 85)
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.Select(x => (x.Song, x.TotalScore))
|
||||
.ToList();
|
||||
Id = id,
|
||||
Title = songTitle,
|
||||
Artist = artist,
|
||||
IsLocal = true
|
||||
});
|
||||
}
|
||||
|
||||
allCandidates.AddRange(scoredLocal);
|
||||
|
||||
// If we found good local matches, return them (don't search external)
|
||||
if (scoredLocal.Any(x => x.TotalScore >= 70))
|
||||
if (localResults.Count > 0)
|
||||
{
|
||||
// Score local results
|
||||
var scoredLocal = localResults
|
||||
.Select(song => new
|
||||
{
|
||||
_logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search",
|
||||
scoredLocal.Count, title);
|
||||
return allCandidates;
|
||||
}
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.Where(x =>
|
||||
x.TotalScore >= 40 ||
|
||||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||
x.TitleScore >= 85)
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.Select(x => (x.Song, x.TotalScore))
|
||||
.ToList();
|
||||
|
||||
allCandidates.AddRange(scoredLocal);
|
||||
|
||||
// If we found good local matches, return them (don't search external)
|
||||
if (scoredLocal.Any(x => x.TotalScore >= 70))
|
||||
{
|
||||
_logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search",
|
||||
scoredLocal.Count, title);
|
||||
return allCandidates;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to search local library for '{Title}'", title);
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 2: Only search EXTERNAL if no good local match found
|
||||
var externalResults = await metadataService.SearchSongsAsync(query, limit: 10);
|
||||
|
||||
if (externalResults.Count > 0)
|
||||
catch (Exception ex)
|
||||
{
|
||||
var scoredExternal = externalResults
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.Where(x =>
|
||||
x.TotalScore >= 40 ||
|
||||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||
x.TitleScore >= 85)
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.Select(x => (x.Song, x.TotalScore))
|
||||
.ToList();
|
||||
|
||||
allCandidates.AddRange(scoredExternal);
|
||||
_logger.LogWarning(ex, "Failed to search local library for '{Title}'", title);
|
||||
}
|
||||
}
|
||||
|
||||
return allCandidates;
|
||||
}
|
||||
catch
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// STEP 2: Only search EXTERNAL if no good local match found
|
||||
var externalResults = await metadataService.SearchSongsAsync(query, limit: 10, cancellationToken);
|
||||
|
||||
if (externalResults.Count > 0)
|
||||
{
|
||||
return new List<(Song, double)>();
|
||||
var scoredExternal = externalResults
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.Where(x =>
|
||||
x.TotalScore >= 40 ||
|
||||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||
x.TitleScore >= 85)
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.Select(x => (x.Song, x.TotalScore))
|
||||
.ToList();
|
||||
|
||||
allCandidates.AddRange(scoredExternal);
|
||||
}
|
||||
|
||||
return allCandidates;
|
||||
}
|
||||
|
||||
private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist)
|
||||
@@ -1161,21 +1250,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Attempts to match a track by ISRC.
|
||||
/// SEARCHES LOCAL FIRST, then external if no local match found.
|
||||
/// </summary>
|
||||
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
|
||||
private async Task<Song?> TryMatchByIsrcAsync(
|
||||
string isrc,
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// STEP 1: Search LOCAL Jellyfin library FIRST by ISRC
|
||||
// Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search
|
||||
// Local tracks will be found via fuzzy matching instead
|
||||
// STEP 1: Search LOCAL Jellyfin library FIRST by ISRC
|
||||
// Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search
|
||||
// Local tracks will be found via fuzzy matching instead
|
||||
|
||||
// STEP 2: Search EXTERNAL by ISRC
|
||||
return await metadataService.FindSongByIsrcAsync(isrc);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// STEP 2: Search EXTERNAL by ISRC
|
||||
return await metadataService.FindSongByIsrcAsync(isrc, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
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);
|
||||
|
||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||
EnsureDirectoryExists(albumFolder);
|
||||
var extension = downloadInfo.MimeType?.ToLower() switch
|
||||
{
|
||||
"audio/flac" => ".flac", "audio/mpeg" => ".mp3", "audio/mp4" => ".m4a", _ => ".flac"
|
||||
};
|
||||
|
||||
if (basePath.EndsWith("transcoded") && IOFile.Exists(outputPath))
|
||||
{
|
||||
IOFile.SetLastWriteTime(outputPath, DateTime.UtcNow);
|
||||
Logger.LogInformation("Quality override cache hit: {Path}", outputPath);
|
||||
return outputPath;
|
||||
}
|
||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId);
|
||||
|
||||
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||
EnsureDirectoryExists(albumFolder);
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
@@ -357,6 +368,9 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
? 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;
|
||||
@@ -498,14 +498,19 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Execute searches in parallel
|
||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
||||
var songsTask = songLimit > 0
|
||||
? SearchSongsAsync(query, songLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Song>());
|
||||
var albumsTask = albumLimit > 0
|
||||
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Album>());
|
||||
var artistsTask = artistLimit > 0
|
||||
? SearchArtistsAsync(query, artistLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Artist>());
|
||||
|
||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||
|
||||
var temp = new SearchResult
|
||||
var temp = new SearchResult
|
||||
{
|
||||
Songs = await songsTask,
|
||||
Albums = await albumsTask,
|
||||
@@ -578,50 +583,78 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
||||
{
|
||||
if (externalProvider != "squidwtf") return null;
|
||||
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
var raceCount = Math.Min(3, _fallbackHelper.EndpointCount);
|
||||
|
||||
if (raceCount > 1)
|
||||
{
|
||||
// Per hifi-api spec: GET /info/?id={trackId} returns track metadata
|
||||
var url = $"{baseUrl}/info/?id={externalId}";
|
||||
_logger.LogInformation(
|
||||
"Racing top {EndpointCount} SquidWTF endpoints for track {TrackId} metadata resolution",
|
||||
raceCount,
|
||||
externalId);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
try
|
||||
{
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
return await _fallbackHelper.RaceTopEndpointsAsync(
|
||||
raceCount,
|
||||
(baseUrl, ct) => 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(
|
||||
baseUrl => FetchSongAsync(baseUrl, externalId, cancellationToken),
|
||||
(Song?)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");
|
||||
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
|
||||
|
||||
// Try a test search to verify functionality
|
||||
await ValidateSearchFunctionality(baseUrl, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
WriteStatus("SquidWTF API", $"REACHABLE ({baseUrl})", ConsoleColor.Green);
|
||||
WriteDetail("No authentication required - powered by Tidal");
|
||||
|
||||
return ValidationResult.Success("SquidWTF validation completed");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
|
||||
}
|
||||
}, ValidationResult.Failure("-1", "All SquidWTF API endpoints failed"));
|
||||
// Try a test search to verify functionality
|
||||
await ValidateSearchFunctionality(baseUrl, cancellationToken);
|
||||
|
||||
if (!apiResult.IsValid)
|
||||
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,6 +6,7 @@ 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/"
|
||||
};
|
||||
@@ -23,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)
|
||||
{
|
||||
@@ -34,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
|
||||
@@ -60,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}");
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path fill="#FFDD00" d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 96" aria-hidden="true"><path fill="#f0f6fc" fill-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 980 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_1_219" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
|
||||
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1_219)">
|
||||
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
|
||||
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
|
||||
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
|
||||
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
|
||||
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
|
||||
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
+199
-72
@@ -12,8 +12,8 @@
|
||||
<!-- Restart Required Banner -->
|
||||
<div class="restart-banner" id="restart-banner">
|
||||
⚠️ Configuration changed. Restart required to apply changes.
|
||||
<button onclick="restartContainer()">Restart Allstarr</button>
|
||||
<button onclick="dismissRestartBanner()"
|
||||
<button data-action="restartContainer">Restart Allstarr</button>
|
||||
<button data-action="dismissRestartBanner"
|
||||
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
||||
</div>
|
||||
|
||||
@@ -28,58 +28,93 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="support-badge">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
<p class="support-text">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development
|
||||
</p>
|
||||
<ul class="support-funding-icons">
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Support on Ko-fi">
|
||||
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="GitHub Sponsors">
|
||||
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
|
||||
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container" id="main-container" style="display:none;">
|
||||
<header>
|
||||
<h1>
|
||||
Allstarr <span class="version" id="version">Loading...</span>
|
||||
</h1>
|
||||
<div class="header-actions">
|
||||
<div class="auth-user" id="auth-user-display" style="display:none;">
|
||||
Signed in as <strong id="auth-user-name">-</strong>
|
||||
<div class="container hidden" id="main-container">
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Admin navigation">
|
||||
<div class="sidebar-brand">
|
||||
<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>
|
||||
<button id="auth-logout-btn" onclick="logoutAdminSession()" style="display:none;">Logout</button>
|
||||
<div id="status-indicator">
|
||||
<span class="status-badge" id="spotify-status">
|
||||
<span class="status-dot"></span>
|
||||
<span>Loading...</span>
|
||||
</span>
|
||||
<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="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">
|
||||
<div class="auth-user hidden" id="auth-user-display">
|
||||
Signed in as <strong id="auth-user-name">-</strong>
|
||||
</div>
|
||||
<button id="auth-logout-btn" data-action="logoutAdminSession" class="hidden">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</aside>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||
<div class="tab" data-tab="kept">Kept Downloads</div>
|
||||
<div class="tab" data-tab="scrobbling">Scrobbling</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
<main class="app-main">
|
||||
<div class="tabs top-tabs" aria-hidden="true">
|
||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||
<div class="tab" data-tab="kept">Kept Downloads</div>
|
||||
<div class="tab" data-tab="scrobbling">Scrobbling</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="report-issues">Report Issues</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
<div class="tab-content active" id="tab-dashboard">
|
||||
<div class="card" id="download-activity-card">
|
||||
<h2>Live Download Queue</h2>
|
||||
<div id="download-activity-list" class="download-queue-list">
|
||||
<div class="empty-state">No active downloads</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Spotify API</h2>
|
||||
@@ -128,9 +163,9 @@
|
||||
</h2>
|
||||
<div id="dashboard-guidance" class="guidance-stack"></div>
|
||||
<div class="card-actions-row">
|
||||
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
|
||||
<button onclick="clearCache()">Clear Cache</button>
|
||||
<button onclick="openAddPlaylist()">Add Playlist</button>
|
||||
<button class="primary" data-action="refreshPlaylists">Refresh All Playlists</button>
|
||||
<button data-action="clearCache">Clear Cache</button>
|
||||
<button data-action="openAddPlaylist">Add Playlist</button>
|
||||
<button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -145,7 +180,7 @@
|
||||
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
<p class="text-secondary mb-16">
|
||||
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing
|
||||
tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
|
||||
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more
|
||||
@@ -153,10 +188,9 @@
|
||||
</p>
|
||||
<div id="jellyfin-guidance" class="guidance-stack"></div>
|
||||
|
||||
<div id="jellyfin-user-filter" style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
|
||||
<div class="form-group" style="margin: 0; flex: 1; min-width: 200px;">
|
||||
<label
|
||||
style="display: block; margin-bottom: 4px; color: var(--text-secondary); font-size: 0.85rem;">User</label>
|
||||
<div id="jellyfin-user-filter" class="flex-row-wrap mb-16">
|
||||
<div class="form-group jellyfin-user-form-group">
|
||||
<label class="text-secondary">User</label>
|
||||
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()"
|
||||
style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
||||
<option value="">All Users</option>
|
||||
@@ -232,7 +266,7 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
<p class="text-secondary mb-12">
|
||||
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music
|
||||
service.
|
||||
</p>
|
||||
@@ -268,15 +302,14 @@
|
||||
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For
|
||||
local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||
</p>
|
||||
<div id="mappings-summary"
|
||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div id="mappings-summary" class="summary-box">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
|
||||
<span class="summary-label">Total:</span>
|
||||
<span class="summary-value" id="mappings-total">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">External:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--success);"
|
||||
<span class="summary-label">External:</span>
|
||||
<span class="summary-value success"
|
||||
id="mappings-external">0</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,15 +342,14 @@
|
||||
<button onclick="fetchMissingTracks()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
<p class="text-secondary mb-12">
|
||||
Tracks that couldn't be matched locally or externally. Map them manually to add them to your
|
||||
playlists.
|
||||
</p>
|
||||
<div id="missing-summary"
|
||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div id="missing-summary" class="summary-box">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Missing:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);"
|
||||
<span class="summary-label">Total Missing:</span>
|
||||
<span class="summary-value warning"
|
||||
id="missing-total">0</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,23 +380,23 @@
|
||||
<h2>
|
||||
Kept Downloads
|
||||
<div class="actions">
|
||||
<button onclick="downloadAllKept()" style="background:var(--accent);border-color:var(--accent);">Download All</button>
|
||||
<button onclick="downloadAllKept()" class="primary">Download All</button>
|
||||
<button onclick="deleteAllKept()" class="danger">Delete All</button>
|
||||
<button onclick="fetchDownloads()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
<p class="text-secondary mb-12">
|
||||
Downloaded files stored permanently. Download individual tracks or download all as a zip archive.
|
||||
</p>
|
||||
<div id="downloads-summary"
|
||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div id="downloads-summary" class="summary-box">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Files:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);"
|
||||
<span class="summary-label">Total Files:</span>
|
||||
<span class="summary-value accent"
|
||||
id="downloads-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Size:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0
|
||||
<span class="summary-label">Total Size:</span>
|
||||
<span class="summary-value accent" id="downloads-size">0
|
||||
B</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -871,6 +903,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">
|
||||
@@ -964,14 +1072,33 @@
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<p>
|
||||
<p class="support-text">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
supporting its development
|
||||
</p>
|
||||
<ul class="support-funding-icons">
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Support on Ko-fi">
|
||||
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="GitHub Sponsors">
|
||||
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
|
||||
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Playlist Modal -->
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
function toBoolean(value) {
|
||||
if (value === true || value === false) {
|
||||
return value;
|
||||
}
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes";
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function getActionArgs(el) {
|
||||
if (!el || !el.dataset) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Convention:
|
||||
// - data-action="foo"
|
||||
// - data-arg-bar="baz" => { bar: "baz" }
|
||||
const args = {};
|
||||
for (const [key, value] of Object.entries(el.dataset)) {
|
||||
if (!key.startsWith("arg")) continue;
|
||||
const argName = key.slice(3);
|
||||
if (!argName) continue;
|
||||
const normalized =
|
||||
argName.charAt(0).toLowerCase() + argName.slice(1);
|
||||
args[normalized] = value;
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function initActionDispatcher({ root = document } = {}) {
|
||||
const handlers = new Map();
|
||||
|
||||
function register(actionName, handler) {
|
||||
if (!actionName || typeof handler !== "function") {
|
||||
return;
|
||||
}
|
||||
handlers.set(actionName, handler);
|
||||
}
|
||||
|
||||
async function dispatch(actionName, el, event = null) {
|
||||
const handler = handlers.get(actionName);
|
||||
const args = getActionArgs(el);
|
||||
|
||||
if (handler) {
|
||||
return await handler({ el, event, args, toBoolean, toNumber });
|
||||
}
|
||||
|
||||
// Transitional fallback: if a legacy window function exists, call it.
|
||||
// This allows incremental conversion away from inline onclick.
|
||||
const legacy = typeof window !== "undefined" ? window[actionName] : null;
|
||||
if (typeof legacy === "function") {
|
||||
const legacyArgs = args && Object.keys(args).length > 0 ? [args] : [];
|
||||
return legacy(...legacyArgs);
|
||||
}
|
||||
|
||||
console.warn(`No handler registered for action "${actionName}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
function bind() {
|
||||
root.addEventListener("click", (event) => {
|
||||
const trigger = event.target?.closest?.("[data-action]");
|
||||
if (!trigger) return;
|
||||
|
||||
const actionName = trigger.getAttribute("data-action") || "";
|
||||
if (!actionName) return;
|
||||
|
||||
event.preventDefault();
|
||||
dispatch(actionName, trigger, event);
|
||||
});
|
||||
}
|
||||
|
||||
bind();
|
||||
|
||||
return { register, dispatch };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -124,6 +124,14 @@ export async function deleteDownload(path) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAllDownloads() {
|
||||
return requestJson(
|
||||
"/api/admin/downloads/all",
|
||||
{ method: "DELETE" },
|
||||
"Failed to delete all downloads",
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchConfig() {
|
||||
return requestJson(
|
||||
"/api/admin/config",
|
||||
@@ -144,10 +152,15 @@ export async function fetchJellyfinUsers() {
|
||||
return requestOptionalJson("/api/admin/jellyfin/users");
|
||||
}
|
||||
|
||||
export async function fetchJellyfinPlaylists(userId = null) {
|
||||
export async function fetchJellyfinPlaylists(userId = null, includeStats = true) {
|
||||
let url = "/api/admin/jellyfin/playlists";
|
||||
const params = [];
|
||||
if (userId) {
|
||||
url += "?userId=" + encodeURIComponent(userId);
|
||||
params.push("userId=" + encodeURIComponent(userId));
|
||||
}
|
||||
params.push("includeStats=" + String(Boolean(includeStats)));
|
||||
if (params.length > 0) {
|
||||
url += "?" + params.join("&");
|
||||
}
|
||||
|
||||
return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeHtml, showToast, formatCookieAge } from "./utils.js";
|
||||
import { escapeHtml, escapeJs, showToast, formatCookieAge } from "./utils.js";
|
||||
import * as API from "./api.js";
|
||||
import * as UI from "./ui.js";
|
||||
import { renderCookieAge } from "./settings-editor.js";
|
||||
@@ -15,6 +15,8 @@ let onCookieNeedsInit = async () => {};
|
||||
let setCurrentConfigState = () => {};
|
||||
let syncConfigUiExtras = () => {};
|
||||
let loadScrobblingConfig = () => {};
|
||||
let injectedPlaylistRequestToken = 0;
|
||||
let jellyfinPlaylistRequestToken = 0;
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
@@ -38,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");
|
||||
@@ -129,6 +141,7 @@ async function fetchMissingTracks() {
|
||||
missing.forEach((t) => {
|
||||
missingTracks.push({
|
||||
playlist: playlist.name,
|
||||
provider: t.externalProvider || t.provider || "squidwtf",
|
||||
...t,
|
||||
});
|
||||
});
|
||||
@@ -151,6 +164,7 @@ async function fetchMissingTracks() {
|
||||
const artist =
|
||||
t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
|
||||
const searchQuery = `${t.title} ${artist}`;
|
||||
const provider = t.provider || "squidwtf";
|
||||
const trackPosition = Number.isFinite(t.position)
|
||||
? Number(t.position)
|
||||
: 0;
|
||||
@@ -163,7 +177,7 @@ async function fetchMissingTracks() {
|
||||
<td class="mapping-actions-cell">
|
||||
<button class="map-action-btn map-action-search missing-track-search-btn"
|
||||
data-query="${escapeHtml(searchQuery)}"
|
||||
data-provider="squidwtf">🔍 Search</button>
|
||||
data-provider="${escapeHtml(provider)}">🔍 Search</button>
|
||||
<button class="map-action-btn map-action-local missing-track-local-btn"
|
||||
data-playlist="${escapeHtml(t.playlist)}"
|
||||
data-position="${trackPosition}"
|
||||
@@ -213,9 +227,9 @@ async function fetchDownloads() {
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||
<td>
|
||||
<button onclick="downloadFile('${escapeJs(f.path)}')"
|
||||
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
|
||||
<button onclick="deleteDownload('${escapeJs(f.path)}')"
|
||||
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -245,11 +259,28 @@ async function fetchJellyfinPlaylists() {
|
||||
'<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
||||
|
||||
try {
|
||||
const requestToken = ++jellyfinPlaylistRequestToken;
|
||||
const userId = isAdminSession()
|
||||
? document.getElementById("jellyfin-user-select")?.value
|
||||
: null;
|
||||
const data = await API.fetchJellyfinPlaylists(userId);
|
||||
UI.updateJellyfinPlaylistsUI(data);
|
||||
const baseData = await API.fetchJellyfinPlaylists(userId, false);
|
||||
if (requestToken !== jellyfinPlaylistRequestToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
UI.updateJellyfinPlaylistsUI(baseData);
|
||||
|
||||
// Enrich counts after initial render so big accounts don't appear empty.
|
||||
API.fetchJellyfinPlaylists(userId, true)
|
||||
.then((statsData) => {
|
||||
if (requestToken !== jellyfinPlaylistRequestToken) {
|
||||
return;
|
||||
}
|
||||
UI.updateJellyfinPlaylistsUI(statsData);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch Jellyfin playlist track stats:", err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch Jellyfin playlists:", error);
|
||||
tbody.innerHTML =
|
||||
@@ -346,7 +377,10 @@ function startDashboardRefresh() {
|
||||
fetchPlaylists();
|
||||
fetchTrackMappings();
|
||||
fetchMissingTracks();
|
||||
fetchDownloads();
|
||||
const keptTab = document.getElementById("tab-kept");
|
||||
if (keptTab && keptTab.classList.contains("active")) {
|
||||
fetchDownloads();
|
||||
}
|
||||
|
||||
const endpointsTab = document.getElementById("tab-endpoints");
|
||||
if (endpointsTab && endpointsTab.classList.contains("active")) {
|
||||
@@ -380,7 +414,6 @@ async function loadDashboardData() {
|
||||
}
|
||||
|
||||
startDashboardRefresh();
|
||||
startDownloadActivityStream();
|
||||
}
|
||||
|
||||
function startDownloadActivityStream() {
|
||||
|
||||
@@ -100,14 +100,14 @@ export async function viewTracks(name) {
|
||||
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
|
||||
const externalSearchLink =
|
||||
t.isLocal === false && t.searchQuery && t.externalProvider
|
||||
? `<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', '${escapeJs(t.externalProvider)}'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||
? `<br><small style="color:var(--accent)"><a href="#" data-action="searchProvider" data-arg-query="${escapeHtml(escapeJs(t.searchQuery))}" data-arg-provider="${escapeHtml(escapeJs(t.externalProvider))}" style="color:var(--accent);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||
: "";
|
||||
const missingSearchLink =
|
||||
t.isLocal === null && t.searchQuery
|
||||
? `<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', 'squidwtf'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||
? `<br><small style="color:var(--text-secondary)"><a href="#" data-action="searchProvider" data-arg-query="${escapeHtml(escapeJs(t.searchQuery))}" data-arg-provider="squidwtf" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||
: "";
|
||||
|
||||
const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || "")}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
|
||||
const lyricsMapButton = `<button class="small" data-action="openLyricsMap" data-arg-artist="${escapeHtml(escapeJs(firstArtist))}" data-arg-title="${escapeHtml(escapeJs(t.title))}" data-arg-album="${escapeHtml(escapeJs(t.album || ""))}" data-arg-duration-seconds="${durationSeconds}" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
|
||||
|
||||
return `
|
||||
<div class="track-item" data-position="${t.position}">
|
||||
@@ -246,7 +246,7 @@ export async function searchJellyfinTracks() {
|
||||
const artist = track.artist || "";
|
||||
const album = track.album || "";
|
||||
return `
|
||||
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" onclick="selectJellyfinTrack('${escapeJs(id)}')">
|
||||
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" data-action="selectJellyfinTrack" data-arg-jellyfin-id="${escapeHtml(escapeJs(id))}">
|
||||
<div>
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<br>
|
||||
@@ -344,7 +344,15 @@ export async function searchExternalTracks() {
|
||||
const externalUrl = track.url || "";
|
||||
|
||||
return `
|
||||
<div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}" onclick="selectExternalTrack(${index}, '${escapeJs(id)}', '${escapeJs(title)}', '${escapeJs(artist)}', '${escapeJs(providerName)}', '${escapeJs(externalUrl)}')">
|
||||
<div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}"
|
||||
data-action="selectExternalTrack"
|
||||
data-arg-result-index="${index}"
|
||||
data-arg-external-id="${escapeHtml(escapeJs(id))}"
|
||||
data-arg-title="${escapeHtml(escapeJs(title))}"
|
||||
data-arg-artist="${escapeHtml(escapeJs(artist))}"
|
||||
data-arg-provider="${escapeHtml(escapeJs(providerName))}"
|
||||
data-arg-external-url="${escapeHtml(escapeJs(externalUrl))}"
|
||||
>
|
||||
<div>
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<br>
|
||||
@@ -662,13 +670,26 @@ export async function saveLyricsMapping() {
|
||||
// Search provider (open in new tab)
|
||||
export async function searchProvider(query, provider) {
|
||||
try {
|
||||
const data = await API.getSquidWTFBaseUrl();
|
||||
const baseUrl = data.baseUrl; // Use the actual property name from API
|
||||
const searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
|
||||
const normalizedProvider = (provider || "squidwtf").toLowerCase();
|
||||
let searchUrl = "";
|
||||
|
||||
if (normalizedProvider === "squidwtf" || normalizedProvider === "tidal") {
|
||||
const data = await API.getSquidWTFBaseUrl();
|
||||
const baseUrl = data.baseUrl;
|
||||
searchUrl = `${baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||
} else if (normalizedProvider === "deezer") {
|
||||
searchUrl = `https://www.deezer.com/search/${encodeURIComponent(query)}`;
|
||||
} else if (normalizedProvider === "qobuz") {
|
||||
searchUrl = `https://www.qobuz.com/search?query=${encodeURIComponent(query)}`;
|
||||
} else {
|
||||
const data = await API.getSquidWTFBaseUrl();
|
||||
const baseUrl = data.baseUrl;
|
||||
searchUrl = `${baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
window.open(searchUrl, "_blank");
|
||||
} catch (error) {
|
||||
console.error("Failed to get SquidWTF base URL:", error);
|
||||
// Fallback to first encoded URL (triton)
|
||||
showToast("Failed to get SquidWTF URL, using fallback", "warning");
|
||||
console.error("Failed to open provider search:", error);
|
||||
showToast("Failed to open provider search link", "warning");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+107
-29
@@ -34,17 +34,14 @@ import {
|
||||
} from "./playlist-admin.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;
|
||||
|
||||
window.showToast = showToast;
|
||||
window.escapeHtml = escapeHtml;
|
||||
window.escapeJs = escapeJs;
|
||||
window.openModal = openModal;
|
||||
window.closeModal = closeModal;
|
||||
window.capitalizeProvider = capitalizeProvider;
|
||||
|
||||
window.showRestartBanner = function () {
|
||||
restartRequired = true;
|
||||
document.getElementById("restart-banner")?.classList.add("active");
|
||||
@@ -58,17 +55,30 @@ window.switchTab = function (tabName) {
|
||||
document
|
||||
.querySelectorAll(".tab")
|
||||
.forEach((tab) => tab.classList.remove("active"));
|
||||
document
|
||||
.querySelectorAll(".sidebar-link")
|
||||
.forEach((link) => link.classList.remove("active"));
|
||||
document
|
||||
.querySelectorAll(".tab-content")
|
||||
.forEach((content) => content.classList.remove("active"));
|
||||
|
||||
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
||||
const sidebarLink = document.querySelector(
|
||||
`.sidebar-link[data-tab="${tabName}"]`,
|
||||
);
|
||||
const content = document.getElementById(`tab-${tabName}`);
|
||||
|
||||
if (tab && content) {
|
||||
tab.classList.add("active");
|
||||
if (sidebarLink) {
|
||||
sidebarLink.classList.add("active");
|
||||
}
|
||||
content.classList.add("active");
|
||||
window.location.hash = tabName;
|
||||
|
||||
if (tabName === "kept" && typeof window.fetchDownloads === "function") {
|
||||
window.fetchDownloads();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,6 +138,8 @@ initPlaylistAdmin({
|
||||
fetchJellyfinPlaylists: dashboard.fetchJellyfinPlaylists,
|
||||
});
|
||||
|
||||
initIssueReporter();
|
||||
|
||||
const authSession = initAuthSession({
|
||||
stopDashboardRefresh: dashboard.stopDashboardRefresh,
|
||||
loadDashboardData: dashboard.loadDashboardData,
|
||||
@@ -138,46 +150,112 @@ const authSession = initAuthSession({
|
||||
},
|
||||
});
|
||||
|
||||
window.viewTracks = viewTracks;
|
||||
window.openManualMap = openManualMap;
|
||||
window.openExternalMap = openExternalMap;
|
||||
window.openMapToLocal = openManualMap;
|
||||
window.openMapToExternal = openExternalMap;
|
||||
window.openModal = openModal;
|
||||
window.closeModal = closeModal;
|
||||
window.searchJellyfinTracks = searchJellyfinTracks;
|
||||
window.selectJellyfinTrack = selectJellyfinTrack;
|
||||
window.saveLocalMapping = saveLocalMapping;
|
||||
window.saveManualMapping = saveManualMapping;
|
||||
window.searchExternalTracks = searchExternalTracks;
|
||||
window.selectExternalTrack = selectExternalTrack;
|
||||
window.validateExternalMapping = validateExternalMapping;
|
||||
window.openLyricsMap = openLyricsMap;
|
||||
window.saveLyricsMapping = saveLyricsMapping;
|
||||
window.searchProvider = searchProvider;
|
||||
window.validateExternalMapping = validateExternalMapping;
|
||||
window.saveLyricsMapping = saveLyricsMapping;
|
||||
// Note: viewTracks/selectExternalTrack/selectJellyfinTrack/openLyricsMap/searchProvider
|
||||
// are now wired via the ActionDispatcher and no longer require window exports.
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
console.log("🚀 Allstarr Admin UI (Modular) loaded");
|
||||
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
window.switchTab(tab.dataset.tab);
|
||||
});
|
||||
const dispatcher = initActionDispatcher({ root: document });
|
||||
// Register a few core actions first; more will be migrated as inline
|
||||
// onclick handlers are removed from HTML and generated markup.
|
||||
dispatcher.register("switchTab", ({ args }) => {
|
||||
const tab = args?.tab || args?.tabName;
|
||||
if (tab) {
|
||||
window.switchTab(tab);
|
||||
}
|
||||
});
|
||||
dispatcher.register("logoutAdminSession", () => window.logoutAdminSession?.());
|
||||
dispatcher.register("dismissRestartBanner", () =>
|
||||
window.dismissRestartBanner?.(),
|
||||
);
|
||||
dispatcher.register("restartContainer", () => window.restartContainer?.());
|
||||
dispatcher.register("refreshPlaylists", () => window.refreshPlaylists?.());
|
||||
dispatcher.register("clearCache", () => window.clearCache?.());
|
||||
dispatcher.register("openAddPlaylist", () => window.openAddPlaylist?.());
|
||||
dispatcher.register("toggleRowMenu", ({ event, args }) =>
|
||||
window.toggleRowMenu?.(event, args?.menuId),
|
||||
);
|
||||
dispatcher.register("toggleDetailsRow", ({ event, args }) =>
|
||||
window.toggleDetailsRow?.(event, args?.detailsRowId),
|
||||
);
|
||||
dispatcher.register("viewTracks", ({ args }) => viewTracks(args?.playlistName));
|
||||
dispatcher.register("refreshPlaylist", ({ args }) =>
|
||||
window.refreshPlaylist?.(args?.playlistName),
|
||||
);
|
||||
dispatcher.register("matchPlaylistTracks", ({ args }) =>
|
||||
window.matchPlaylistTracks?.(args?.playlistName),
|
||||
);
|
||||
dispatcher.register("clearPlaylistCache", ({ args }) =>
|
||||
window.clearPlaylistCache?.(args?.playlistName),
|
||||
);
|
||||
dispatcher.register("editPlaylistSchedule", ({ args }) =>
|
||||
window.editPlaylistSchedule?.(args?.playlistName, args?.syncSchedule),
|
||||
);
|
||||
dispatcher.register("removePlaylist", ({ args }) =>
|
||||
window.removePlaylist?.(args?.playlistName),
|
||||
);
|
||||
dispatcher.register("openLinkPlaylist", ({ args }) =>
|
||||
window.openLinkPlaylist?.(args?.jellyfinId, args?.jellyfinName),
|
||||
);
|
||||
dispatcher.register("unlinkPlaylist", ({ args }) =>
|
||||
window.unlinkPlaylist?.(args?.jellyfinId, args?.jellyfinName),
|
||||
);
|
||||
dispatcher.register("fetchJellyfinPlaylists", () =>
|
||||
window.fetchJellyfinPlaylists?.(),
|
||||
);
|
||||
dispatcher.register("searchProvider", ({ args }) =>
|
||||
searchProvider(args?.query, args?.provider),
|
||||
);
|
||||
dispatcher.register("openLyricsMap", ({ args, toNumber }) =>
|
||||
openLyricsMap(
|
||||
args?.artist,
|
||||
args?.title,
|
||||
args?.album,
|
||||
toNumber(args?.durationSeconds) ?? 0,
|
||||
),
|
||||
);
|
||||
dispatcher.register("selectJellyfinTrack", ({ args }) =>
|
||||
selectJellyfinTrack(args?.jellyfinId),
|
||||
);
|
||||
dispatcher.register("selectExternalTrack", ({ args, toNumber }) =>
|
||||
selectExternalTrack(
|
||||
toNumber(args?.resultIndex),
|
||||
args?.externalId,
|
||||
args?.title,
|
||||
args?.artist,
|
||||
args?.provider,
|
||||
args?.externalUrl,
|
||||
),
|
||||
);
|
||||
dispatcher.register("downloadFile", ({ args }) =>
|
||||
window.downloadFile?.(args?.path),
|
||||
);
|
||||
dispatcher.register("deleteDownload", ({ args }) =>
|
||||
window.deleteDownload?.(args?.path),
|
||||
);
|
||||
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
window.switchTab(hash);
|
||||
}
|
||||
initNavigationView({ switchTab: window.switchTab });
|
||||
|
||||
setupModalBackdropClose();
|
||||
|
||||
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
|
||||
if (scrobblingTab) {
|
||||
scrobblingTab.addEventListener("click", () => {
|
||||
if (authSession.isAuthenticated()) {
|
||||
window.loadScrobblingConfig();
|
||||
}
|
||||
});
|
||||
}
|
||||
initScrobblingView({
|
||||
isAuthenticated: () => authSession.isAuthenticated(),
|
||||
loadScrobblingConfig: () => window.loadScrobblingConfig?.(),
|
||||
});
|
||||
|
||||
authSession.bootstrapAuth();
|
||||
});
|
||||
|
||||
@@ -1,17 +1,100 @@
|
||||
// Modal management
|
||||
const modalState = new Map();
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function getModal(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function getFocusableElements(modal) {
|
||||
return Array.from(modal.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"),
|
||||
);
|
||||
}
|
||||
|
||||
function onModalKeyDown(event, modal) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeModal(modal.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusable = getFocusableElements(modal);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const isShift = event.shiftKey;
|
||||
|
||||
if (isShift && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!isShift && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function openModal(id) {
|
||||
document.getElementById(id).classList.add('active');
|
||||
const modal = getModal(id);
|
||||
if (!modal) return;
|
||||
|
||||
const modalContent = modal.querySelector(".modal-content");
|
||||
if (!modalContent) return;
|
||||
|
||||
const previousActive = document.activeElement;
|
||||
modalState.set(id, { previousActive });
|
||||
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.removeAttribute("aria-hidden");
|
||||
modal.classList.add("active");
|
||||
|
||||
const keydownHandler = (event) => onModalKeyDown(event, modal);
|
||||
modalState.set(id, { previousActive, keydownHandler });
|
||||
modal.addEventListener("keydown", keydownHandler);
|
||||
|
||||
const focusable = getFocusableElements(modalContent);
|
||||
if (focusable.length > 0) {
|
||||
focusable[0].focus();
|
||||
} else {
|
||||
modalContent.setAttribute("tabindex", "-1");
|
||||
modalContent.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('active');
|
||||
const modal = getModal(id);
|
||||
if (!modal) return;
|
||||
|
||||
modal.classList.remove("active");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
|
||||
const state = modalState.get(id);
|
||||
if (state?.keydownHandler) {
|
||||
modal.removeEventListener("keydown", state.keydownHandler);
|
||||
}
|
||||
|
||||
if (state?.previousActive && typeof state.previousActive.focus === "function") {
|
||||
state.previousActive.focus();
|
||||
}
|
||||
|
||||
modalState.delete(id);
|
||||
}
|
||||
|
||||
export function setupModalBackdropClose() {
|
||||
document.querySelectorAll('.modal').forEach(modal => {
|
||||
modal.addEventListener('click', e => {
|
||||
if (e.target === modal) closeModal(modal.id);
|
||||
});
|
||||
document.querySelectorAll(".modal").forEach((modal) => {
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeModal(modal.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,6 +77,20 @@ function downloadAllKept() {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAllKept() {
|
||||
const result = await runAction({
|
||||
confirmMessage:
|
||||
"Delete ALL kept downloads?\n\nThis will permanently remove all kept audio files.",
|
||||
task: () => API.deleteAllDownloads(),
|
||||
success: (data) => data.message || "All kept downloads deleted",
|
||||
error: (err) => err.message || "Failed to delete all kept downloads",
|
||||
});
|
||||
|
||||
if (result) {
|
||||
await fetchDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDownload(path) {
|
||||
const result = await runAction({
|
||||
confirmMessage: `Delete this file?\n\n${path}\n\nThis action cannot be undone.`,
|
||||
@@ -364,6 +378,7 @@ export function initOperations(options) {
|
||||
window.deleteTrackMapping = deleteTrackMapping;
|
||||
window.downloadFile = downloadFile;
|
||||
window.downloadAllKept = downloadAllKept;
|
||||
window.deleteAllKept = deleteAllKept;
|
||||
window.deleteDownload = deleteDownload;
|
||||
window.refreshPlaylists = refreshPlaylists;
|
||||
window.refreshPlaylist = refreshPlaylist;
|
||||
|
||||
@@ -70,7 +70,12 @@ async function openLinkPlaylist(jellyfinId, name) {
|
||||
}
|
||||
|
||||
try {
|
||||
spotifyUserPlaylists = await API.fetchSpotifyUserPlaylists(selectedUserId);
|
||||
const response = await API.fetchSpotifyUserPlaylists(selectedUserId);
|
||||
spotifyUserPlaylists = Array.isArray(response?.playlists)
|
||||
? response.playlists
|
||||
: Array.isArray(response)
|
||||
? response
|
||||
: [];
|
||||
spotifyUserPlaylistsScopeUserId = selectedUserId;
|
||||
const availablePlaylists = spotifyUserPlaylists.filter((p) => !p.isLinked);
|
||||
|
||||
|
||||
+426
-98
@@ -3,6 +3,9 @@
|
||||
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
|
||||
|
||||
let rowMenuHandlersBound = false;
|
||||
let tableRowHandlersBound = false;
|
||||
const expandedInjectedPlaylistDetails = new Set();
|
||||
let openInjectedPlaylistMenuKey = null;
|
||||
|
||||
function bindRowMenuHandlers() {
|
||||
if (rowMenuHandlersBound) {
|
||||
@@ -16,12 +19,55 @@ function bindRowMenuHandlers() {
|
||||
rowMenuHandlersBound = true;
|
||||
}
|
||||
|
||||
function bindTableRowHandlers() {
|
||||
if (tableRowHandlersBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const detailsTrigger = event.target.closest?.(
|
||||
"button.details-trigger[data-details-target]",
|
||||
);
|
||||
if (detailsTrigger) {
|
||||
const target = detailsTrigger.getAttribute("data-details-target");
|
||||
if (target) {
|
||||
toggleDetailsRow(event, target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const row = event.target.closest?.("tr.compact-row[data-details-row]");
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("button, a, .row-actions-menu")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailsRowId = row.getAttribute("data-details-row");
|
||||
if (detailsRowId) {
|
||||
toggleDetailsRow(null, detailsRowId);
|
||||
}
|
||||
});
|
||||
|
||||
tableRowHandlersBound = true;
|
||||
}
|
||||
|
||||
function closeAllRowMenus(exceptId = null) {
|
||||
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) {
|
||||
@@ -32,6 +78,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +101,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) {
|
||||
@@ -82,6 +143,18 @@ function toggleDetailsRow(event, detailsRowId) {
|
||||
);
|
||||
if (parentRow) {
|
||||
parentRow.classList.toggle("expanded", isExpanded);
|
||||
|
||||
// Persist Injected Playlists details expansion across auto-refreshes.
|
||||
if (parentRow.closest("#playlist-table-body")) {
|
||||
const detailsKey = parentRow.getAttribute("data-details-key");
|
||||
if (detailsKey) {
|
||||
if (isExpanded) {
|
||||
expandedInjectedPlaylistDetails.add(detailsKey);
|
||||
} else {
|
||||
expandedInjectedPlaylistDetails.delete(detailsKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +248,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;
|
||||
@@ -183,10 +525,11 @@ if (typeof window !== "undefined") {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const backendTypeEl = document.getElementById("backend-type");
|
||||
if (backendTypeEl) backendTypeEl.textContent = data.backendType;
|
||||
@@ -268,9 +611,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", [
|
||||
@@ -324,89 +673,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 syncSchedule = playlist.syncSchedule || "0 8 * * *";
|
||||
const escapedPlaylistName = escapeJs(playlist.name);
|
||||
const escapedSyncSchedule = escapeJs(syncSchedule);
|
||||
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" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
|
||||
<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="false"
|
||||
onclick="toggleDetailsRow(event, '${detailsRowId}')">Details</button>
|
||||
<div class="row-actions-wrap">
|
||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
||||
onclick="toggleRowMenu(event, '${menuId}')">...</button>
|
||||
<div class="row-actions-menu" id="${menuId}" role="menu">
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); viewTracks('${escapedPlaylistName}')">View Tracks</button>
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); refreshPlaylist('${escapedPlaylistName}')">Refresh</button>
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); matchPlaylistTracks('${escapedPlaylistName}')">Rematch</button>
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); clearPlaylistCache('${escapedPlaylistName}')">Rebuild</button>
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit Schedule</button>
|
||||
<hr>
|
||||
<button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); removePlaylist('${escapedPlaylistName}')">Remove Playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="${detailsRowId}" class="details-row" 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" onclick="editPlaylistSchedule('${escapedPlaylistName}', '${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) {
|
||||
@@ -478,9 +806,9 @@ export function updateDownloadsUI(data) {
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||
<td>
|
||||
<button onclick="downloadFile('${escapeJs(f.path)}')"
|
||||
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
|
||||
<button onclick="deleteDownload('${escapeJs(f.path)}')"
|
||||
<button data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -634,26 +962,27 @@ export function updateJellyfinPlaylistsUI(data) {
|
||||
.map((playlist, index) => {
|
||||
const detailsRowId = `jellyfin-details-${index}`;
|
||||
const menuId = `jellyfin-menu-${index}`;
|
||||
const statsPending = Boolean(playlist.statsPending);
|
||||
const localCount = playlist.localTracks || 0;
|
||||
const externalCount = playlist.externalTracks || 0;
|
||||
const externalAvailable = playlist.externalAvailable || 0;
|
||||
const escapedId = escapeJs(playlist.id);
|
||||
const escapedName = escapeJs(playlist.name);
|
||||
const escapedId = escapeHtml(playlist.id);
|
||||
const escapedName = escapeHtml(playlist.name);
|
||||
const statusClass = playlist.isConfigured ? "success" : "info";
|
||||
const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked";
|
||||
|
||||
const actionButtons = playlist.isConfigured
|
||||
? `
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
|
||||
<button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); unlinkPlaylist('${escapedId}', '${escapedName}')">Unlink from Spotify</button>
|
||||
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
|
||||
<button class="danger-item" data-action="unlinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Unlink from Spotify</button>
|
||||
`
|
||||
: `
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); openLinkPlaylist('${escapedId}', '${escapedName}')">Link to Spotify</button>
|
||||
<button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
|
||||
<button data-action="openLinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Link to Spotify</button>
|
||||
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
|
||||
`;
|
||||
|
||||
return `
|
||||
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
|
||||
<tr class="compact-row" data-details-row="${detailsRowId}">
|
||||
<td>
|
||||
<div class="name-cell">
|
||||
<strong>${escapeHtml(playlist.name)}</strong>
|
||||
@@ -661,16 +990,15 @@ export function updateJellyfinPlaylistsUI(data) {
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="track-count">${localCount + externalAvailable}</span>
|
||||
<div class="meta-text">L ${localCount} • E ${externalAvailable}/${externalCount}</div>
|
||||
<span class="track-count">${statsPending ? "..." : localCount + externalAvailable}</span>
|
||||
<div class="meta-text">${statsPending ? "Loading track stats..." : `L ${localCount} • E ${externalAvailable}/${externalCount}`}</div>
|
||||
</td>
|
||||
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
|
||||
<td class="row-controls">
|
||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false"
|
||||
onclick="toggleDetailsRow(event, '${detailsRowId}')">Details</button>
|
||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false">Details</button>
|
||||
<div class="row-actions-wrap">
|
||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
||||
onclick="toggleRowMenu(event, '${menuId}')">...</button>
|
||||
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
||||
<div class="row-actions-menu" id="${menuId}" role="menu">
|
||||
${actionButtons}
|
||||
</div>
|
||||
@@ -683,11 +1011,11 @@ export function updateJellyfinPlaylistsUI(data) {
|
||||
<div class="details-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Local Tracks</span>
|
||||
<span class="detail-value">${localCount}</span>
|
||||
<span class="detail-value">${statsPending ? "..." : localCount}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">External Tracks</span>
|
||||
<span class="detail-value">${externalAvailable}/${externalCount}</span>
|
||||
<span class="detail-value">${statsPending ? "Loading..." : `${externalAvailable}/${externalCount}`}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Linked Spotify ID</span>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
This folder contains small “view” modules for the admin UI.
|
||||
|
||||
Goal: keep `js/main.js` as orchestration only, while view modules encapsulate DOM wiring for each section.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export function initNavigationView({ switchTab } = {}) {
|
||||
const doSwitch =
|
||||
typeof switchTab === "function" ? switchTab : (tab) => window.switchTab?.(tab);
|
||||
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
doSwitch(tab.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".sidebar-link").forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
doSwitch(link.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
doSwitch(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export function initScrobblingView({
|
||||
isAuthenticated,
|
||||
loadScrobblingConfig,
|
||||
} = {}) {
|
||||
const canLoad =
|
||||
typeof isAuthenticated === "function" ? isAuthenticated : () => false;
|
||||
const load =
|
||||
typeof loadScrobblingConfig === "function"
|
||||
? loadScrobblingConfig
|
||||
: () => window.loadScrobblingConfig?.();
|
||||
|
||||
function onActivateScrobbling() {
|
||||
if (canLoad()) {
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
|
||||
if (scrobblingTab) {
|
||||
scrobblingTab.addEventListener("click", onActivateScrobbling);
|
||||
}
|
||||
|
||||
const scrobblingSidebar = document.querySelector(
|
||||
'.sidebar-link[data-tab="scrobbling"]',
|
||||
);
|
||||
if (scrobblingSidebar) {
|
||||
scrobblingSidebar.addEventListener("click", onActivateScrobbling);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spotify Track Mappings - Allstarr</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
@@ -668,13 +669,30 @@
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<p>
|
||||
<p class="support-text">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
supporting its development
|
||||
</p>
|
||||
<ul class="support-funding-icons">
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Support on Ko-fi">
|
||||
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="GitHub Sponsors">
|
||||
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
|
||||
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,6 +15,7 @@ let localMapContext = null;
|
||||
let localMapResults = [];
|
||||
let localMapSelectedIndex = -1;
|
||||
let externalMapContext = null;
|
||||
const modalFocusState = new Map();
|
||||
|
||||
function showToast(message, type = "success", duration = 3000) {
|
||||
const toast = document.createElement("div");
|
||||
@@ -247,9 +248,26 @@ function toggleModal(modalId, shouldOpen) {
|
||||
}
|
||||
|
||||
if (shouldOpen) {
|
||||
const previousActive = document.activeElement;
|
||||
modalFocusState.set(modalId, previousActive);
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.removeAttribute("aria-hidden");
|
||||
modal.classList.add("active");
|
||||
const firstFocusable = modal.querySelector(
|
||||
'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus();
|
||||
}
|
||||
} else {
|
||||
modal.classList.remove("active");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
const previousActive = modalFocusState.get(modalId);
|
||||
if (previousActive && typeof previousActive.focus === "function") {
|
||||
previousActive.focus();
|
||||
}
|
||||
modalFocusState.delete(modalId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,6 +645,10 @@ function initializeEventListeners() {
|
||||
closeLocalMapModal();
|
||||
closeExternalMapModal();
|
||||
});
|
||||
|
||||
document.querySelectorAll(".modal-overlay").forEach((modal) => {
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
|
||||
+384
-27
@@ -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;
|
||||
@@ -97,10 +117,165 @@ body {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.support-text {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.support-funding-icons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.support-funding-icons li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.support-badge .support-funding-icons {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.support-footer .support-funding-icons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.support-funding-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.9;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.support-badge .support-funding-link:hover,
|
||||
.support-footer .support-funding-link:hover {
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.support-funding-link img {
|
||||
display: block;
|
||||
height: 30px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto 0 0;
|
||||
padding: 20px 20px 20px 8px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 14px;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.title-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title-link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.sidebar-subtitle {
|
||||
margin-top: 2px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
|
||||
monospace;
|
||||
}
|
||||
|
||||
.sidebar-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;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: rgba(33, 38, 45, 0.7);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
border-color: rgba(88, 166, 255, 0.35);
|
||||
color: #9ecbff;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-footer button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-tabs,
|
||||
.tabs.top-tabs {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.support-footer {
|
||||
@@ -112,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;
|
||||
@@ -140,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;
|
||||
@@ -646,7 +800,8 @@ button.danger:hover {
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
@@ -656,15 +811,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;
|
||||
@@ -897,6 +1061,28 @@ input::placeholder {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.report-issue-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.report-preview-panel textarea {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.support-badge {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
@@ -919,6 +1105,177 @@ 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;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mb-16 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mt-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-row-wrap {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flex-row-wrap-8 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.summary-value.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.summary-value.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.summary-value.accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.callout {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.callout.warning {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-color: var(--warning);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.callout.warning-strong {
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
border-color: #ffc107;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.callout.danger {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
border-color: var(--error);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pill-card {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stats-grid-auto {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.max-h-600 {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.jellyfin-user-form-group {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.jellyfin-user-form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tracks-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
Reference in New Issue
Block a user