mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 03:53:10 -04:00
Compare commits
16 Commits
v1.3.2
...
v1.0.3-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
8d6dd7ccf1
|
|||
|
ebdd8d4e2a
|
|||
|
e4599a419e
|
|||
|
86290dff0d
|
|||
|
0a9e528418
|
|||
|
f74728fc73
|
|||
|
87467be61b
|
|||
|
713ecd4ec8
|
|||
|
0ff1e3a428
|
|||
|
cef18b9482
|
|||
|
1bfe30b216
|
|||
|
c9c82a650d
|
|||
|
d0a7dbcc96
|
|||
|
9c9a827a91
|
|||
|
96889738df
|
|||
|
f3c791496e
|
+51
-17
@@ -1,15 +1,51 @@
|
||||
# ===== BACKEND SELECTION =====
|
||||
# Choose which media server backend to use: Subsonic or Jellyfin
|
||||
BACKEND_TYPE=Subsonic
|
||||
BACKEND_TYPE=Jellyfin
|
||||
|
||||
# ===== REDIS CACHE =====
|
||||
# Enable Redis caching for metadata and images (default: true)
|
||||
REDIS_ENABLED=true
|
||||
# ===== REDIS CACHE (REQUIRED) =====
|
||||
# Redis is the primary cache for all runtime data (search results, playlists, lyrics, etc.)
|
||||
# File cache (/app/cache) acts as a persistence layer for cold starts
|
||||
# Redis snapshots to disk every 60 seconds + AOF for durability
|
||||
|
||||
# Redis data persistence directory (default: ./redis-data)
|
||||
# Redis will save snapshots and append-only logs here to persist cache across restarts
|
||||
# Contains Redis RDB snapshots and AOF logs for crash recovery
|
||||
REDIS_DATA_PATH=./redis-data
|
||||
|
||||
# ===== CACHE TTL SETTINGS =====
|
||||
# Configure how long different types of data are cached
|
||||
# Longer durations reduce API calls but may show stale data
|
||||
# All values are configurable via Web UI (Configuration tab > Cache Settings)
|
||||
# Changes require container restart to apply
|
||||
|
||||
# Search results cache duration in minutes (default: 120 = 2 hours)
|
||||
CACHE_SEARCH_RESULTS_MINUTES=120
|
||||
|
||||
# Playlist cover images cache duration in hours (default: 168 = 1 week)
|
||||
CACHE_PLAYLIST_IMAGES_HOURS=168
|
||||
|
||||
# Spotify playlist items cache duration in hours (default: 168 = 1 week)
|
||||
CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS=168
|
||||
|
||||
# Spotify matched tracks cache duration in days (default: 30 days)
|
||||
# This is the mapping of Spotify IDs to local/external tracks
|
||||
CACHE_SPOTIFY_MATCHED_TRACKS_DAYS=30
|
||||
|
||||
# Lyrics cache duration in days (default: 14 = 2 weeks)
|
||||
CACHE_LYRICS_DAYS=14
|
||||
|
||||
# Genre data cache duration in days (default: 30 days)
|
||||
CACHE_GENRE_DAYS=30
|
||||
|
||||
# External metadata (SquidWTF/Deezer/Qobuz) cache duration in days (default: 7 days)
|
||||
CACHE_METADATA_DAYS=7
|
||||
|
||||
# Odesli URL conversion cache duration in days (default: 60 days)
|
||||
CACHE_ODESLI_LOOKUP_DAYS=60
|
||||
|
||||
# Jellyfin proxy images cache duration in days (default: 14 = 2 weeks)
|
||||
CACHE_PROXY_IMAGES_DAYS=14
|
||||
|
||||
|
||||
# ===== SUBSONIC/NAVIDROME CONFIGURATION =====
|
||||
# Server URL (required if using Subsonic backend)
|
||||
SUBSONIC_URL=http://localhost:4533
|
||||
@@ -40,11 +76,16 @@ MUSIC_SERVICE=SquidWTF
|
||||
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
||||
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
||||
# - downloads/kept/ - Favorited external tracks (always permanent)
|
||||
DOWNLOAD_PATH=./downloads
|
||||
Library__DownloadPath=./downloads
|
||||
|
||||
# ===== SQUIDWTF CONFIGURATION =====
|
||||
# Different quality options for SquidWTF. Only FLAC supported right now
|
||||
SQUIDWTF_QUALITY=FLAC
|
||||
# Preferred audio quality (optional, default: LOSSLESS)
|
||||
# - HI_RES or HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest quality)
|
||||
# - FLAC or LOSSLESS: 16-bit/44.1kHz FLAC (CD quality, recommended)
|
||||
# - HIGH: 320kbps AAC (high quality, smaller files)
|
||||
# - LOW: 96kbps AAC (low quality, smallest files)
|
||||
# If not specified, LOSSLESS (16-bit FLAC) will be used
|
||||
SQUIDWTF_QUALITY=LOSSLESS
|
||||
|
||||
# ===== DEEZER CONFIGURATION =====
|
||||
# Deezer ARL token (required if using Deezer)
|
||||
@@ -95,12 +136,12 @@ EXPLICIT_FILTER=All
|
||||
# The played track is downloaded first, remaining tracks are queued
|
||||
DOWNLOAD_MODE=Track
|
||||
|
||||
# Storage mode (optional, default: Permanent)
|
||||
# Storage mode (optional, default: Cache)
|
||||
# - Permanent: Files are saved to the library permanently and registered in the media server
|
||||
# - Cache: Files are stored in /tmp and automatically cleaned up after CACHE_DURATION_HOURS
|
||||
# Not registered in media server, ideal for streaming without library bloat
|
||||
# Note: On Linux/Docker, you can customize cache location by setting TMPDIR environment variable
|
||||
STORAGE_MODE=Permanent
|
||||
STORAGE_MODE=Cache
|
||||
|
||||
# Cache duration in hours (optional, default: 1)
|
||||
# Files older than this duration will be automatically deleted when STORAGE_MODE=Cache
|
||||
@@ -143,13 +184,6 @@ SPOTIFY_IMPORT_PLAYLISTS=[]
|
||||
# Enable direct Spotify API access (default: false)
|
||||
SPOTIFY_API_ENABLED=false
|
||||
|
||||
# Spotify Client ID from https://developer.spotify.com/dashboard
|
||||
# Create an app in the Spotify Developer Dashboard to get this
|
||||
SPOTIFY_API_CLIENT_ID=
|
||||
|
||||
# Spotify Client Secret (optional - only needed for certain OAuth flows)
|
||||
SPOTIFY_API_CLIENT_SECRET=
|
||||
|
||||
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
|
||||
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
|
||||
# via session cookie because they're not accessible through the official API.
|
||||
|
||||
+3
-15
@@ -83,21 +83,9 @@ cache/
|
||||
# Docker volumes
|
||||
redis-data/
|
||||
|
||||
# API keys and specs (ignore markdown docs, keep OpenAPI spec)
|
||||
apis/steering/
|
||||
apis/api-calls/*.json
|
||||
!apis/api-calls/jellyfin-openapi-stable.json
|
||||
apis/temp.json
|
||||
|
||||
# Temporary documentation files
|
||||
apis/*.md
|
||||
|
||||
# Log files for debugging
|
||||
apis/api-calls/*.log
|
||||
|
||||
# Endpoint usage tracking
|
||||
apis/api-calls/endpoint-usage.json
|
||||
/app/cache/endpoint-usage/
|
||||
# Ignore everything in apis folder except jellyfin-openapi-stable.json
|
||||
apis/*
|
||||
!apis/jellyfin-openapi-stable.json
|
||||
|
||||
# Original source code for reference
|
||||
originals/
|
||||
|
||||
@@ -37,6 +37,8 @@ The proxy will be available at `http://localhost:5274`.
|
||||
## Web Dashboard
|
||||
|
||||
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
||||
<img width="1664" height="1101" alt="image" src="https://github.com/user-attachments/assets/9159100b-7e11-449e-8530-517d336d6bd2" />
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
@@ -74,8 +76,6 @@ There's an environment variable to modify this.
|
||||
|
||||
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
|
||||
|
||||
|
||||
|
||||
### Nginx Proxy Setup (Required)
|
||||
|
||||
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
|
||||
@@ -139,8 +139,14 @@ This project brings together all the music streaming providers into one unified
|
||||
**Compatible Jellyfin clients:**
|
||||
|
||||
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
|
||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
|
||||
- [Finamp](https://github.com/jmshrv/finamp) ()
|
||||
<img width="1691" height="1128" alt="image" src="https://github.com/user-attachments/assets/c602f71c-c4dd-49a9-b533-1558e24a9f45" />
|
||||
|
||||
|
||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android)
|
||||
<img width="523" height="1025" alt="image" src="https://github.com/user-attachments/assets/135e2721-5fd7-482f-bb06-b0736003cfe7" />
|
||||
|
||||
|
||||
- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS)
|
||||
|
||||
_Working on getting more currently_
|
||||
|
||||
@@ -336,6 +342,9 @@ Subsonic__EnableExternalPlaylists=false
|
||||
|
||||
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
|
||||
|
||||
<img width="1649" height="3764" alt="image" src="https://github.com/user-attachments/assets/a4d3d79c-7741-427f-8c01-ffc90f3a579b" />
|
||||
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
1. **Install the Jellyfin Spotify Import Plugin**
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using System.Collections.Generic;
|
||||
using allstarr.Filters;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class ApiKeyAuthFilterTests
|
||||
{
|
||||
private readonly Mock<ILogger<ApiKeyAuthFilter>> _loggerMock;
|
||||
private readonly IOptions<JellyfinSettings> _options;
|
||||
|
||||
public ApiKeyAuthFilterTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<ApiKeyAuthFilter>>();
|
||||
_options = Options.Create(new JellyfinSettings { ApiKey = "secret-key" });
|
||||
}
|
||||
|
||||
private static (ActionExecutingContext ExecContext, ActionContext ActionContext) CreateContext(HttpContext httpContext)
|
||||
{
|
||||
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
|
||||
var execContext = new ActionExecutingContext(actionContext, new List<IFilterMetadata>(), new Dictionary<string, object?>(), controller: new object());
|
||||
return (execContext, actionContext);
|
||||
}
|
||||
|
||||
private static ActionExecutionDelegate CreateNext(ActionContext actionContext, Action onInvoke)
|
||||
{
|
||||
return () =>
|
||||
{
|
||||
onInvoke();
|
||||
var executedContext = new ActionExecutedContext(actionContext, new List<IFilterMetadata>(), controller: new object());
|
||||
return Task.FromResult(executedContext);
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnActionExecutionAsync_WithValidHeader_AllowsRequest()
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers["X-Api-Key"] = "secret-key";
|
||||
|
||||
var (ctx, actionCtx) = CreateContext(httpContext);
|
||||
var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object);
|
||||
|
||||
var invoked = false;
|
||||
var next = CreateNext(actionCtx, () => invoked = true);
|
||||
|
||||
// Act
|
||||
await filter.OnActionExecutionAsync(ctx, next);
|
||||
|
||||
// Assert
|
||||
Assert.True(invoked, "Next delegate should be invoked for valid API key header");
|
||||
Assert.Null(ctx.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnActionExecutionAsync_WithValidQuery_AllowsRequest()
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.QueryString = new QueryString("?api_key=secret-key");
|
||||
|
||||
var (ctx, actionCtx) = CreateContext(httpContext);
|
||||
var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object);
|
||||
|
||||
var invoked = false;
|
||||
var next = CreateNext(actionCtx, () => invoked = true);
|
||||
|
||||
// Act
|
||||
await filter.OnActionExecutionAsync(ctx, next);
|
||||
|
||||
// Assert
|
||||
Assert.True(invoked, "Next delegate should be invoked for valid API key query");
|
||||
Assert.Null(ctx.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnActionExecutionAsync_WithXEmbyTokenHeader_AllowsRequest()
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers["X-Emby-Token"] = "secret-key";
|
||||
|
||||
var (ctx, actionCtx) = CreateContext(httpContext);
|
||||
var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object);
|
||||
|
||||
var invoked = false;
|
||||
var next = CreateNext(actionCtx, () => invoked = true);
|
||||
|
||||
// Act
|
||||
await filter.OnActionExecutionAsync(ctx, next);
|
||||
|
||||
// Assert
|
||||
Assert.True(invoked, "Next delegate should be invoked for valid X-Emby-Token header");
|
||||
Assert.Null(ctx.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnActionExecutionAsync_WithMissingKey_ReturnsUnauthorized()
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
var (ctx, actionCtx) = CreateContext(httpContext);
|
||||
var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object);
|
||||
|
||||
var invoked = false;
|
||||
var next = CreateNext(actionCtx, () => invoked = true);
|
||||
|
||||
// Act
|
||||
await filter.OnActionExecutionAsync(ctx, next);
|
||||
|
||||
// Assert
|
||||
Assert.False(invoked, "Next delegate should not be invoked when API key is missing");
|
||||
Assert.IsType<UnauthorizedObjectResult>(ctx.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnActionExecutionAsync_WithWrongKey_ReturnsUnauthorized()
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers["X-Api-Key"] = "wrong-key";
|
||||
|
||||
var (ctx, actionCtx) = CreateContext(httpContext);
|
||||
var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object);
|
||||
|
||||
var invoked = false;
|
||||
var next = CreateNext(actionCtx, () => invoked = true);
|
||||
|
||||
// Act
|
||||
await filter.OnActionExecutionAsync(ctx, next);
|
||||
|
||||
// Assert
|
||||
Assert.False(invoked, "Next delegate should not be invoked for wrong API key");
|
||||
Assert.IsType<UnauthorizedObjectResult>(ctx.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnActionExecutionAsync_ConstantTimeComparison_WorksForDifferentLengths()
|
||||
{
|
||||
// Arrange
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers["X-Api-Key"] = "short";
|
||||
|
||||
var (ctx, actionCtx) = CreateContext(httpContext);
|
||||
var filter = new ApiKeyAuthFilter(Options.Create(new JellyfinSettings { ApiKey = "much-longer-secret-key" }), _loggerMock.Object);
|
||||
|
||||
var invoked = false;
|
||||
var next = CreateNext(actionCtx, () => invoked = true);
|
||||
|
||||
// Act
|
||||
await filter.OnActionExecutionAsync(ctx, next);
|
||||
|
||||
// Assert
|
||||
Assert.False(invoked, "Next should not be invoked for wrong key");
|
||||
Assert.IsType<UnauthorizedObjectResult>(ctx.Result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using Xunit;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class FuzzyMatcherTests
|
||||
{
|
||||
|
||||
[Theory]
|
||||
[InlineData("Mr. Brightside", "Mr. Brightside", 100)]
|
||||
[InlineData("Mr Brightside", "Mr. Brightside", 100)]
|
||||
[InlineData("Mr. Brightside", "Mr Brightside", 100)]
|
||||
[InlineData("The Killers", "Killers", 85)]
|
||||
[InlineData("Dua Lipa", "Dua-Lipa", 100)]
|
||||
public void CalculateSimilarity_ExactAndNearMatches_ReturnsHighScore(string str1, string str2, int expectedMin)
|
||||
{
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= expectedMin, $"Expected score >= {expectedMin}, got {score}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Mr. Brightside", "Somebody Told Me", 20)]
|
||||
[InlineData("The Killers", "The Beatles", 40)]
|
||||
[InlineData("Hot Fuss", "Sam's Town", 20)]
|
||||
public void CalculateSimilarity_DifferentStrings_ReturnsLowScore(string str1, string str2, int expectedMax)
|
||||
{
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score <= expectedMax, $"Expected score <= {expectedMax}, got {score}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_IgnoresPunctuation()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Don't Stop Believin'";
|
||||
var str2 = "Dont Stop Believin";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 95, $"Expected high score for punctuation differences, got {score}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_IgnoresCase()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Mr. Brightside";
|
||||
var str2 = "mr. brightside";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(100, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_HandlesArticles()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "The Killers";
|
||||
var str2 = "Killers";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 80, $"Expected high score when 'The' is removed, got {score}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_HandlesFeaturedArtists()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Song Title (feat. Artist)";
|
||||
var str2 = "Song Title";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 70, $"Expected decent score for featured artist variations, got {score}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_HandlesRemixes()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Song Title - Radio Edit";
|
||||
var str2 = "Song Title";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 70, $"Expected decent score for remix/edit variations, got {score}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "", 0)]
|
||||
[InlineData("Test", "", 0)]
|
||||
[InlineData("", "Test", 0)]
|
||||
public void CalculateSimilarity_EmptyStrings_ReturnsZero(string str1, string str2, int expected)
|
||||
{
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected, score);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_TokenOrder_DoesNotMatter()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Bright Side Mr";
|
||||
var str2 = "Mr Bright Side";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 90, $"Expected high score regardless of token order, got {score}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_PartialTokenMatch_ReturnsModerateScore()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Mr. Brightside";
|
||||
var str2 = "Mr. Brightside (Live)";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 70 && score < 100, $"Expected moderate score for partial match, got {score}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSimilarity_SpecialCharacters_AreNormalized()
|
||||
{
|
||||
// Arrange
|
||||
var str1 = "Café del Mar";
|
||||
var str2 = "Cafe del Mar";
|
||||
|
||||
// Act
|
||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||
|
||||
// Assert
|
||||
Assert.True(score >= 90, $"Expected high score for accented characters, got {score}");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System.Diagnostics;
|
||||
using Xunit;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests to validate JavaScript syntax in wwwroot files.
|
||||
/// This prevents broken JavaScript from being committed.
|
||||
/// </summary>
|
||||
public class JavaScriptSyntaxTests
|
||||
{
|
||||
private readonly string _wwwrootPath;
|
||||
|
||||
public JavaScriptSyntaxTests()
|
||||
{
|
||||
// Get the path to the wwwroot directory
|
||||
var projectRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", ".."));
|
||||
_wwwrootPath = Path.Combine(projectRoot, "allstarr", "wwwroot");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppJs_ShouldHaveValidSyntax()
|
||||
{
|
||||
var filePath = Path.Combine(_wwwrootPath, "app.js");
|
||||
Assert.True(File.Exists(filePath), $"app.js not found at {filePath}");
|
||||
|
||||
var isValid = ValidateJavaScriptSyntax(filePath, out var error);
|
||||
Assert.True(isValid, $"app.js has syntax errors:\n{error}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotifyMappingsJs_ShouldHaveValidSyntax()
|
||||
{
|
||||
var filePath = Path.Combine(_wwwrootPath, "spotify-mappings.js");
|
||||
Assert.True(File.Exists(filePath), $"spotify-mappings.js not found at {filePath}");
|
||||
|
||||
var isValid = ValidateJavaScriptSyntax(filePath, out var error);
|
||||
Assert.True(isValid, $"spotify-mappings.js has syntax errors:\n{error}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModularJs_UtilsShouldHaveValidSyntax()
|
||||
{
|
||||
var filePath = Path.Combine(_wwwrootPath, "js", "utils.js");
|
||||
Assert.True(File.Exists(filePath), $"js/utils.js not found at {filePath}");
|
||||
|
||||
var isValid = ValidateJavaScriptSyntax(filePath, out var error);
|
||||
Assert.True(isValid, $"js/utils.js has syntax errors:\n{error}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModularJs_ApiShouldHaveValidSyntax()
|
||||
{
|
||||
var filePath = Path.Combine(_wwwrootPath, "js", "api.js");
|
||||
Assert.True(File.Exists(filePath), $"js/api.js not found at {filePath}");
|
||||
|
||||
var isValid = ValidateJavaScriptSyntax(filePath, out var error);
|
||||
Assert.True(isValid, $"js/api.js has syntax errors:\n{error}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ModularJs_MainShouldHaveValidSyntax()
|
||||
{
|
||||
var filePath = Path.Combine(_wwwrootPath, "js", "main.js");
|
||||
Assert.True(File.Exists(filePath), $"js/main.js not found at {filePath}");
|
||||
|
||||
var isValid = ValidateJavaScriptSyntax(filePath, out var error);
|
||||
Assert.True(isValid, $"js/main.js has syntax errors:\n{error}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppJs_ShouldBeDeprecated()
|
||||
{
|
||||
var filePath = Path.Combine(_wwwrootPath, "app.js");
|
||||
var content = File.ReadAllText(filePath);
|
||||
|
||||
// Check that the file is now just a deprecation notice
|
||||
Assert.Contains("DEPRECATED", content);
|
||||
Assert.Contains("main.js", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MainJs_ShouldBeComplete()
|
||||
{
|
||||
var filePath = Path.Combine(_wwwrootPath, "js", "main.js");
|
||||
var content = File.ReadAllText(filePath);
|
||||
|
||||
// Check that critical window functions exist
|
||||
Assert.Contains("window.fetchStatus", content);
|
||||
Assert.Contains("window.fetchPlaylists", content);
|
||||
Assert.Contains("window.fetchConfig", content);
|
||||
Assert.Contains("window.fetchEndpointUsage", content);
|
||||
|
||||
// Check that the file has proper initialization
|
||||
Assert.Contains("DOMContentLoaded", content);
|
||||
Assert.Contains("window.fetchStatus();", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppJs_ShouldHaveBalancedBraces()
|
||||
{
|
||||
// app.js is now deprecated and just contains comments
|
||||
// Skip this test or check main.js instead
|
||||
var filePath = Path.Combine(_wwwrootPath, "js", "main.js");
|
||||
var content = File.ReadAllText(filePath);
|
||||
|
||||
var openBraces = content.Count(c => c == '{');
|
||||
var closeBraces = content.Count(c => c == '}');
|
||||
|
||||
Assert.Equal(openBraces, closeBraces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppJs_ShouldHaveBalancedParentheses()
|
||||
{
|
||||
// app.js is now deprecated and just contains comments
|
||||
// Skip this test or check main.js instead
|
||||
var filePath = Path.Combine(_wwwrootPath, "js", "main.js");
|
||||
var content = File.ReadAllText(filePath);
|
||||
|
||||
// Remove strings and comments to avoid false positives
|
||||
var cleanedContent = RemoveStringsAndComments(content);
|
||||
|
||||
var openParens = cleanedContent.Count(c => c == '(');
|
||||
var closeParens = cleanedContent.Count(c => c == ')');
|
||||
|
||||
Assert.Equal(openParens, closeParens);
|
||||
}
|
||||
|
||||
private bool ValidateJavaScriptSyntax(string filePath, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
// Use Node.js to check syntax
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "node",
|
||||
Arguments = $"--check \"{filePath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
var stderr = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
error = stderr;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Failed to run Node.js syntax check: {ex.Message}\n" +
|
||||
"Make sure Node.js is installed and available in PATH.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string RemoveStringsAndComments(string content)
|
||||
{
|
||||
// Simple removal of strings and comments for brace counting
|
||||
// This is not perfect but good enough for basic validation
|
||||
var result = content;
|
||||
|
||||
// Remove single-line comments
|
||||
result = System.Text.RegularExpressions.Regex.Replace(result, @"//.*$", "", System.Text.RegularExpressions.RegexOptions.Multiline);
|
||||
|
||||
// Remove multi-line comments
|
||||
result = System.Text.RegularExpressions.Regex.Replace(result, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||
|
||||
// Remove strings (simple approach)
|
||||
result = System.Text.RegularExpressions.Regex.Replace(result, @"""(?:[^""\\]|\\.)*""", "");
|
||||
result = System.Text.RegularExpressions.Regex.Replace(result, @"'(?:[^'\\]|\\.)*'", "");
|
||||
result = System.Text.RegularExpressions.Regex.Replace(result, @"`(?:[^`\\]|\\.)*`", "");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using allstarr.Models.Settings;
|
||||
@@ -40,13 +41,19 @@ public class JellyfinProxyServiceTests
|
||||
ClientName = "TestClient",
|
||||
DeviceName = "TestDevice",
|
||||
DeviceId = "test-device-id",
|
||||
ClientVersion = "1.0.0"
|
||||
ClientVersion = "1.0.3"
|
||||
};
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
|
||||
|
||||
// Initialize cache settings for tests
|
||||
var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
|
||||
serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults
|
||||
var serviceProvider = serviceCollection.BuildServiceProvider();
|
||||
CacheExtensions.InitializeCacheSettings(serviceProvider);
|
||||
|
||||
_service = new JellyfinProxyService(
|
||||
_mockHttpClientFactory.Object,
|
||||
Options.Create(_settings),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using allstarr.Services.Lyrics;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class LrclibServiceTests
|
||||
{
|
||||
private readonly Mock<ILogger<LrclibService>> _mockLogger;
|
||||
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
||||
private readonly Mock<RedisCacheService> _mockCache;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public LrclibServiceTests()
|
||||
{
|
||||
_mockLogger = new Mock<ILogger<LrclibService>>();
|
||||
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
|
||||
|
||||
// Create mock Redis cache
|
||||
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
||||
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
||||
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri("https://lrclib.net")
|
||||
};
|
||||
|
||||
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(_httpClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesWithDependencies()
|
||||
{
|
||||
// Act
|
||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLyricsAsync_RequiresValidParameters()
|
||||
{
|
||||
// Arrange
|
||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||
|
||||
// Act & Assert - Should handle empty parameters gracefully
|
||||
var result = service.GetLyricsAsync("", "Artist", "Album", 180);
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLyricsAsync_SupportsMultipleArtists()
|
||||
{
|
||||
// Arrange
|
||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||
var artists = new[] { "Artist 1", "Artist 2", "Artist 3" };
|
||||
|
||||
// Act
|
||||
var result = service.GetLyricsAsync("Track Name", artists, "Album", 180);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLyricsByIdAsync_AcceptsValidId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = service.GetLyricsByIdAsync(123456);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLyricsCachedAsync_UsesCache()
|
||||
{
|
||||
// Arrange
|
||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = service.GetLyricsCachedAsync("Track", "Artist", "Album", 180);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class PathHelperExtraTests : IDisposable
|
||||
{
|
||||
private readonly string _testPath;
|
||||
|
||||
public PathHelperExtraTests()
|
||||
{
|
||||
_testPath = Path.Combine(Path.GetTempPath(), "allstarr-pathhelper-extra-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(_testPath);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testPath)) Directory.Delete(_testPath, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTrackPath_WithProviderAndExternalId_SanitizesSuffix()
|
||||
{
|
||||
var downloadPath = _testPath;
|
||||
var artist = "Artist";
|
||||
var album = "Album";
|
||||
var title = "Song";
|
||||
var provider = "prov/../ider"; // contains slashes and dots
|
||||
var externalId = "..\evil|id"; // contains traversal and invalid chars
|
||||
|
||||
var path = PathHelper.BuildTrackPath(downloadPath, artist, album, title, 1, ".mp3", provider, externalId);
|
||||
|
||||
// Ensure the path contains sanitized provider/external id and no directory separators in the filename
|
||||
var fileName = Path.GetFileName(path);
|
||||
Assert.Contains("[", fileName);
|
||||
Assert.DoesNotContain("..", fileName);
|
||||
Assert.DoesNotContain("/", fileName);
|
||||
Assert.DoesNotContain("\\", fileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveUniquePath_HandlesNoDirectoryProvided()
|
||||
{
|
||||
// Arrange - create files in current directory
|
||||
var originalCurrent = Directory.GetCurrentDirectory();
|
||||
try
|
||||
{
|
||||
Directory.SetCurrentDirectory(_testPath);
|
||||
var baseName = "song.mp3";
|
||||
File.WriteAllText(Path.Combine(_testPath, baseName), "x");
|
||||
|
||||
// Act
|
||||
var unique = PathHelper.ResolveUniquePath(baseName);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(baseName, unique);
|
||||
Assert.Contains("song (1).mp3", unique);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.SetCurrentDirectory(originalCurrent);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveUniquePath_ThrowsAfterManyAttempts()
|
||||
{
|
||||
// Arrange
|
||||
var basePath = Path.Combine(_testPath, "a.mp3");
|
||||
// Create files a.mp3 through a (10010).mp3 to force exhaustion
|
||||
File.WriteAllText(basePath, "x");
|
||||
for (int i = 1; i <= 10005; i++)
|
||||
{
|
||||
var p = Path.Combine(_testPath, $"a ({i}).mp3");
|
||||
File.WriteAllText(p, "x");
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<IOException>(() => PathHelper.ResolveUniquePath(basePath));
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ public class QobuzDownloadServiceTests : IDisposable
|
||||
var mockResponse = new HttpResponseMessage
|
||||
{
|
||||
StatusCode = HttpStatusCode.OK,
|
||||
Content = new StringContent(@"<html><script src=""/resources/1.0.0-b001/bundle.js""></script></html>")
|
||||
Content = new StringContent(@"<html><script src=""/resources/1.0.3-b001/bundle.js""></script></html>")
|
||||
};
|
||||
|
||||
_httpMessageHandlerMock.Protected()
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class RedisCacheServiceTests
|
||||
{
|
||||
private readonly Mock<ILogger<RedisCacheService>> _mockLogger;
|
||||
private readonly IOptions<RedisSettings> _settings;
|
||||
|
||||
public RedisCacheServiceTests()
|
||||
{
|
||||
_mockLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
_settings = Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = false, // Disabled for unit tests to avoid requiring actual Redis
|
||||
ConnectionString = "localhost:6379"
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesWithSettings()
|
||||
{
|
||||
// Act
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
Assert.False(service.IsEnabled); // Should be disabled in tests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithEnabledSettings_AttemptsConnection()
|
||||
{
|
||||
// Arrange
|
||||
var enabledSettings = Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "localhost:6379"
|
||||
});
|
||||
|
||||
// Act - Constructor will try to connect but should handle failure gracefully
|
||||
var service = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
||||
|
||||
// Assert - Service should be created even if connection fails
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStringAsync_WhenDisabled_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.GetStringAsync("test:key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_WhenDisabled_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.GetAsync<TestObject>("test:key");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.SetStringAsync("test:key", "test value");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var testObj = new TestObject { Id = 1, Name = "Test" };
|
||||
|
||||
// Act
|
||||
var result = await service.SetAsync("test:key", testObj);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.DeleteAsync("test:key");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.ExistsAsync("test:key");
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.DeleteByPatternAsync("test:*");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var expiry = TimeSpan.FromHours(1);
|
||||
|
||||
// Act
|
||||
var result = await service.SetStringAsync("test:key", "value", expiry);
|
||||
|
||||
// Assert - Should return false when disabled, but not throw
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var testObj = new TestObject { Id = 1, Name = "Test" };
|
||||
var expiry = TimeSpan.FromDays(30);
|
||||
|
||||
// Act
|
||||
var result = await service.SetAsync("test:key", testObj, expiry);
|
||||
|
||||
// Assert - Should return false when disabled, but not throw
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsEnabled_ReflectsSettings()
|
||||
{
|
||||
// Arrange
|
||||
var disabledService = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
var enabledSettings = Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "localhost:6379"
|
||||
});
|
||||
var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
||||
|
||||
// Assert
|
||||
Assert.False(disabledService.IsEnabled);
|
||||
// enabledService.IsEnabled may be false if connection fails, which is expected
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_DeserializesComplexObjects()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.GetAsync<ComplexTestObject>("test:complex");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result); // Null when disabled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_SerializesComplexObjects()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var complexObj = new ComplexTestObject
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Test",
|
||||
Items = new System.Collections.Generic.List<string> { "Item1", "Item2" },
|
||||
Metadata = new System.Collections.Generic.Dictionary<string, string>
|
||||
{
|
||||
{ "Key1", "Value1" },
|
||||
{ "Key2", "Value2" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await service.SetAsync("test:complex", complexObj, TimeSpan.FromHours(1));
|
||||
|
||||
// Assert
|
||||
Assert.False(result); // False when disabled
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectionString_IsConfigurable()
|
||||
{
|
||||
// Arrange
|
||||
var customSettings = Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = false,
|
||||
ConnectionString = "redis-server:6380,password=secret,ssl=true"
|
||||
});
|
||||
|
||||
// Act
|
||||
var service = new RedisCacheService(customSettings, _mockLogger.Object);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
private class TestObject
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private class ComplexTestObject
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public System.Collections.Generic.List<string> Items { get; set; } = new();
|
||||
public System.Collections.Generic.Dictionary<string, string> Metadata { get; set; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class SpotifyApiClientTests
|
||||
{
|
||||
private readonly Mock<ILogger<SpotifyApiClient>> _mockLogger;
|
||||
private readonly IOptions<SpotifyApiSettings> _settings;
|
||||
|
||||
public SpotifyApiClientTests()
|
||||
{
|
||||
_mockLogger = new Mock<ILogger<SpotifyApiClient>>();
|
||||
_settings = Options.Create(new SpotifyApiSettings
|
||||
{
|
||||
Enabled = true,
|
||||
SessionCookie = "test_session_cookie_value",
|
||||
CacheDurationMinutes = 60,
|
||||
RateLimitDelayMs = 100,
|
||||
PreferIsrcMatching = true
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesWithSettings()
|
||||
{
|
||||
// Act
|
||||
var client = new SpotifyApiClient(_mockLogger.Object, _settings);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Settings_AreConfiguredCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var client = new SpotifyApiClient(_mockLogger.Object, _settings);
|
||||
|
||||
// Assert - Constructor should not throw
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionCookie_IsRequired_ForWebApiAccess()
|
||||
{
|
||||
// Arrange
|
||||
var settingsWithoutCookie = Options.Create(new SpotifyApiSettings
|
||||
{
|
||||
Enabled = true,
|
||||
SessionCookie = "" // Empty cookie
|
||||
});
|
||||
|
||||
// Act
|
||||
var client = new SpotifyApiClient(_mockLogger.Object, settingsWithoutCookie);
|
||||
|
||||
// Assert - Constructor should not throw, but GetWebAccessTokenAsync will return null
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RateLimitSettings_AreRespected()
|
||||
{
|
||||
// Arrange
|
||||
var customSettings = Options.Create(new SpotifyApiSettings
|
||||
{
|
||||
Enabled = true,
|
||||
SessionCookie = "test_cookie",
|
||||
RateLimitDelayMs = 500
|
||||
});
|
||||
|
||||
// Act
|
||||
var client = new SpotifyApiClient(_mockLogger.Object, customSettings);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class SpotifyMappingServiceTests
|
||||
{
|
||||
private readonly Mock<ILogger<RedisCacheService>> _mockCacheLogger;
|
||||
private readonly Mock<ILogger<SpotifyMappingService>> _mockLogger;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly SpotifyMappingService _service;
|
||||
|
||||
public SpotifyMappingServiceTests()
|
||||
{
|
||||
_mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
_mockLogger = new Mock<ILogger<SpotifyMappingService>>();
|
||||
|
||||
// Use disabled Redis for tests
|
||||
var redisSettings = Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = false,
|
||||
ConnectionString = "localhost:6379"
|
||||
});
|
||||
|
||||
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object);
|
||||
_service = new SpotifyMappingService(_cache, _mockLogger.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotifyTrackMapping_NeedsValidation_LocalMapping_WithinSevenDays()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test",
|
||||
TargetType = "local",
|
||||
LocalId = "abc123",
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = DateTime.UtcNow.AddDays(-3) // 3 days ago
|
||||
};
|
||||
|
||||
// Act
|
||||
var needsValidation = mapping.NeedsValidation(isPlaylistSync: false);
|
||||
|
||||
// Assert
|
||||
Assert.False(needsValidation); // Should not need validation yet
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotifyTrackMapping_NeedsValidation_LocalMapping_AfterSevenDays()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test",
|
||||
TargetType = "local",
|
||||
LocalId = "abc123",
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = DateTime.UtcNow.AddDays(-8) // 8 days ago
|
||||
};
|
||||
|
||||
// Act
|
||||
var needsValidation = mapping.NeedsValidation(isPlaylistSync: false);
|
||||
|
||||
// Assert
|
||||
Assert.True(needsValidation); // Should need validation
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotifyTrackMapping_NeedsValidation_ExternalMapping_OnPlaylistSync()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test",
|
||||
TargetType = "external",
|
||||
ExternalProvider = "SquidWTF",
|
||||
ExternalId = "789",
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = DateTime.UtcNow.AddMinutes(-5) // Just validated
|
||||
};
|
||||
|
||||
// Act
|
||||
var needsValidation = mapping.NeedsValidation(isPlaylistSync: true);
|
||||
|
||||
// Assert
|
||||
Assert.True(needsValidation); // Should validate on every sync
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotifyTrackMapping_NeedsValidation_ExternalMapping_NotOnPlaylistSync()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test",
|
||||
TargetType = "external",
|
||||
ExternalProvider = "SquidWTF",
|
||||
ExternalId = "789",
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = DateTime.UtcNow.AddMinutes(-5)
|
||||
};
|
||||
|
||||
// Act
|
||||
var needsValidation = mapping.NeedsValidation(isPlaylistSync: false);
|
||||
|
||||
// Assert
|
||||
Assert.False(needsValidation); // Should not validate if not playlist sync
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotifyTrackMapping_NeedsValidation_NeverValidated()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test",
|
||||
TargetType = "local",
|
||||
LocalId = "abc123",
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = null // Never validated
|
||||
};
|
||||
|
||||
// Act
|
||||
var needsValidation = mapping.NeedsValidation(isPlaylistSync: false);
|
||||
|
||||
// Assert
|
||||
Assert.True(needsValidation); // Should always validate if never validated
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveMappingAsync_RejectsInvalidLocalMapping()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "3n3Ppam7vgaVa1iaRUc9Lp",
|
||||
TargetType = "local",
|
||||
LocalId = null, // Invalid - no LocalId
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.SaveMappingAsync(mapping);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveMappingAsync_RejectsInvalidExternalMapping()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "3n3Ppam7vgaVa1iaRUc9Lp",
|
||||
TargetType = "external",
|
||||
ExternalProvider = "SquidWTF",
|
||||
ExternalId = null, // Invalid - no ExternalId
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.SaveMappingAsync(mapping);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveMappingAsync_RejectsEmptySpotifyId()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "", // Invalid - empty
|
||||
TargetType = "local",
|
||||
LocalId = "abc123",
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.SaveMappingAsync(mapping);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMappingAsync_ReturnsNullWhenNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var spotifyId = "nonexistent";
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMappingAsync(spotifyId);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result); // Redis is disabled, so nothing will be found
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using Xunit;
|
||||
using System;
|
||||
using allstarr.Models.Spotify;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Spotify mapping validation logic.
|
||||
/// Focuses on the NeedsValidation() method and validation rules.
|
||||
/// </summary>
|
||||
public class SpotifyMappingValidationTests
|
||||
{
|
||||
|
||||
[Fact]
|
||||
public void SpotifyTrackMapping_NeedsValidation_LocalMapping_WithinSevenDays()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test",
|
||||
TargetType = "local",
|
||||
LocalId = "abc123",
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = DateTime.UtcNow.AddDays(-3) // 3 days ago
|
||||
};
|
||||
|
||||
// Act
|
||||
var needsValidation = mapping.NeedsValidation(isPlaylistSync: false);
|
||||
|
||||
// Assert
|
||||
Assert.False(needsValidation); // Should not need validation yet
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotifyTrackMapping_NeedsValidation_LocalMapping_AfterSevenDays()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test",
|
||||
TargetType = "local",
|
||||
LocalId = "abc123",
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = DateTime.UtcNow.AddDays(-8) // 8 days ago
|
||||
};
|
||||
|
||||
// Act
|
||||
var needsValidation = mapping.NeedsValidation(isPlaylistSync: false);
|
||||
|
||||
// Assert
|
||||
Assert.True(needsValidation); // Should need validation
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotifyTrackMapping_NeedsValidation_ExternalMapping_OnPlaylistSync()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test",
|
||||
TargetType = "external",
|
||||
ExternalProvider = "SquidWTF",
|
||||
ExternalId = "789",
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = DateTime.UtcNow.AddMinutes(-5) // Just validated
|
||||
};
|
||||
|
||||
// Act
|
||||
var needsValidation = mapping.NeedsValidation(isPlaylistSync: true);
|
||||
|
||||
// Assert
|
||||
Assert.True(needsValidation); // Should validate on every sync
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotifyTrackMapping_NeedsValidation_ExternalMapping_NotOnPlaylistSync()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test",
|
||||
TargetType = "external",
|
||||
ExternalProvider = "SquidWTF",
|
||||
ExternalId = "789",
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = DateTime.UtcNow.AddMinutes(-5)
|
||||
};
|
||||
|
||||
// Act
|
||||
var needsValidation = mapping.NeedsValidation(isPlaylistSync: false);
|
||||
|
||||
// Assert
|
||||
Assert.False(needsValidation); // Should not validate if not playlist sync
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotifyTrackMapping_NeedsValidation_NeverValidated()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test",
|
||||
TargetType = "local",
|
||||
LocalId = "abc123",
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = null // Never validated
|
||||
};
|
||||
|
||||
// Act
|
||||
var needsValidation = mapping.NeedsValidation(isPlaylistSync: false);
|
||||
|
||||
// Assert
|
||||
Assert.True(needsValidation); // Should always validate if never validated
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotifyTrackMapping_NeedsValidation_LocalMapping_ExactlySevenDays()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test",
|
||||
TargetType = "local",
|
||||
LocalId = "abc123",
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = DateTime.UtcNow.AddDays(-7) // Exactly 7 days
|
||||
};
|
||||
|
||||
// Act
|
||||
var needsValidation = mapping.NeedsValidation(isPlaylistSync: false);
|
||||
|
||||
// Assert
|
||||
Assert.True(needsValidation); // Should validate at 7 days
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotifyTrackMapping_NeedsValidation_ManualMapping_FollowsSameRules()
|
||||
{
|
||||
// Arrange - Manual local mapping
|
||||
var manualLocal = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test1",
|
||||
TargetType = "local",
|
||||
LocalId = "abc123",
|
||||
Source = "manual",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = DateTime.UtcNow.AddDays(-8)
|
||||
};
|
||||
|
||||
// Arrange - Manual external mapping
|
||||
var manualExternal = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = "test2",
|
||||
TargetType = "external",
|
||||
ExternalProvider = "SquidWTF",
|
||||
ExternalId = "789",
|
||||
Source = "manual",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastValidatedAt = DateTime.UtcNow.AddMinutes(-5)
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
Assert.True(manualLocal.NeedsValidation(false)); // Manual local follows 7-day rule
|
||||
Assert.True(manualExternal.NeedsValidation(true)); // Manual external validates on sync
|
||||
Assert.False(manualExternal.NeedsValidation(false)); // But not outside sync
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Services.SquidWTF;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Models.Settings;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class SquidWTFMetadataServiceTests
|
||||
{
|
||||
private readonly Mock<ILogger<SquidWTFMetadataService>> _mockLogger;
|
||||
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
||||
private readonly IOptions<SubsonicSettings> _subsonicSettings;
|
||||
private readonly IOptions<SquidWTFSettings> _squidwtfSettings;
|
||||
private readonly Mock<RedisCacheService> _mockCache;
|
||||
private readonly List<string> _apiUrls;
|
||||
|
||||
public SquidWTFMetadataServiceTests()
|
||||
{
|
||||
_mockLogger = new Mock<ILogger<SquidWTFMetadataService>>();
|
||||
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
|
||||
|
||||
_subsonicSettings = Options.Create(new SubsonicSettings
|
||||
{
|
||||
ExplicitFilter = ExplicitFilter.All
|
||||
});
|
||||
|
||||
_squidwtfSettings = Options.Create(new SquidWTFSettings
|
||||
{
|
||||
Quality = "FLAC"
|
||||
});
|
||||
|
||||
// Create mock Redis cache
|
||||
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
||||
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
||||
|
||||
_apiUrls = new List<string>
|
||||
{
|
||||
"https://squid.wtf",
|
||||
"https://mirror1.squid.wtf",
|
||||
"https://mirror2.squid.wtf"
|
||||
};
|
||||
|
||||
var httpClient = new System.Net.Http.HttpClient();
|
||||
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesWithDependencies()
|
||||
{
|
||||
// Act
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_AcceptsOptionalGenreEnrichment()
|
||||
{
|
||||
// Arrange - GenreEnrichmentService is optional, just pass null
|
||||
|
||||
// Act
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls,
|
||||
null); // GenreEnrichmentService is optional
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchSongsAsync_AcceptsQueryAndLimit()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.SearchSongsAsync("Mr. Brightside", 20);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchAlbumsAsync_AcceptsQueryAndLimit()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.SearchAlbumsAsync("Hot Fuss", 20);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchArtistsAsync_AcceptsQueryAndLimit()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.SearchArtistsAsync("The Killers", 20);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchPlaylistsAsync_AcceptsQueryAndLimit()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.SearchPlaylistsAsync("Rock Classics", 20);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSongAsync_RequiresProviderAndId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.GetSongAsync("squidwtf", "123456");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAlbumAsync_RequiresProviderAndId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.GetAlbumAsync("squidwtf", "789012");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetArtistAsync_RequiresProviderAndId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.GetArtistAsync("squidwtf", "345678");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetArtistAlbumsAsync_RequiresProviderAndId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.GetArtistAlbumsAsync("squidwtf", "345678");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPlaylistAsync_RequiresProviderAndId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.GetPlaylistAsync("squidwtf", "playlist123");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPlaylistTracksAsync_RequiresProviderAndId()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.GetPlaylistTracksAsync("squidwtf", "playlist123");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SearchAllAsync_CombinesAllSearchTypes()
|
||||
{
|
||||
// Arrange
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Act
|
||||
var result = service.SearchAllAsync("The Killers", 20, 20, 20);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitFilter_RespectsSettings()
|
||||
{
|
||||
// Arrange - Test with CleanOnly filter
|
||||
var cleanOnlySettings = Options.Create(new SubsonicSettings
|
||||
{
|
||||
ExplicitFilter = ExplicitFilter.CleanOnly
|
||||
});
|
||||
|
||||
// Act
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
cleanOnlySettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
_apiUrls);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleApiUrls_EnablesRoundRobinFallback()
|
||||
{
|
||||
// Arrange
|
||||
var multipleUrls = new List<string>
|
||||
{
|
||||
"https://primary.squid.wtf",
|
||||
"https://backup1.squid.wtf",
|
||||
"https://backup2.squid.wtf",
|
||||
"https://backup3.squid.wtf"
|
||||
};
|
||||
|
||||
// Act
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
multipleUrls);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using allstarr.Middleware;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class WebSocketProxyMiddlewareTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildMaskedQuery_RedactsSensitiveParams()
|
||||
{
|
||||
var qs = "?api_key=secret&deviceId=abc&token=othertoken";
|
||||
var masked = allstarr.Middleware.WebSocketProxyMiddleware.BuildMaskedQuery(qs);
|
||||
|
||||
Assert.Contains("api_key=<redacted>", masked);
|
||||
Assert.Contains("deviceId=abc", masked);
|
||||
Assert.Contains("token=<redacted>", masked);
|
||||
Assert.DoesNotContain("secret", masked);
|
||||
Assert.DoesNotContain("othertoken", masked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMaskedQuery_EmptyOrNull_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal(string.Empty, allstarr.Middleware.WebSocketProxyMiddleware.BuildMaskedQuery(null));
|
||||
Assert.Equal(string.Empty, allstarr.Middleware.WebSocketProxyMiddleware.BuildMaskedQuery(string.Empty));
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,18 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services;
|
||||
using allstarr.Filters;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Runtime;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin API controller for the web dashboard.
|
||||
/// Provides endpoints for viewing status, playlists, and modifying configuration.
|
||||
/// Only accessible on internal admin port (5275) - not exposed through reverse proxy.
|
||||
/// Legacy AdminController - All functionality has been split into specialized controllers:
|
||||
/// - ConfigController: Configuration management
|
||||
/// - DiagnosticsController: System diagnostics and debugging
|
||||
/// - DownloadsController: Download management
|
||||
/// - PlaylistController: Playlist operations
|
||||
/// - JellyfinAdminController: Jellyfin-specific operations
|
||||
/// - SpotifyAdminController: Spotify-specific operations
|
||||
/// - LyricsController: Lyrics management
|
||||
/// - MappingController: Track mapping management
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
@@ -24,3474 +20,15 @@ namespace allstarr.Controllers;
|
||||
public class AdminController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<AdminController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly JellyfinSettings _jellyfinSettings;
|
||||
private readonly SubsonicSettings _subsonicSettings;
|
||||
private readonly DeezerSettings _deezerSettings;
|
||||
private readonly QobuzSettings _qobuzSettings;
|
||||
private readonly SquidWTFSettings _squidWtfSettings;
|
||||
private readonly MusicBrainzSettings _musicBrainzSettings;
|
||||
private readonly SpotifyApiClient _spotifyClient;
|
||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||
private readonly SpotifyTrackMatchingService? _matchingService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly HttpClient _jellyfinHttpClient;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly string _envFilePath;
|
||||
private readonly List<string> _squidWtfApiUrls;
|
||||
private static int _urlIndex = 0;
|
||||
private static readonly object _urlIndexLock = new();
|
||||
private const string CacheDirectory = "/app/cache/spotify";
|
||||
|
||||
public AdminController(
|
||||
ILogger<AdminController> logger,
|
||||
IConfiguration configuration,
|
||||
IWebHostEnvironment environment,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
IOptions<SubsonicSettings> subsonicSettings,
|
||||
IOptions<DeezerSettings> deezerSettings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||
IOptions<MusicBrainzSettings> musicBrainzSettings,
|
||||
SpotifyApiClient spotifyClient,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
RedisCacheService cache,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IServiceProvider serviceProvider,
|
||||
SpotifyTrackMatchingService? matchingService = null)
|
||||
public AdminController(ILogger<AdminController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_environment = environment;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_subsonicSettings = subsonicSettings.Value;
|
||||
_deezerSettings = deezerSettings.Value;
|
||||
_qobuzSettings = qobuzSettings.Value;
|
||||
_squidWtfSettings = squidWtfSettings.Value;
|
||||
_musicBrainzSettings = musicBrainzSettings.Value;
|
||||
_spotifyClient = spotifyClient;
|
||||
_playlistFetcher = playlistFetcher;
|
||||
_matchingService = matchingService;
|
||||
_cache = cache;
|
||||
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
||||
_serviceProvider = serviceProvider;
|
||||
|
||||
// Decode SquidWTF base URLs
|
||||
_squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||
|
||||
// .env file path is always /app/.env in Docker (mounted from host)
|
||||
// In development, it's in the parent directory of ContentRootPath
|
||||
_envFilePath = _environment.IsDevelopment()
|
||||
? Path.Combine(_environment.ContentRootPath, "..", ".env")
|
||||
: "/app/.env";
|
||||
}
|
||||
|
||||
private static List<string> DecodeSquidWtfUrls()
|
||||
{
|
||||
var encodedUrls = new[]
|
||||
{
|
||||
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
|
||||
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum
|
||||
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
|
||||
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2
|
||||
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1
|
||||
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
|
||||
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
|
||||
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
|
||||
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
|
||||
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus
|
||||
};
|
||||
|
||||
return encodedUrls
|
||||
.Select(encoded => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to safely check if a dynamic cache result has a value
|
||||
/// Handles the case where JsonElement cannot be compared to null directly
|
||||
/// </summary>
|
||||
private static bool HasValue(object? obj)
|
||||
{
|
||||
if (obj == null) return false;
|
||||
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current system status and configuration
|
||||
/// </summary>
|
||||
[HttpGet("status")]
|
||||
public IActionResult GetStatus()
|
||||
{
|
||||
// Determine Spotify auth status based on configuration only
|
||||
// DO NOT call Spotify API here - this endpoint is polled frequently
|
||||
var spotifyAuthStatus = "not_configured";
|
||||
string? spotifyUser = null;
|
||||
|
||||
if (_spotifyApiSettings.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
// If cookie is set, assume it's working until proven otherwise
|
||||
// Actual validation happens when playlists are fetched
|
||||
spotifyAuthStatus = "configured";
|
||||
spotifyUser = "(cookie set)";
|
||||
}
|
||||
else if (_spotifyApiSettings.Enabled)
|
||||
{
|
||||
spotifyAuthStatus = "missing_cookie";
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
version = "1.0.0",
|
||||
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
||||
jellyfinUrl = _jellyfinSettings.Url,
|
||||
spotify = new
|
||||
{
|
||||
apiEnabled = _spotifyApiSettings.Enabled,
|
||||
authStatus = spotifyAuthStatus,
|
||||
user = spotifyUser,
|
||||
hasCookie = !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie),
|
||||
cookieSetDate = _spotifyApiSettings.SessionCookieSetDate,
|
||||
cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes,
|
||||
preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching
|
||||
},
|
||||
spotifyImport = new
|
||||
{
|
||||
enabled = _spotifyImportSettings.Enabled,
|
||||
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
|
||||
playlistCount = _spotifyImportSettings.Playlists.Count
|
||||
},
|
||||
deezer = new
|
||||
{
|
||||
hasArl = !string.IsNullOrEmpty(_deezerSettings.Arl),
|
||||
quality = _deezerSettings.Quality ?? "FLAC"
|
||||
},
|
||||
qobuz = new
|
||||
{
|
||||
hasToken = !string.IsNullOrEmpty(_qobuzSettings.UserAuthToken),
|
||||
quality = _qobuzSettings.Quality ?? "FLAC"
|
||||
},
|
||||
squidWtf = new
|
||||
{
|
||||
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a random SquidWTF base URL for searching (round-robin)
|
||||
/// </summary>
|
||||
[HttpGet("squidwtf-base-url")]
|
||||
public IActionResult GetSquidWtfBaseUrl()
|
||||
{
|
||||
if (_squidWtfApiUrls.Count == 0)
|
||||
{
|
||||
return NotFound(new { error = "No SquidWTF base URLs configured" });
|
||||
}
|
||||
|
||||
string baseUrl;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
baseUrl = _squidWtfApiUrls[_urlIndex];
|
||||
_urlIndex = (_urlIndex + 1) % _squidWtfApiUrls.Count;
|
||||
}
|
||||
|
||||
return Ok(new { baseUrl });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get list of configured playlists with their current data
|
||||
/// </summary>
|
||||
[HttpGet("playlists")]
|
||||
public async Task<IActionResult> GetPlaylists([FromQuery] bool refresh = false)
|
||||
{
|
||||
var playlistCacheFile = "/app/cache/admin_playlists_summary.json";
|
||||
|
||||
// Check file cache first (5 minute TTL) unless refresh is requested
|
||||
if (!refresh && System.IO.File.Exists(playlistCacheFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(playlistCacheFile);
|
||||
var age = DateTime.UtcNow - fileInfo.LastWriteTimeUtc;
|
||||
|
||||
if (age.TotalMinutes < 5)
|
||||
{
|
||||
var cachedJson = await System.IO.File.ReadAllTextAsync(playlistCacheFile);
|
||||
var cachedData = JsonSerializer.Deserialize<Dictionary<string, object>>(cachedJson);
|
||||
_logger.LogDebug("📦 Returning cached playlist summary (age: {Age:F1}m)", age.TotalMinutes);
|
||||
return Ok(cachedData);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("🔄 Cache expired (age: {Age:F1}m), refreshing...", age.TotalMinutes);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read cached playlist summary");
|
||||
}
|
||||
}
|
||||
else if (refresh)
|
||||
{
|
||||
_logger.LogInformation("🔄 Force refresh requested for playlist summary");
|
||||
}
|
||||
|
||||
var playlists = new List<object>();
|
||||
|
||||
// Read playlists directly from .env file to get the latest configuration
|
||||
// (IOptions is cached and doesn't reload after .env changes)
|
||||
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
|
||||
foreach (var config in configuredPlaylists)
|
||||
{
|
||||
var playlistInfo = new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = config.Name,
|
||||
["id"] = config.Id,
|
||||
["jellyfinId"] = config.JellyfinId,
|
||||
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
||||
["trackCount"] = 0,
|
||||
["localTracks"] = 0,
|
||||
["externalTracks"] = 0,
|
||||
["lastFetched"] = null as DateTime?,
|
||||
["cacheAge"] = null as string
|
||||
};
|
||||
|
||||
// Get Spotify playlist track count from cache
|
||||
var cacheFilePath = Path.Combine(CacheDirectory, $"{SanitizeFileName(config.Name)}_spotify.json");
|
||||
int spotifyTrackCount = 0;
|
||||
|
||||
if (System.IO.File.Exists(cacheFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(cacheFilePath);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("tracks", out var tracks))
|
||||
{
|
||||
spotifyTrackCount = tracks.GetArrayLength();
|
||||
playlistInfo["trackCount"] = spotifyTrackCount;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("fetchedAt", out var fetchedAt))
|
||||
{
|
||||
var fetchedTime = fetchedAt.GetDateTime();
|
||||
playlistInfo["lastFetched"] = fetchedTime;
|
||||
var age = DateTime.UtcNow - fetchedTime;
|
||||
playlistInfo["cacheAge"] = age.TotalHours < 1
|
||||
? $"{age.TotalMinutes:F0}m"
|
||||
: $"{age.TotalHours:F1}h";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read cache for playlist {Name}", config.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current Jellyfin playlist track count
|
||||
if (!string.IsNullOrEmpty(config.JellyfinId))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Jellyfin requires UserId parameter to fetch playlist items
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
// If no user configured, try to get the first user
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
var usersResponse = await _jellyfinHttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users")
|
||||
{
|
||||
Headers = { { "X-Emby-Authorization", GetJellyfinAuthHeader() } }
|
||||
});
|
||||
|
||||
if (usersResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var usersJson = await usersResponse.Content.ReadAsStringAsync();
|
||||
using var usersDoc = JsonDocument.Parse(usersJson);
|
||||
if (usersDoc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
userId = usersDoc.RootElement[0].GetProperty("Id").GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("No user ID available to fetch playlist items for {Name}", config.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Playlists/{config.JellyfinId}/Items?UserId={userId}&Fields=Path";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
_logger.LogDebug("Fetching Jellyfin playlist items for {Name} from {Url}", config.Name, url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var jellyfinJson = await response.Content.ReadAsStringAsync();
|
||||
using var jellyfinDoc = JsonDocument.Parse(jellyfinJson);
|
||||
|
||||
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
// Get Spotify tracks to match against
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
|
||||
// Try to use the pre-built playlist cache first (includes manual mappings!)
|
||||
var playlistItemsCacheKey = $"spotify:playlist:items:{config.Name}";
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
{
|
||||
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||
}
|
||||
catch (Exception cacheEx)
|
||||
{
|
||||
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}",
|
||||
config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||
|
||||
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||
{
|
||||
// Use the pre-built cache which respects manual mappings
|
||||
var localCount = 0;
|
||||
var externalCount = 0;
|
||||
|
||||
foreach (var item in cachedPlaylistItems)
|
||||
{
|
||||
// Check if it's external by looking for external provider in ProviderIds
|
||||
// External providers: SquidWTF, Deezer, Qobuz, Tidal
|
||||
var isExternal = false;
|
||||
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
// Handle both Dictionary<string, string> and JsonElement
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (providerIds != null)
|
||||
{
|
||||
// Check for external provider keys (not MusicBrainz, ISRC, Spotify, etc)
|
||||
isExternal = providerIds.Keys.Any(k =>
|
||||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("Deezer", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("Qobuz", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("Tidal", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
externalCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
localCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var externalMissingCount = spotifyTracks.Count - cachedPlaylistItems.Count;
|
||||
if (externalMissingCount < 0) externalMissingCount = 0;
|
||||
|
||||
playlistInfo["localTracks"] = localCount;
|
||||
playlistInfo["externalMatched"] = externalCount;
|
||||
playlistInfo["externalMissing"] = externalMissingCount;
|
||||
playlistInfo["externalTotal"] = externalCount + externalMissingCount;
|
||||
playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count;
|
||||
playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served
|
||||
|
||||
_logger.LogInformation("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||
config.Name, spotifyTracks.Count, localCount, externalCount, externalMissingCount, localCount + externalCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: Build list of local tracks from Jellyfin (match by name only)
|
||||
var localTracks = new List<(string Title, string Artist)>();
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var title = 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() ?? "";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
localTracks.Add((title, artist));
|
||||
}
|
||||
}
|
||||
|
||||
// Get matched external tracks cache once
|
||||
var matchedTracksKey = $"spotify:matched:ordered:{config.Name}";
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
var matchedSpotifyIds = new HashSet<string>(
|
||||
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
);
|
||||
|
||||
var localCount = 0;
|
||||
var externalMatchedCount = 0;
|
||||
var externalMissingCount = 0;
|
||||
|
||||
// Match each Spotify track to determine if it's local, external, or missing
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
var isLocal = false;
|
||||
var hasExternalMapping = false;
|
||||
|
||||
// FIRST: Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
// Manual Jellyfin mapping exists - this track is definitely local
|
||||
isLocal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
// External manual mapping exists
|
||||
hasExternalMapping = true;
|
||||
}
|
||||
else if (localTracks.Count > 0)
|
||||
{
|
||||
// SECOND: No manual mapping, try fuzzy matching with local tracks
|
||||
var bestMatch = localTracks
|
||||
.Select(local => new
|
||||
{
|
||||
Local = local,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Local,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Use 70% threshold (same as playback matching)
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
||||
{
|
||||
isLocal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocal)
|
||||
{
|
||||
localCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if external track is matched (either manual mapping or auto-matched)
|
||||
if (hasExternalMapping || matchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
externalMatchedCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
externalMissingCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playlistInfo["localTracks"] = localCount;
|
||||
playlistInfo["externalMatched"] = externalMatchedCount;
|
||||
playlistInfo["externalMissing"] = externalMissingCount;
|
||||
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
|
||||
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
|
||||
playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served
|
||||
|
||||
_logger.LogDebug("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||
config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount, localCount + externalMatchedCount);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No Items property in Jellyfin response for {Name}", config.Name);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to get Jellyfin playlist {Name}: {StatusCode}",
|
||||
config.Name, response.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Playlist {Name} has no JellyfinId configured", config.Name);
|
||||
}
|
||||
|
||||
playlists.Add(playlistInfo);
|
||||
}
|
||||
|
||||
// Save to file cache
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
var cacheFile = Path.Combine(cacheDir, "admin_playlists_summary.json");
|
||||
|
||||
var response = new { playlists };
|
||||
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false });
|
||||
await System.IO.File.WriteAllTextAsync(cacheFile, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved playlist summary to cache");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save playlist summary cache");
|
||||
}
|
||||
|
||||
return Ok(new { playlists });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get tracks for a specific playlist with local/external status
|
||||
/// </summary>
|
||||
[HttpGet("playlists/{name}/tracks")]
|
||||
public async Task<IActionResult> GetPlaylistTracks(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
// Get Spotify tracks
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
|
||||
|
||||
var tracksWithStatus = new List<object>();
|
||||
|
||||
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
|
||||
// This cache includes all matched tracks with proper provider IDs
|
||||
var playlistItemsCacheKey = $"spotify:playlist:items:{decodedName}";
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
{
|
||||
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||
}
|
||||
catch (Exception cacheEx)
|
||||
{
|
||||
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
_logger.LogInformation("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}",
|
||||
decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||
|
||||
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||
{
|
||||
// Build a map of Spotify ID -> cached item for quick lookup
|
||||
var spotifyIdToItem = new Dictionary<string, Dictionary<string, object?>>();
|
||||
|
||||
foreach (var item in cachedPlaylistItems)
|
||||
{
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId))
|
||||
{
|
||||
spotifyIdToItem[spotifyId] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match each Spotify track to its cached item
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
bool? isLocal = null;
|
||||
string? externalProvider = null;
|
||||
bool isManualMapping = false;
|
||||
string? manualMappingType = null;
|
||||
string? manualMappingId = null;
|
||||
|
||||
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out var cachedItem))
|
||||
{
|
||||
// Track is in the cache - determine if it's local or external
|
||||
if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (providerIds != null)
|
||||
{
|
||||
_logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys));
|
||||
|
||||
// Check for external provider keys (case-insensitive)
|
||||
// External providers: squidwtf, deezer, qobuz, tidal (lowercase)
|
||||
var providerKey = providerIds.Keys.FirstOrDefault(k =>
|
||||
k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (providerKey != null)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF";
|
||||
_logger.LogDebug("✓ Track {Title} identified as SquidWTF", track.Title);
|
||||
}
|
||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase))) != null)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "Deezer";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Deezer", track.Title);
|
||||
}
|
||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase))) != null)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "Qobuz";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Qobuz", track.Title);
|
||||
}
|
||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase))) != null)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "Tidal";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Tidal", track.Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No external provider key found - it's a local track
|
||||
// Local tracks have MusicBrainz, ISRC, Spotify IDs but no external provider
|
||||
isLocal = true;
|
||||
_logger.LogDebug("✓ Track {Title} identified as LOCAL (has ProviderIds but no external provider)", track.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Track {Title} has ProviderIds object but it's null after parsing", track.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Track {Title} in cache but has NO ProviderIds - treating as missing", track.Title);
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
|
||||
// Check if this is a manual mapping
|
||||
var manualJellyfinKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualJellyfinKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
isManualMapping = true;
|
||||
manualMappingType = "jellyfin";
|
||||
manualMappingId = manualJellyfinId;
|
||||
}
|
||||
else
|
||||
{
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
if (extRoot.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
isManualMapping = true;
|
||||
manualMappingType = "external";
|
||||
manualMappingId = idEl.GetString();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Track not in cache - it's missing
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
|
||||
// Check lyrics status
|
||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
|
||||
|
||||
tracksWithStatus.Add(new
|
||||
{
|
||||
position = track.Position,
|
||||
title = track.Title,
|
||||
artists = track.Artists,
|
||||
album = track.Album,
|
||||
isrc = track.Isrc,
|
||||
spotifyId = track.SpotifyId,
|
||||
durationMs = track.DurationMs,
|
||||
albumArtUrl = track.AlbumArtUrl,
|
||||
isLocal = isLocal,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null,
|
||||
isManualMapping = isManualMapping,
|
||||
manualMappingType = manualMappingType,
|
||||
manualMappingId = manualMappingId,
|
||||
hasLyrics = hasLyrics
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
name = decodedName,
|
||||
trackCount = spotifyTracks.Count,
|
||||
tracks = tracksWithStatus
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: Cache not available, use matched tracks cache
|
||||
_logger.LogWarning("Playlist cache not available for {Playlist}, using fallback", decodedName);
|
||||
|
||||
var fallbackMatchedTracksKey = $"spotify:matched:ordered:{decodedName}";
|
||||
var fallbackMatchedTracks = await _cache.GetAsync<List<MatchedTrack>>(fallbackMatchedTracksKey);
|
||||
var fallbackMatchedSpotifyIds = new HashSet<string>(
|
||||
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
);
|
||||
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
bool? isLocal = null;
|
||||
string? externalProvider = null;
|
||||
|
||||
// Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
isLocal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
string? provider = null;
|
||||
|
||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||
{
|
||||
provider = providerEl.GetString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(provider))
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = provider;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||
}
|
||||
}
|
||||
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF";
|
||||
}
|
||||
else
|
||||
{
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
tracksWithStatus.Add(new
|
||||
{
|
||||
position = track.Position,
|
||||
title = track.Title,
|
||||
artists = track.Artists,
|
||||
album = track.Album,
|
||||
isrc = track.Isrc,
|
||||
spotifyId = track.SpotifyId,
|
||||
durationMs = track.DurationMs,
|
||||
albumArtUrl = track.AlbumArtUrl,
|
||||
isLocal = isLocal,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
name = decodedName,
|
||||
trackCount = spotifyTracks.Count,
|
||||
tracks = tracksWithStatus
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger a manual refresh of all playlists
|
||||
/// </summary>
|
||||
[HttpPost("playlists/refresh")]
|
||||
public async Task<IActionResult> RefreshPlaylists()
|
||||
{
|
||||
_logger.LogInformation("Manual playlist refresh triggered from admin UI");
|
||||
await _playlistFetcher.TriggerFetchAsync();
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
InvalidatePlaylistSummaryCache();
|
||||
|
||||
return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger track matching for a specific playlist
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/match")]
|
||||
public async Task<IActionResult> MatchPlaylistTracks(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Name}", decodedName);
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "Track matching service is not available" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
InvalidatePlaylistSummaryCache();
|
||||
|
||||
return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to trigger track matching for {Name}", decodedName);
|
||||
return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear cache and rebuild for a specific playlist
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/clear-cache")]
|
||||
public async Task<IActionResult> ClearPlaylistCache(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Clear cache & rebuild triggered for playlist: {Name}", decodedName);
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "Track matching service is not available" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Clear all cache keys for this playlist
|
||||
var cacheKeys = new[]
|
||||
{
|
||||
$"spotify:playlist:items:{decodedName}", // Pre-built items cache
|
||||
$"spotify:matched:ordered:{decodedName}", // Ordered matched tracks
|
||||
$"spotify:matched:{decodedName}", // Legacy matched tracks
|
||||
$"spotify:missing:{decodedName}" // Missing tracks
|
||||
};
|
||||
|
||||
foreach (var key in cacheKeys)
|
||||
{
|
||||
await _cache.DeleteAsync(key);
|
||||
_logger.LogDebug("Cleared cache key: {Key}", key);
|
||||
}
|
||||
|
||||
// Delete file caches
|
||||
var safeName = string.Join("_", decodedName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filesToDelete = new[]
|
||||
{
|
||||
Path.Combine(CacheDirectory, $"{safeName}_items.json"),
|
||||
Path.Combine(CacheDirectory, $"{safeName}_matched.json")
|
||||
};
|
||||
|
||||
foreach (var file in filesToDelete)
|
||||
{
|
||||
if (System.IO.File.Exists(file))
|
||||
{
|
||||
System.IO.File.Delete(file);
|
||||
_logger.LogDebug("Deleted cache file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ Cleared all caches for playlist: {Name}", decodedName);
|
||||
|
||||
// Trigger rebuild
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
InvalidatePlaylistSummaryCache();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = $"Cache cleared and rebuild triggered for {decodedName}",
|
||||
timestamp = DateTime.UtcNow,
|
||||
clearedKeys = cacheKeys.Length,
|
||||
clearedFiles = filesToDelete.Count(System.IO.File.Exists)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to clear cache for {Name}", decodedName);
|
||||
return StatusCode(500, new { error = "Failed to clear cache", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search Jellyfin library for tracks (for manual mapping)
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/search")]
|
||||
public async Task<IActionResult> SearchJellyfinTracks([FromQuery] string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return BadRequest(new { error = "Query is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
// Build URL with UserId if available
|
||||
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
url += $"&UserId={userId}";
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
_logger.LogDebug("Searching Jellyfin: {Url}", url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var tracks = new List<object>();
|
||||
if (doc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
// Verify it's actually an Audio item
|
||||
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
||||
if (type != "Audio")
|
||||
{
|
||||
_logger.LogDebug("Skipping non-audio item: {Type}", type);
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.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() ?? "";
|
||||
}
|
||||
|
||||
tracks.Add(new { id, title, artist, album });
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { tracks });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to search Jellyfin tracks");
|
||||
return StatusCode(500, new { error = "Search failed" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get track details by Jellyfin ID (for URL-based mapping)
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/track/{id}")]
|
||||
public async Task<IActionResult> GetJellyfinTrack(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return BadRequest(new { error = "Track ID is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
url += $"?UserId={userId}";
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
_logger.LogDebug("Fetching Jellyfin track {Id} from {Url}", id, url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}",
|
||||
id, response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var item = doc.RootElement;
|
||||
|
||||
// Verify it's an Audio item
|
||||
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
||||
if (type != "Audio")
|
||||
{
|
||||
_logger.LogWarning("Item {Id} is not an Audio track, it's a {Type}", id, type);
|
||||
return BadRequest(new { error = $"Item is not an audio track (it's a {type})" });
|
||||
}
|
||||
|
||||
var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.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() ?? "";
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found Jellyfin track: {Title} by {Artist}", title, artist);
|
||||
|
||||
return Ok(new { id = trackId, title, artist, album });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get Jellyfin track {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to get track details" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save manual track mapping (local Jellyfin or external provider)
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/map")]
|
||||
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SpotifyId))
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyId is required" });
|
||||
}
|
||||
|
||||
// Validate that either Jellyfin mapping or external mapping is provided
|
||||
var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId);
|
||||
var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId);
|
||||
|
||||
if (!hasJellyfinMapping && !hasExternalMapping)
|
||||
{
|
||||
return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" });
|
||||
}
|
||||
|
||||
if (hasJellyfinMapping && hasExternalMapping)
|
||||
{
|
||||
return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string? normalizedProvider = null;
|
||||
|
||||
if (hasJellyfinMapping)
|
||||
{
|
||||
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||
await _cache.SetAsync(mappingKey, request.JellyfinId!);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null);
|
||||
|
||||
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
||||
decodedName, request.SpotifyId, request.JellyfinId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
|
||||
await _cache.SetAsync(externalMappingKey, externalMapping);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
|
||||
|
||||
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
||||
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
||||
}
|
||||
|
||||
// Clear all related caches to force rebuild
|
||||
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||
var orderedCacheKey = $"spotify:matched:ordered:{decodedName}";
|
||||
var playlistItemsKey = $"spotify:playlist:items:{decodedName}";
|
||||
|
||||
await _cache.DeleteAsync(matchedCacheKey);
|
||||
await _cache.DeleteAsync(orderedCacheKey);
|
||||
await _cache.DeleteAsync(playlistItemsKey);
|
||||
|
||||
// Also delete file caches to force rebuild
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
var safeName = string.Join("_", decodedName.Split(Path.GetInvalidFileNameChars()));
|
||||
var matchedFile = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||
var itemsFile = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
if (System.IO.File.Exists(matchedFile))
|
||||
{
|
||||
System.IO.File.Delete(matchedFile);
|
||||
_logger.LogDebug("Deleted matched tracks file cache for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
if (System.IO.File.Exists(itemsFile))
|
||||
{
|
||||
System.IO.File.Delete(itemsFile);
|
||||
_logger.LogDebug("Deleted playlist items file cache for {Playlist}", decodedName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete file caches for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
||||
|
||||
// Fetch external provider track details to return to the UI (only for external mappings)
|
||||
string? trackTitle = null;
|
||||
string? trackArtist = null;
|
||||
string? trackAlbum = null;
|
||||
|
||||
if (hasExternalMapping && normalizedProvider != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
||||
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
||||
|
||||
if (externalSong != null)
|
||||
{
|
||||
trackTitle = externalSong.Title;
|
||||
trackArtist = externalSong.Artist;
|
||||
trackAlbum = externalSong.Album;
|
||||
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
|
||||
normalizedProvider, request.ExternalId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger immediate playlist rebuild with the new mapping
|
||||
if (_matchingService != null)
|
||||
{
|
||||
_logger.LogInformation("Triggering immediate playlist rebuild for {Playlist} with new manual mapping", decodedName);
|
||||
|
||||
// Run rebuild in background with timeout to avoid blocking the response
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); // 2 minute timeout
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
_logger.LogInformation("✓ Playlist {Playlist} rebuilt successfully with manual mapping", decodedName);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Playlist rebuild for {Playlist} timed out after 2 minutes", decodedName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to rebuild playlist {Playlist} after manual mapping", decodedName);
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Matching service not available - playlist will rebuild on next scheduled run");
|
||||
}
|
||||
|
||||
// Return success with track details if available
|
||||
var mappedTrack = new
|
||||
{
|
||||
id = request.ExternalId,
|
||||
title = trackTitle ?? "Unknown",
|
||||
artist = trackArtist ?? "Unknown",
|
||||
album = trackAlbum ?? "Unknown",
|
||||
isLocal = false,
|
||||
externalProvider = request.ExternalProvider!.ToLowerInvariant()
|
||||
};
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Mapping saved and playlist rebuild triggered",
|
||||
track = mappedTrack,
|
||||
rebuildTriggered = _matchingService != null
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save manual mapping");
|
||||
return StatusCode(500, new { error = "Failed to save mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger track matching for all playlists
|
||||
/// </summary>
|
||||
[HttpPost("playlists/match-all")]
|
||||
public async Task<IActionResult> MatchAllPlaylistTracks()
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for all playlists");
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "Track matching service is not available" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _matchingService.TriggerMatchingAsync();
|
||||
return Ok(new { message = "Track matching triggered for all playlists", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to trigger track matching for all playlists");
|
||||
return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current configuration (safe values only)
|
||||
/// </summary>
|
||||
[HttpGet("config")]
|
||||
public IActionResult GetConfig()
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
spotifyApi = new
|
||||
{
|
||||
enabled = _spotifyApiSettings.Enabled,
|
||||
sessionCookie = MaskValue(_spotifyApiSettings.SessionCookie, showLast: 8),
|
||||
sessionCookieSetDate = _spotifyApiSettings.SessionCookieSetDate,
|
||||
cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes,
|
||||
rateLimitDelayMs = _spotifyApiSettings.RateLimitDelayMs,
|
||||
preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching
|
||||
},
|
||||
spotifyImport = new
|
||||
{
|
||||
enabled = _spotifyImportSettings.Enabled,
|
||||
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
|
||||
playlists = _spotifyImportSettings.Playlists.Select(p => new
|
||||
{
|
||||
name = p.Name,
|
||||
id = p.Id,
|
||||
localTracksPosition = p.LocalTracksPosition.ToString()
|
||||
})
|
||||
},
|
||||
jellyfin = new
|
||||
{
|
||||
url = _jellyfinSettings.Url,
|
||||
apiKey = MaskValue(_jellyfinSettings.ApiKey),
|
||||
userId = _jellyfinSettings.UserId ?? "(not set)",
|
||||
libraryId = _jellyfinSettings.LibraryId
|
||||
},
|
||||
library = new
|
||||
{
|
||||
downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache")
|
||||
: Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "permanent"),
|
||||
keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"),
|
||||
storageMode = _subsonicSettings.StorageMode.ToString(),
|
||||
cacheDurationHours = _subsonicSettings.CacheDurationHours,
|
||||
downloadMode = _subsonicSettings.DownloadMode.ToString()
|
||||
},
|
||||
deezer = new
|
||||
{
|
||||
arl = MaskValue(_deezerSettings.Arl, showLast: 8),
|
||||
arlFallback = MaskValue(_deezerSettings.ArlFallback, showLast: 8),
|
||||
quality = _deezerSettings.Quality ?? "FLAC"
|
||||
},
|
||||
qobuz = new
|
||||
{
|
||||
userAuthToken = MaskValue(_qobuzSettings.UserAuthToken, showLast: 8),
|
||||
userId = _qobuzSettings.UserId,
|
||||
quality = _qobuzSettings.Quality ?? "FLAC"
|
||||
},
|
||||
squidWtf = new
|
||||
{
|
||||
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
||||
},
|
||||
musicBrainz = new
|
||||
{
|
||||
enabled = _musicBrainzSettings.Enabled,
|
||||
username = _musicBrainzSettings.Username ?? "(not set)",
|
||||
password = MaskValue(_musicBrainzSettings.Password),
|
||||
baseUrl = _musicBrainzSettings.BaseUrl,
|
||||
rateLimitMs = _musicBrainzSettings.RateLimitMs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update configuration by modifying .env file
|
||||
/// </summary>
|
||||
[HttpPost("config")]
|
||||
public async Task<IActionResult> UpdateConfig([FromBody] ConfigUpdateRequest request)
|
||||
{
|
||||
if (request == null || request.Updates == null || request.Updates.Count == 0)
|
||||
{
|
||||
return BadRequest(new { error = "No updates provided" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Config update requested: {Count} changes", request.Updates.Count);
|
||||
|
||||
try
|
||||
{
|
||||
// Check if .env file exists
|
||||
if (!System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
_logger.LogWarning(".env file not found at {Path}, creating new file", _envFilePath);
|
||||
}
|
||||
|
||||
// Read current .env file or create new one
|
||||
var envContent = new Dictionary<string, string>();
|
||||
|
||||
if (System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
var lines = await System.IO.File.ReadAllLinesAsync(_envFilePath);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
|
||||
continue;
|
||||
|
||||
var eqIndex = line.IndexOf('=');
|
||||
if (eqIndex > 0)
|
||||
{
|
||||
var key = line[..eqIndex].Trim();
|
||||
var value = line[(eqIndex + 1)..].Trim();
|
||||
envContent[key] = value;
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("Loaded {Count} existing env vars from {Path}", envContent.Count, _envFilePath);
|
||||
}
|
||||
|
||||
// Apply updates with validation
|
||||
var appliedUpdates = new List<string>();
|
||||
foreach (var (key, value) in request.Updates)
|
||||
{
|
||||
// Validate key format
|
||||
if (!IsValidEnvKey(key))
|
||||
{
|
||||
_logger.LogWarning("Invalid env key rejected: {Key}", key);
|
||||
return BadRequest(new { error = $"Invalid environment variable key: {key}" });
|
||||
}
|
||||
|
||||
envContent[key] = value;
|
||||
appliedUpdates.Add(key);
|
||||
_logger.LogInformation(" Setting {Key} = {Value}", key,
|
||||
key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL")
|
||||
? "***" + (value.Length > 8 ? value[^8..] : "")
|
||||
: value);
|
||||
|
||||
// Auto-set cookie date when Spotify session cookie is updated
|
||||
if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE";
|
||||
var dateValue = DateTime.UtcNow.ToString("o"); // ISO 8601 format
|
||||
envContent[dateKey] = dateValue;
|
||||
appliedUpdates.Add(dateKey);
|
||||
_logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
await System.IO.File.WriteAllTextAsync(_envFilePath, newContent + "\n");
|
||||
|
||||
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Configuration updated. Restart container to apply changes.",
|
||||
updatedKeys = appliedUpdates,
|
||||
requiresRestart = true,
|
||||
envFilePath = _envFilePath
|
||||
});
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Permission denied writing to .env file at {Path}", _envFilePath);
|
||||
return StatusCode(500, new {
|
||||
error = "Permission denied",
|
||||
details = "Cannot write to .env file. Check file permissions and volume mount.",
|
||||
path = _envFilePath
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update configuration at {Path}", _envFilePath);
|
||||
return StatusCode(500, new {
|
||||
error = "Failed to update configuration",
|
||||
details = ex.Message,
|
||||
path = _envFilePath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a new playlist to the configuration
|
||||
/// </summary>
|
||||
[HttpPost("playlists")]
|
||||
public async Task<IActionResult> AddPlaylist([FromBody] AddPlaylistRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Name) || string.IsNullOrEmpty(request.SpotifyId))
|
||||
{
|
||||
return BadRequest(new { error = "Name and SpotifyId are required" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Adding playlist: {Name} ({SpotifyId})", request.Name, request.SpotifyId);
|
||||
|
||||
// Get current playlists
|
||||
var currentPlaylists = _spotifyImportSettings.Playlists.ToList();
|
||||
|
||||
// Check for duplicates
|
||||
if (currentPlaylists.Any(p => p.Id == request.SpotifyId || p.Name == request.Name))
|
||||
{
|
||||
return BadRequest(new { error = "Playlist with this name or ID already exists" });
|
||||
}
|
||||
|
||||
// Add new playlist
|
||||
currentPlaylists.Add(new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = request.Name,
|
||||
Id = request.SpotifyId,
|
||||
LocalTracksPosition = request.LocalTracksPosition == "last"
|
||||
? LocalTracksPosition.Last
|
||||
: LocalTracksPosition.First
|
||||
});
|
||||
|
||||
// Convert to JSON format for env var
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
||||
);
|
||||
|
||||
// Update .env file
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a playlist from the configuration
|
||||
/// </summary>
|
||||
[HttpDelete("playlists/{name}")]
|
||||
public async Task<IActionResult> RemovePlaylist(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Removing playlist: {Name}", decodedName);
|
||||
|
||||
// Read current playlists from .env file (not stale in-memory config)
|
||||
var currentPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
var playlist = currentPlaylists.FirstOrDefault(p => p.Name == decodedName);
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
return NotFound(new { error = "Playlist not found" });
|
||||
}
|
||||
|
||||
currentPlaylists.Remove(playlist);
|
||||
|
||||
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
||||
);
|
||||
|
||||
// Update .env file
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all cached data
|
||||
/// </summary>
|
||||
[HttpPost("cache/clear")]
|
||||
public async Task<IActionResult> ClearCache()
|
||||
{
|
||||
_logger.LogInformation("Cache clear requested from admin UI");
|
||||
|
||||
var clearedFiles = 0;
|
||||
var clearedRedisKeys = 0;
|
||||
|
||||
// Clear file cache
|
||||
if (Directory.Exists(CacheDirectory))
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(CacheDirectory, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.File.Delete(file);
|
||||
clearedFiles++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete cache file {File}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear ALL Redis cache keys for Spotify playlists
|
||||
// This includes matched tracks, ordered tracks, missing tracks, playlist items, etc.
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var keysToDelete = new[]
|
||||
{
|
||||
$"spotify:playlist:{playlist.Name}",
|
||||
$"spotify:missing:{playlist.Name}",
|
||||
$"spotify:matched:{playlist.Name}",
|
||||
$"spotify:matched:ordered:{playlist.Name}",
|
||||
$"spotify:playlist:items:{playlist.Name}" // NEW: Clear file-backed playlist items cache
|
||||
};
|
||||
|
||||
foreach (var key in keysToDelete)
|
||||
{
|
||||
if (await _cache.DeleteAsync(key))
|
||||
{
|
||||
clearedRedisKeys++;
|
||||
_logger.LogInformation("Cleared Redis cache key: {Key}", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all search cache keys (pattern-based deletion)
|
||||
var searchKeysDeleted = await _cache.DeleteByPatternAsync("search:*");
|
||||
clearedRedisKeys += searchKeysDeleted;
|
||||
|
||||
// Clear all image cache keys (pattern-based deletion)
|
||||
var imageKeysDeleted = await _cache.DeleteByPatternAsync("image:*");
|
||||
clearedRedisKeys += imageKeysDeleted;
|
||||
|
||||
_logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys (including {SearchKeys} search keys, {ImageKeys} image keys)",
|
||||
clearedFiles, clearedRedisKeys, searchKeysDeleted, imageKeysDeleted);
|
||||
|
||||
return Ok(new {
|
||||
message = "Cache cleared successfully",
|
||||
filesDeleted = clearedFiles,
|
||||
redisKeysDeleted = clearedRedisKeys
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restart the allstarr container to apply configuration changes
|
||||
/// </summary>
|
||||
[HttpPost("restart")]
|
||||
public async Task<IActionResult> RestartContainer()
|
||||
{
|
||||
_logger.LogInformation("Container restart requested from admin UI");
|
||||
|
||||
try
|
||||
{
|
||||
// Use Docker socket to restart the container
|
||||
var socketPath = "/var/run/docker.sock";
|
||||
|
||||
if (!System.IO.File.Exists(socketPath))
|
||||
{
|
||||
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
|
||||
return StatusCode(503, new {
|
||||
error = "Docker socket not available",
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
});
|
||||
}
|
||||
|
||||
// Get container ID from hostname (Docker sets hostname to container ID by default)
|
||||
// Or use the well-known container name
|
||||
var containerId = Environment.MachineName;
|
||||
var containerName = "allstarr";
|
||||
|
||||
_logger.LogInformation("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName);
|
||||
|
||||
// Create Unix socket HTTP client
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = async (context, cancellationToken) =>
|
||||
{
|
||||
var socket = new System.Net.Sockets.Socket(
|
||||
System.Net.Sockets.AddressFamily.Unix,
|
||||
System.Net.Sockets.SocketType.Stream,
|
||||
System.Net.Sockets.ProtocolType.Unspecified);
|
||||
|
||||
var endpoint = new System.Net.Sockets.UnixDomainSocketEndPoint(socketPath);
|
||||
await socket.ConnectAsync(endpoint, cancellationToken);
|
||||
|
||||
return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
};
|
||||
|
||||
using var dockerClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost")
|
||||
};
|
||||
|
||||
// Try to restart by container name first, then by ID
|
||||
var response = await dockerClient.PostAsync($"/containers/{containerName}/restart?t=5", null);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// Try by container ID
|
||||
response = await dockerClient.PostAsync($"/containers/{containerId}/restart?t=5", null);
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Container restart initiated successfully");
|
||||
return Ok(new { message = "Restarting container...", success = true });
|
||||
}
|
||||
else
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new {
|
||||
error = "Failed to restart container",
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error restarting container");
|
||||
return StatusCode(500, new {
|
||||
error = "Failed to restart container",
|
||||
details = ex.Message,
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize cookie date to current date if cookie exists but date is not set
|
||||
/// </summary>
|
||||
[HttpPost("config/init-cookie-date")]
|
||||
public async Task<IActionResult> InitCookieDate()
|
||||
{
|
||||
// Only init if cookie exists but date is not set
|
||||
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
return BadRequest(new { error = "No cookie set" });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_spotifyApiSettings.SessionCookieSetDate))
|
||||
{
|
||||
return Ok(new { message = "Cookie date already set", date = _spotifyApiSettings.SessionCookieSetDate });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Initializing cookie date to current date (cookie existed without date tracking)");
|
||||
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = DateTime.UtcNow.ToString("o")
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all Jellyfin users
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/users")]
|
||||
public async Task<IActionResult> GetJellyfinUsers()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Users";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to fetch Jellyfin users: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch users from Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var users = new List<object>();
|
||||
|
||||
foreach (var user in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var id = user.GetProperty("Id").GetString();
|
||||
var name = user.GetProperty("Name").GetString();
|
||||
|
||||
users.Add(new { id, name });
|
||||
}
|
||||
|
||||
return Ok(new { users });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Jellyfin users");
|
||||
return StatusCode(500, new { error = "Failed to fetch users", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all Jellyfin libraries (virtual folders)
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/libraries")]
|
||||
public async Task<IActionResult> GetJellyfinLibraries()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Library/VirtualFolders";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to fetch Jellyfin libraries: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch libraries from Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var libraries = new List<object>();
|
||||
|
||||
foreach (var lib in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var name = lib.GetProperty("Name").GetString();
|
||||
var itemId = lib.TryGetProperty("ItemId", out var id) ? id.GetString() : null;
|
||||
var collectionType = lib.TryGetProperty("CollectionType", out var ct) ? ct.GetString() : null;
|
||||
|
||||
libraries.Add(new { id = itemId, name, collectionType });
|
||||
}
|
||||
|
||||
return Ok(new { libraries });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Jellyfin libraries");
|
||||
return StatusCode(500, new { error = "Failed to fetch libraries", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all playlists from Jellyfin
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/playlists")]
|
||||
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Build URL with optional userId filter
|
||||
var url = $"{_jellyfinSettings.Url}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ProviderIds,ChildCount,RecursiveItemCount,SongCount";
|
||||
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
url += $"&UserId={userId}";
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to fetch Jellyfin playlists: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch playlists from Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var playlists = new List<object>();
|
||||
|
||||
// Read current playlists from .env file for accurate linked status
|
||||
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
|
||||
if (doc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var id = item.GetProperty("Id").GetString();
|
||||
var name = item.GetProperty("Name").GetString();
|
||||
|
||||
// Try multiple fields for track count - Jellyfin may use different fields
|
||||
var childCount = 0;
|
||||
if (item.TryGetProperty("ChildCount", out var cc) && cc.ValueKind == JsonValueKind.Number)
|
||||
childCount = cc.GetInt32();
|
||||
else if (item.TryGetProperty("SongCount", out var sc) && sc.ValueKind == JsonValueKind.Number)
|
||||
childCount = sc.GetInt32();
|
||||
else if (item.TryGetProperty("RecursiveItemCount", out var ric) && ric.ValueKind == JsonValueKind.Number)
|
||||
childCount = ric.GetInt32();
|
||||
|
||||
// Check if this playlist is configured in allstarr by Jellyfin ID
|
||||
var configuredPlaylist = configuredPlaylists
|
||||
.FirstOrDefault(p => p.JellyfinId.Equals(id, StringComparison.OrdinalIgnoreCase));
|
||||
var isConfigured = configuredPlaylist != null;
|
||||
var linkedSpotifyId = configuredPlaylist?.Id;
|
||||
|
||||
// Only fetch detailed track stats for configured Spotify playlists
|
||||
// This avoids expensive queries for large non-Spotify playlists
|
||||
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
||||
if (isConfigured)
|
||||
{
|
||||
trackStats = await GetPlaylistTrackStats(id!);
|
||||
}
|
||||
|
||||
playlists.Add(new
|
||||
{
|
||||
id,
|
||||
name,
|
||||
trackCount = childCount,
|
||||
linkedSpotifyId,
|
||||
isConfigured,
|
||||
localTracks = trackStats.LocalTracks,
|
||||
externalTracks = trackStats.ExternalTracks,
|
||||
externalAvailable = trackStats.ExternalAvailable
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { playlists });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Jellyfin playlists");
|
||||
return StatusCode(500, new { error = "Failed to fetch playlists", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get track statistics for a playlist (local vs external)
|
||||
/// </summary>
|
||||
private async Task<(int LocalTracks, int ExternalTracks, int ExternalAvailable)> GetPlaylistTrackStats(string playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Jellyfin requires a UserId to fetch playlist items
|
||||
// We'll use the first available user if not specified
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
// If no user configured, try to get the first user
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
var usersResponse = await _jellyfinHttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users")
|
||||
{
|
||||
Headers = { { "X-Emby-Authorization", GetJellyfinAuthHeader() } }
|
||||
});
|
||||
|
||||
if (usersResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var usersJson = await usersResponse.Content.ReadAsStringAsync();
|
||||
using var usersDoc = JsonDocument.Parse(usersJson);
|
||||
if (usersDoc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
userId = usersDoc.RootElement[0].GetProperty("Id").GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("No user ID available to fetch playlist items for {PlaylistId}", playlistId);
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistId}/Items?UserId={userId}&Fields=Path";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode);
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var localTracks = 0;
|
||||
var externalTracks = 0;
|
||||
var externalAvailable = 0;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
// Simpler detection: Check if Path exists and is not empty
|
||||
// External tracks from allstarr won't have a Path property
|
||||
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
|
||||
pathProp.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrEmpty(pathProp.GetString());
|
||||
|
||||
if (hasPath)
|
||||
{
|
||||
var pathStr = pathProp.GetString()!;
|
||||
// Check if it's a real file path (not a URL)
|
||||
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
|
||||
{
|
||||
localTracks++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// It's a URL or external source
|
||||
externalTracks++;
|
||||
externalAvailable++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No path means it's external
|
||||
externalTracks++;
|
||||
externalAvailable++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Playlist {PlaylistId} stats: {Local} local, {External} external",
|
||||
playlistId, localTracks, externalTracks);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No Items property in playlist response for {PlaylistId}", playlistId);
|
||||
}
|
||||
|
||||
return (localTracks, externalTracks, externalAvailable);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get track stats for playlist {PlaylistId}", playlistId);
|
||||
return (0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Link a Jellyfin playlist to a Spotify playlist
|
||||
/// </summary>
|
||||
[HttpPost("jellyfin/playlists/{jellyfinPlaylistId}/link")]
|
||||
public async Task<IActionResult> LinkPlaylist(string jellyfinPlaylistId, [FromBody] LinkPlaylistRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.SpotifyPlaylistId))
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyPlaylistId is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.Name))
|
||||
{
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Linking Jellyfin playlist {JellyfinId} to Spotify playlist {SpotifyId} with name {Name}",
|
||||
jellyfinPlaylistId, request.SpotifyPlaylistId, request.Name);
|
||||
|
||||
// Read current playlists from .env file (not in-memory config which is stale)
|
||||
var currentPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
|
||||
// Check if already configured by Jellyfin ID
|
||||
var existingByJellyfinId = currentPlaylists
|
||||
.FirstOrDefault(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingByJellyfinId != null)
|
||||
{
|
||||
return BadRequest(new { error = $"This Jellyfin playlist is already linked to '{existingByJellyfinId.Name}'" });
|
||||
}
|
||||
|
||||
// Check if already configured by name
|
||||
var existingByName = currentPlaylists
|
||||
.FirstOrDefault(p => p.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingByName != null)
|
||||
{
|
||||
return BadRequest(new { error = $"Playlist name '{request.Name}' is already configured" });
|
||||
}
|
||||
|
||||
// Add the playlist to configuration
|
||||
currentPlaylists.Add(new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = request.Name,
|
||||
Id = request.SpotifyPlaylistId,
|
||||
JellyfinId = jellyfinPlaylistId,
|
||||
LocalTracksPosition = LocalTracksPosition.First // Use Spotify order
|
||||
});
|
||||
|
||||
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
||||
);
|
||||
|
||||
// Update .env file
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unlink a playlist (remove from configuration)
|
||||
/// </summary>
|
||||
[HttpDelete("jellyfin/playlists/{name}/unlink")]
|
||||
public async Task<IActionResult> UnlinkPlaylist(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
return await RemovePlaylist(decodedName);
|
||||
}
|
||||
|
||||
private string GetJellyfinAuthHeader()
|
||||
{
|
||||
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read current playlists from .env file (not stale in-memory config)
|
||||
/// </summary>
|
||||
private async Task<List<SpotifyPlaylistConfig>> ReadPlaylistsFromEnvFile()
|
||||
{
|
||||
var playlists = new List<SpotifyPlaylistConfig>();
|
||||
|
||||
if (!System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
return playlists;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var lines = await System.IO.File.ReadAllLinesAsync(_envFilePath);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.TrimStart().StartsWith("SPOTIFY_IMPORT_PLAYLISTS="))
|
||||
{
|
||||
var value = line.Substring(line.IndexOf('=') + 1).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value) || value == "[]")
|
||||
{
|
||||
return playlists;
|
||||
}
|
||||
|
||||
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
||||
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
|
||||
if (playlistArrays != null)
|
||||
{
|
||||
foreach (var arr in playlistArrays)
|
||||
{
|
||||
if (arr.Length >= 2)
|
||||
{
|
||||
playlists.Add(new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = arr[0].Trim(),
|
||||
Id = arr[1].Trim(),
|
||||
JellyfinId = arr.Length >= 3 ? arr[2].Trim() : "",
|
||||
LocalTracksPosition = arr.Length >= 4 &&
|
||||
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
||||
? LocalTracksPosition.Last
|
||||
: LocalTracksPosition.First
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read playlists from .env file");
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
|
||||
private static string MaskValue(string? value, int showLast = 0)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "(not set)";
|
||||
if (value.Length <= showLast) return "***";
|
||||
return showLast > 0 ? "***" + value[^showLast..] : value[..8] + "...";
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
return string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
|
||||
}
|
||||
|
||||
private static bool IsValidEnvKey(string key)
|
||||
{
|
||||
// Only allow alphanumeric, underscore, and must start with letter/underscore
|
||||
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export .env file for backup/transfer
|
||||
/// </summary>
|
||||
[HttpGet("export-env")]
|
||||
public IActionResult ExportEnv()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
return NotFound(new { error = ".env file not found" });
|
||||
}
|
||||
|
||||
var envContent = System.IO.File.ReadAllText(_envFilePath);
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(envContent);
|
||||
|
||||
return File(bytes, "text/plain", ".env");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export .env file");
|
||||
return StatusCode(500, new { error = "Failed to export .env file", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import .env file from upload
|
||||
/// </summary>
|
||||
[HttpPost("import-env")]
|
||||
public async Task<IActionResult> ImportEnv([FromForm] IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { error = "No file provided" });
|
||||
}
|
||||
|
||||
if (!file.FileName.EndsWith(".env"))
|
||||
{
|
||||
return BadRequest(new { error = "File must be a .env file" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Read uploaded file
|
||||
using var reader = new StreamReader(file.OpenReadStream());
|
||||
var content = await reader.ReadToEndAsync();
|
||||
|
||||
// Validate it's a valid .env file (basic check)
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return BadRequest(new { error = ".env file is empty" });
|
||||
}
|
||||
|
||||
// Backup existing .env
|
||||
if (System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
var backupPath = $"{_envFilePath}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
System.IO.File.Copy(_envFilePath, backupPath, true);
|
||||
_logger.LogInformation("Backed up existing .env to {BackupPath}", backupPath);
|
||||
}
|
||||
|
||||
// Write new .env file
|
||||
await System.IO.File.WriteAllTextAsync(_envFilePath, content);
|
||||
|
||||
_logger.LogInformation(".env file imported successfully");
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
message = ".env file imported successfully. Restart the application for changes to take effect."
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to import .env file");
|
||||
return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets detailed memory usage statistics for debugging.
|
||||
/// </summary>
|
||||
[HttpGet("memory-stats")]
|
||||
public IActionResult GetMemoryStats()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get memory stats BEFORE GC
|
||||
var memoryBeforeGC = GC.GetTotalMemory(false);
|
||||
var gen0Before = GC.CollectionCount(0);
|
||||
var gen1Before = GC.CollectionCount(1);
|
||||
var gen2Before = GC.CollectionCount(2);
|
||||
|
||||
// Force garbage collection to get accurate numbers
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
var memoryAfterGC = GC.GetTotalMemory(false);
|
||||
var gen0After = GC.CollectionCount(0);
|
||||
var gen1After = GC.CollectionCount(1);
|
||||
var gen2After = GC.CollectionCount(2);
|
||||
|
||||
// Get process memory info
|
||||
var process = System.Diagnostics.Process.GetCurrentProcess();
|
||||
|
||||
return Ok(new {
|
||||
Timestamp = DateTime.UtcNow,
|
||||
BeforeGC = new {
|
||||
GCMemoryBytes = memoryBeforeGC,
|
||||
GCMemoryMB = Math.Round(memoryBeforeGC / (1024.0 * 1024.0), 2)
|
||||
},
|
||||
AfterGC = new {
|
||||
GCMemoryBytes = memoryAfterGC,
|
||||
GCMemoryMB = Math.Round(memoryAfterGC / (1024.0 * 1024.0), 2)
|
||||
},
|
||||
MemoryFreedMB = Math.Round((memoryBeforeGC - memoryAfterGC) / (1024.0 * 1024.0), 2),
|
||||
ProcessWorkingSetBytes = process.WorkingSet64,
|
||||
ProcessWorkingSetMB = Math.Round(process.WorkingSet64 / (1024.0 * 1024.0), 2),
|
||||
ProcessPrivateMemoryBytes = process.PrivateMemorySize64,
|
||||
ProcessPrivateMemoryMB = Math.Round(process.PrivateMemorySize64 / (1024.0 * 1024.0), 2),
|
||||
ProcessVirtualMemoryBytes = process.VirtualMemorySize64,
|
||||
ProcessVirtualMemoryMB = Math.Round(process.VirtualMemorySize64 / (1024.0 * 1024.0), 2),
|
||||
GCCollections = new {
|
||||
Gen0Before = gen0Before,
|
||||
Gen0After = gen0After,
|
||||
Gen0Triggered = gen0After - gen0Before,
|
||||
Gen1Before = gen1Before,
|
||||
Gen1After = gen1After,
|
||||
Gen1Triggered = gen1After - gen1Before,
|
||||
Gen2Before = gen2Before,
|
||||
Gen2After = gen2After,
|
||||
Gen2Triggered = gen2After - gen2Before
|
||||
},
|
||||
GCMode = GCSettings.IsServerGC ? "Server" : "Workstation",
|
||||
GCLatencyMode = GCSettings.LatencyMode.ToString()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces garbage collection to free up memory (emergency use only).
|
||||
/// </summary>
|
||||
[HttpPost("force-gc")]
|
||||
public IActionResult ForceGarbageCollection()
|
||||
{
|
||||
try
|
||||
{
|
||||
var memoryBefore = GC.GetTotalMemory(false);
|
||||
var processBefore = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
|
||||
|
||||
// Force full garbage collection
|
||||
GC.Collect(2, GCCollectionMode.Forced);
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect(2, GCCollectionMode.Forced);
|
||||
|
||||
var memoryAfter = GC.GetTotalMemory(false);
|
||||
var processAfter = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
|
||||
|
||||
return Ok(new {
|
||||
Timestamp = DateTime.UtcNow,
|
||||
MemoryFreedMB = Math.Round((memoryBefore - memoryAfter) / (1024.0 * 1024.0), 2),
|
||||
ProcessMemoryFreedMB = Math.Round((processBefore - processAfter) / (1024.0 * 1024.0), 2),
|
||||
BeforeGCMB = Math.Round(memoryBefore / (1024.0 * 1024.0), 2),
|
||||
AfterGCMB = Math.Round(memoryAfter / (1024.0 * 1024.0), 2),
|
||||
BeforeProcessMB = Math.Round(processBefore / (1024.0 * 1024.0), 2),
|
||||
AfterProcessMB = Math.Round(processAfter / (1024.0 * 1024.0), 2)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets current active sessions for debugging.
|
||||
/// </summary>
|
||||
[HttpGet("sessions")]
|
||||
public IActionResult GetActiveSessions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sessionManager = HttpContext.RequestServices.GetService<JellyfinSessionManager>();
|
||||
if (sessionManager == null)
|
||||
{
|
||||
return BadRequest(new { error = "Session manager not available" });
|
||||
}
|
||||
|
||||
var sessionInfo = sessionManager.GetSessionsInfo();
|
||||
return Ok(sessionInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to trigger GC after large file operations to prevent memory leaks.
|
||||
/// </summary>
|
||||
private static void TriggerGCAfterLargeOperation(int sizeInBytes)
|
||||
{
|
||||
// Only trigger GC for files larger than 1MB to avoid performance impact
|
||||
if (sizeInBytes > 1024 * 1024)
|
||||
{
|
||||
// Suggest GC collection for large objects (they go to LOH and aren't collected as frequently)
|
||||
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
|
||||
}
|
||||
}
|
||||
|
||||
#region Spotify Admin Endpoints
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
||||
/// </summary>
|
||||
[HttpGet("spotify/sync")]
|
||||
public async Task<IActionResult> TriggerSpotifySync([FromServices] IEnumerable<IHostedService> hostedServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_spotifyImportSettings.Enabled)
|
||||
{
|
||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Manual Spotify sync triggered via admin endpoint");
|
||||
|
||||
// Find the SpotifyMissingTracksFetcher service
|
||||
var fetcherService = hostedServices
|
||||
.OfType<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (fetcherService == null)
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyMissingTracksFetcher service not found" });
|
||||
}
|
||||
|
||||
// Trigger the sync in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use reflection to call the private ExecuteOnceAsync method
|
||||
var method = fetcherService.GetType().GetMethod("ExecuteOnceAsync",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
await (Task)method.Invoke(fetcherService, new object[] { CancellationToken.None })!;
|
||||
_logger.LogInformation("Manual Spotify sync completed successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyMissingTracksFetcher");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during manual Spotify sync");
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(new {
|
||||
message = "Spotify sync started in background",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering Spotify sync");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger endpoint to force Spotify track matching.
|
||||
/// </summary>
|
||||
[HttpGet("spotify/match")]
|
||||
public async Task<IActionResult> TriggerSpotifyMatch([FromServices] IEnumerable<IHostedService> hostedServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_spotifyApiSettings.Enabled)
|
||||
{
|
||||
return BadRequest(new { error = "Spotify API is not enabled" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Manual Spotify track matching triggered via admin endpoint");
|
||||
|
||||
// Find the SpotifyTrackMatchingService
|
||||
var matchingService = hostedServices
|
||||
.OfType<allstarr.Services.Spotify.SpotifyTrackMatchingService>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyTrackMatchingService not found" });
|
||||
}
|
||||
|
||||
// Trigger matching in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use reflection to call the private ExecuteOnceAsync method
|
||||
var method = matchingService.GetType().GetMethod("ExecuteOnceAsync",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
await (Task)method.Invoke(matchingService, new object[] { CancellationToken.None })!;
|
||||
_logger.LogInformation("Manual Spotify track matching completed successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyTrackMatchingService");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during manual Spotify track matching");
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(new {
|
||||
message = "Spotify track matching started in background",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering Spotify track matching");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear Spotify playlist cache to force re-matching.
|
||||
/// </summary>
|
||||
[HttpPost("spotify/clear-cache")]
|
||||
public async Task<IActionResult> ClearSpotifyCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
var clearedKeys = new List<string>();
|
||||
|
||||
// Clear Redis cache for all configured playlists
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var keys = new[]
|
||||
{
|
||||
$"spotify:playlist:{playlist.Name}",
|
||||
$"spotify:playlist:items:{playlist.Name}",
|
||||
$"spotify:matched:{playlist.Name}"
|
||||
};
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
await _cache.DeleteAsync(key);
|
||||
clearedKeys.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count);
|
||||
|
||||
return Ok(new {
|
||||
message = "Spotify cache cleared successfully",
|
||||
clearedKeys = clearedKeys,
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error clearing Spotify cache");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Debug Endpoints
|
||||
|
||||
/// <summary>
|
||||
/// Gets endpoint usage statistics from the log file.
|
||||
/// </summary>
|
||||
[HttpGet("debug/endpoint-usage")]
|
||||
public async Task<IActionResult> GetEndpointUsage(
|
||||
[FromQuery] int top = 100,
|
||||
[FromQuery] string? since = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||
|
||||
if (!System.IO.File.Exists(logFile))
|
||||
{
|
||||
return Ok(new {
|
||||
message = "No endpoint usage data available",
|
||||
endpoints = new object[0]
|
||||
});
|
||||
}
|
||||
|
||||
var lines = await System.IO.File.ReadAllLinesAsync(logFile);
|
||||
var usage = new Dictionary<string, int>();
|
||||
DateTime? sinceDate = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate))
|
||||
{
|
||||
sinceDate = parsedDate;
|
||||
}
|
||||
|
||||
foreach (var line in lines.Skip(1)) // Skip header
|
||||
{
|
||||
var parts = line.Split(',');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
var timestamp = parts[0];
|
||||
var method = parts[1];
|
||||
var endpoint = parts[2];
|
||||
|
||||
// Combine method and endpoint for better clarity
|
||||
var fullEndpoint = $"{method} {endpoint}";
|
||||
|
||||
// Filter by date if specified
|
||||
if (sinceDate.HasValue && DateTime.TryParse(timestamp, out var logDate))
|
||||
{
|
||||
if (logDate < sinceDate.Value)
|
||||
continue;
|
||||
}
|
||||
|
||||
usage[fullEndpoint] = usage.GetValueOrDefault(fullEndpoint, 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
var topEndpoints = usage
|
||||
.OrderByDescending(kv => kv.Value)
|
||||
.Take(top)
|
||||
.Select(kv => new { endpoint = kv.Key, count = kv.Value })
|
||||
.ToArray();
|
||||
|
||||
return Ok(new {
|
||||
totalEndpoints = usage.Count,
|
||||
totalRequests = usage.Values.Sum(),
|
||||
since = since,
|
||||
top = top,
|
||||
endpoints = topEndpoints
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting endpoint usage");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the endpoint usage log file.
|
||||
/// </summary>
|
||||
[HttpDelete("debug/endpoint-usage")]
|
||||
public IActionResult ClearEndpointUsage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||
|
||||
if (System.IO.File.Exists(logFile))
|
||||
{
|
||||
System.IO.File.Delete(logFile);
|
||||
_logger.LogInformation("Cleared endpoint usage log via admin endpoint");
|
||||
|
||||
return Ok(new {
|
||||
message = "Endpoint usage log cleared successfully",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
return Ok(new {
|
||||
message = "No endpoint usage log file found",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error clearing endpoint usage log");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Saves a manual mapping to file for persistence across restarts.
|
||||
/// Manual mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
private async Task SaveManualMappingToFileAsync(
|
||||
string playlistName,
|
||||
string spotifyId,
|
||||
string? jellyfinId,
|
||||
string? externalProvider,
|
||||
string? externalId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsDir = "/app/cache/mappings";
|
||||
Directory.CreateDirectory(mappingsDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
|
||||
|
||||
// Load existing mappings
|
||||
var mappings = new Dictionary<string, ManualMappingEntry>();
|
||||
if (System.IO.File.Exists(filePath))
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json)
|
||||
?? new Dictionary<string, ManualMappingEntry>();
|
||||
}
|
||||
|
||||
// Add or update mapping
|
||||
mappings[spotifyId] = new ManualMappingEntry
|
||||
{
|
||||
SpotifyId = spotifyId,
|
||||
JellyfinId = jellyfinId,
|
||||
ExternalProvider = externalProvider,
|
||||
ExternalId = externalId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Save back to file
|
||||
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
|
||||
|
||||
_logger.LogDebug("💾 Saved manual mapping to file: {Playlist} - {SpotifyId}", playlistName, spotifyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save manual mapping to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save lyrics mapping to file for persistence across restarts.
|
||||
/// Lyrics mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
private async Task SaveLyricsMappingToFileAsync(
|
||||
string artist,
|
||||
string title,
|
||||
string album,
|
||||
int durationSeconds,
|
||||
int lyricsId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsFile = "/app/cache/lyrics_mappings.json";
|
||||
|
||||
// Load existing mappings
|
||||
var mappings = new List<LyricsMappingEntry>();
|
||||
if (System.IO.File.Exists(mappingsFile))
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
|
||||
mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json)
|
||||
?? new List<LyricsMappingEntry>();
|
||||
}
|
||||
|
||||
// Remove any existing mapping for this track
|
||||
mappings.RemoveAll(m =>
|
||||
m.Artist.Equals(artist, StringComparison.OrdinalIgnoreCase) &&
|
||||
m.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Add new mapping
|
||||
mappings.Add(new LyricsMappingEntry
|
||||
{
|
||||
Artist = artist,
|
||||
Title = title,
|
||||
Album = album,
|
||||
DurationSeconds = durationSeconds,
|
||||
LyricsId = lyricsId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
// Save back to file
|
||||
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(mappingsFile, updatedJson);
|
||||
|
||||
_logger.LogDebug("💾 Saved lyrics mapping to file: {Artist} - {Title} → Lyrics ID {LyricsId}",
|
||||
artist, title, lyricsId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save lyrics mapping to file for {Artist} - {Title}", artist, title);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save manual lyrics ID mapping for a track
|
||||
/// </summary>
|
||||
[HttpPost("lyrics/map")]
|
||||
public async Task<IActionResult> SaveLyricsMapping([FromBody] LyricsMappingRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Artist) || string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
return BadRequest(new { error = "Artist and Title are required" });
|
||||
}
|
||||
|
||||
if (request.LyricsId <= 0)
|
||||
{
|
||||
return BadRequest(new { error = "Valid LyricsId is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Store lyrics mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var mappingKey = $"lyrics:manual-map:{request.Artist}:{request.Title}";
|
||||
await _cache.SetStringAsync(mappingKey, request.LyricsId.ToString());
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveLyricsMappingToFileAsync(request.Artist, request.Title, request.Album ?? "", request.DurationSeconds, request.LyricsId);
|
||||
|
||||
_logger.LogInformation("Manual lyrics mapping saved: {Artist} - {Title} → Lyrics ID {LyricsId}",
|
||||
request.Artist, request.Title, request.LyricsId);
|
||||
|
||||
// Optionally fetch and cache the lyrics immediately
|
||||
try
|
||||
{
|
||||
var lyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.LrclibService>();
|
||||
if (lyricsService != null)
|
||||
{
|
||||
var lyricsInfo = await lyricsService.GetLyricsByIdAsync(request.LyricsId);
|
||||
if (lyricsInfo != null && !string.IsNullOrEmpty(lyricsInfo.PlainLyrics))
|
||||
{
|
||||
// Cache the lyrics using the standard cache key
|
||||
var lyricsCacheKey = $"lyrics:{request.Artist}:{request.Title}:{request.Album ?? ""}:{request.DurationSeconds}";
|
||||
await _cache.SetAsync(lyricsCacheKey, lyricsInfo.PlainLyrics);
|
||||
_logger.LogInformation("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics mapping saved and lyrics cached successfully",
|
||||
lyricsId = request.LyricsId,
|
||||
cached = true,
|
||||
lyrics = new
|
||||
{
|
||||
id = lyricsInfo.Id,
|
||||
trackName = lyricsInfo.TrackName,
|
||||
artistName = lyricsInfo.ArtistName,
|
||||
albumName = lyricsInfo.AlbumName,
|
||||
duration = lyricsInfo.Duration,
|
||||
instrumental = lyricsInfo.Instrumental
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch lyrics after mapping, but mapping was saved");
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics mapping saved successfully",
|
||||
lyricsId = request.LyricsId,
|
||||
cached = false
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save lyrics mapping");
|
||||
return StatusCode(500, new { error = "Failed to save lyrics mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get manual lyrics mappings
|
||||
/// </summary>
|
||||
[HttpGet("lyrics/mappings")]
|
||||
public async Task<IActionResult> GetLyricsMappings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsFile = "/app/cache/lyrics_mappings.json";
|
||||
|
||||
if (!System.IO.File.Exists(mappingsFile))
|
||||
{
|
||||
return Ok(new { mappings = new List<object>() });
|
||||
}
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
|
||||
var mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json) ?? new List<LyricsMappingEntry>();
|
||||
|
||||
return Ok(new { mappings });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get lyrics mappings");
|
||||
return StatusCode(500, new { error = "Failed to get lyrics mappings" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all manual track mappings (both Jellyfin and external) for all playlists
|
||||
/// </summary>
|
||||
[HttpGet("mappings/tracks")]
|
||||
public async Task<IActionResult> GetAllTrackMappings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsDir = "/app/cache/mappings";
|
||||
var allMappings = new List<object>();
|
||||
|
||||
if (!Directory.Exists(mappingsDir))
|
||||
{
|
||||
return Ok(new { mappings = allMappings, totalCount = 0 });
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(mappingsDir, "*_mappings.json");
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(file);
|
||||
var playlistMappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
|
||||
|
||||
if (playlistMappings != null)
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
var playlistName = fileName.Replace("_mappings", "").Replace("_", " ");
|
||||
|
||||
foreach (var mapping in playlistMappings.Values)
|
||||
{
|
||||
allMappings.Add(new
|
||||
{
|
||||
playlist = playlistName,
|
||||
spotifyId = mapping.SpotifyId,
|
||||
type = !string.IsNullOrEmpty(mapping.JellyfinId) ? "jellyfin" : "external",
|
||||
jellyfinId = mapping.JellyfinId,
|
||||
externalProvider = mapping.ExternalProvider,
|
||||
externalId = mapping.ExternalId,
|
||||
createdAt = mapping.CreatedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read mapping file {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
mappings = allMappings.OrderBy(m => ((dynamic)m).playlist).ThenBy(m => ((dynamic)m).createdAt),
|
||||
totalCount = allMappings.Count,
|
||||
jellyfinCount = allMappings.Count(m => ((dynamic)m).type == "jellyfin"),
|
||||
externalCount = allMappings.Count(m => ((dynamic)m).type == "external")
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get track mappings");
|
||||
return StatusCode(500, new { error = "Failed to get track mappings" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a manual track mapping
|
||||
/// </summary>
|
||||
[HttpDelete("mappings/tracks")]
|
||||
public async Task<IActionResult> DeleteTrackMapping([FromQuery] string playlist, [FromQuery] string spotifyId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(playlist) || string.IsNullOrEmpty(spotifyId))
|
||||
{
|
||||
return BadRequest(new { error = "playlist and spotifyId parameters are required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mappingsDir = "/app/cache/mappings";
|
||||
var safeName = string.Join("_", playlist.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
|
||||
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
return NotFound(new { error = "Mapping file not found for playlist" });
|
||||
}
|
||||
|
||||
// Load existing mappings
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
var mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
|
||||
|
||||
if (mappings == null || !mappings.ContainsKey(spotifyId))
|
||||
{
|
||||
return NotFound(new { error = "Mapping not found" });
|
||||
}
|
||||
|
||||
// Remove the mapping
|
||||
mappings.Remove(spotifyId);
|
||||
|
||||
// Save back to file (or delete file if empty)
|
||||
if (mappings.Count == 0)
|
||||
{
|
||||
System.IO.File.Delete(filePath);
|
||||
_logger.LogInformation("🗑️ Deleted empty mapping file for playlist {Playlist}", playlist);
|
||||
}
|
||||
else
|
||||
{
|
||||
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
|
||||
_logger.LogInformation("🗑️ Deleted mapping: {Playlist} - {SpotifyId}", playlist, spotifyId);
|
||||
}
|
||||
|
||||
// Also remove from Redis cache
|
||||
var cacheKey = $"manual:mapping:{playlist}:{spotifyId}";
|
||||
await _cache.DeleteAsync(cacheKey);
|
||||
|
||||
return Ok(new { success = true, message = "Mapping deleted successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete track mapping for {Playlist} - {SpotifyId}", playlist, spotifyId);
|
||||
return StatusCode(500, new { error = "Failed to delete track mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID
|
||||
/// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP
|
||||
/// </summary>
|
||||
[HttpGet("lyrics/spotify/test")]
|
||||
public async Task<IActionResult> TestSpotifyLyrics([FromQuery] string trackId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(trackId))
|
||||
{
|
||||
return BadRequest(new { error = "trackId parameter is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var spotifyLyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
||||
|
||||
if (spotifyLyricsService == null)
|
||||
{
|
||||
return StatusCode(500, new { error = "Spotify lyrics service not available" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Testing Spotify lyrics for track ID: {TrackId}", trackId);
|
||||
|
||||
var result = await spotifyLyricsService.GetLyricsByTrackIdAsync(trackId);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
error = "No lyrics found",
|
||||
trackId,
|
||||
message = "Lyrics may not be available for this track, or the Spotify API is not configured correctly"
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
trackId = result.SpotifyTrackId,
|
||||
syncType = result.SyncType,
|
||||
lineCount = result.Lines.Count,
|
||||
language = result.Language,
|
||||
provider = result.Provider,
|
||||
providerDisplayName = result.ProviderDisplayName,
|
||||
lines = result.Lines.Select(l => new
|
||||
{
|
||||
startTimeMs = l.StartTimeMs,
|
||||
endTimeMs = l.EndTimeMs,
|
||||
words = l.Words
|
||||
}).ToList(),
|
||||
// Also show LRC format
|
||||
lrcFormat = string.Join("\n", result.Lines.Select(l =>
|
||||
{
|
||||
var timestamp = TimeSpan.FromMilliseconds(l.StartTimeMs);
|
||||
var mm = (int)timestamp.TotalMinutes;
|
||||
var ss = timestamp.Seconds;
|
||||
var ms = timestamp.Milliseconds / 10;
|
||||
return $"[{mm:D2}:{ss:D2}.{ms:D2}]{l.Words}";
|
||||
}))
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Spotify lyrics for track {TrackId}", trackId);
|
||||
return StatusCode(500, new { error = $"Failed to fetch lyrics: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefetch lyrics for a specific playlist
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/prefetch-lyrics")]
|
||||
public async Task<IActionResult> PrefetchPlaylistLyrics(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
try
|
||||
{
|
||||
var lyricsPrefetchService = _serviceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
|
||||
if (lyricsPrefetchService == null)
|
||||
{
|
||||
return StatusCode(500, new { error = "Lyrics prefetch service not available" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Starting lyrics prefetch for playlist: {Playlist}", decodedName);
|
||||
|
||||
var (fetched, cached, missing) = await lyricsPrefetchService.PrefetchPlaylistLyricsAsync(
|
||||
decodedName,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics prefetch complete",
|
||||
playlist = decodedName,
|
||||
fetched,
|
||||
cached,
|
||||
missing,
|
||||
total = fetched + cached + missing
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to prefetch lyrics for playlist {Playlist}", decodedName);
|
||||
return StatusCode(500, new { error = $"Failed to prefetch lyrics: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the cached playlist summary so it will be regenerated on next request
|
||||
/// </summary>
|
||||
private void InvalidatePlaylistSummaryCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheFile = "/app/cache/admin_playlists_summary.json";
|
||||
if (System.IO.File.Exists(cacheFile))
|
||||
{
|
||||
System.IO.File.Delete(cacheFile);
|
||||
_logger.LogDebug("🗑️ Invalidated playlist summary cache");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to invalidate playlist summary cache");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public class ManualMappingRequest
|
||||
{
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string? JellyfinId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
}
|
||||
|
||||
public class LyricsMappingRequest
|
||||
{
|
||||
public string Artist { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string? Album { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
public int LyricsId { get; set; }
|
||||
}
|
||||
|
||||
public class ManualMappingEntry
|
||||
{
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string? JellyfinId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class LyricsMappingEntry
|
||||
{
|
||||
public string Artist { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string? Album { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
public int LyricsId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class ConfigUpdateRequest
|
||||
{
|
||||
public Dictionary<string, string> Updates { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AddPlaylistRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string SpotifyId { get; set; } = string.Empty;
|
||||
public string LocalTracksPosition { get; set; } = "first";
|
||||
}
|
||||
|
||||
public class LinkPlaylistRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/downloads
|
||||
/// Lists all downloaded files in the KEPT folder only (favorited tracks)
|
||||
/// </summary>
|
||||
[HttpGet("downloads")]
|
||||
public IActionResult GetDownloads()
|
||||
{
|
||||
try
|
||||
{
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
|
||||
_logger.LogInformation("📂 Checking kept folder: {Path}", keptPath);
|
||||
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
|
||||
|
||||
if (!Directory.Exists(keptPath))
|
||||
{
|
||||
_logger.LogWarning("Kept folder does not exist: {Path}", keptPath);
|
||||
return Ok(new { files = new List<object>(), totalSize = 0, count = 0 });
|
||||
}
|
||||
|
||||
var files = new List<object>();
|
||||
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()))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("📂 Found {Count} audio files in kept folder", allFiles.Count);
|
||||
|
||||
foreach (var filePath in allFiles)
|
||||
{
|
||||
_logger.LogDebug("📂 Processing file: {Path}", filePath);
|
||||
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
var relativePath = Path.GetRelativePath(keptPath, filePath);
|
||||
|
||||
// Parse artist/album/track from path structure
|
||||
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var artist = parts.Length > 0 ? parts[0] : "";
|
||||
var album = parts.Length > 1 ? parts[1] : "";
|
||||
var fileName = parts.Length > 2 ? parts[^1] : Path.GetFileName(filePath);
|
||||
|
||||
files.Add(new
|
||||
{
|
||||
path = relativePath,
|
||||
fullPath = filePath,
|
||||
artist,
|
||||
album,
|
||||
fileName,
|
||||
size = fileInfo.Length,
|
||||
sizeFormatted = FormatFileSize(fileInfo.Length),
|
||||
lastModified = fileInfo.LastWriteTimeUtc,
|
||||
extension = fileInfo.Extension
|
||||
});
|
||||
|
||||
totalSize += fileInfo.Length;
|
||||
}
|
||||
|
||||
_logger.LogInformation("📂 Returning {Count} kept files, total size: {Size}", files.Count, FormatFileSize(totalSize));
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName),
|
||||
totalSize,
|
||||
totalSizeFormatted = FormatFileSize(totalSize),
|
||||
count = files.Count
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to list kept downloads");
|
||||
return StatusCode(500, new { error = "Failed to list kept downloads" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE /api/admin/downloads
|
||||
/// Deletes a specific kept file and cleans up empty folders
|
||||
/// </summary>
|
||||
[HttpDelete("downloads")]
|
||||
public IActionResult DeleteDownload([FromQuery] string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return BadRequest(new { error = "Path is required" });
|
||||
}
|
||||
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var fullPath = Path.Combine(keptPath, path);
|
||||
|
||||
_logger.LogInformation("🗑️ Delete request for: {Path}", fullPath);
|
||||
|
||||
// Security: Ensure the path is within the kept directory
|
||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||
var normalizedKeptPath = Path.GetFullPath(keptPath);
|
||||
|
||||
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
|
||||
{
|
||||
_logger.LogWarning("🗑️ Invalid path (outside kept folder): {Path}", normalizedFullPath);
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogWarning("🗑️ File not found: {Path}", fullPath);
|
||||
return NotFound(new { error = "File not found" });
|
||||
}
|
||||
|
||||
System.IO.File.Delete(fullPath);
|
||||
_logger.LogInformation("🗑️ Deleted file: {Path}", fullPath);
|
||||
|
||||
// Clean up empty directories (Album folder, then Artist folder if empty)
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
while (directory != null && directory != keptPath && directory.StartsWith(keptPath))
|
||||
{
|
||||
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
_logger.LogInformation("🗑️ Deleted empty directory: {Dir}", directory);
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("🗑️ Directory not empty or doesn't exist, stopping cleanup: {Dir}", directory);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "File deleted successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete file: {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to delete file" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/downloads/file
|
||||
/// Downloads a specific file from the kept folder
|
||||
/// </summary>
|
||||
[HttpGet("downloads/file")]
|
||||
public IActionResult DownloadFile([FromQuery] string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return BadRequest(new { error = "Path is required" });
|
||||
}
|
||||
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var fullPath = Path.Combine(keptPath, path);
|
||||
|
||||
// Security: Ensure the path is within the kept directory
|
||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||
var normalizedKeptPath = Path.GetFullPath(keptPath);
|
||||
|
||||
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
{
|
||||
return NotFound(new { error = "File not found" });
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(fullPath);
|
||||
var fileStream = System.IO.File.OpenRead(fullPath);
|
||||
|
||||
return File(fileStream, "application/octet-stream", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to download file: {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to download file" });
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatFileSize(long bytes)
|
||||
[HttpGet("health")]
|
||||
public IActionResult Health()
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len = len / 1024;
|
||||
}
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
return Ok(new { status = "healthy", message = "Admin API is running" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Admin;
|
||||
using allstarr.Filters;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services.Common;
|
||||
using System.Text.Json;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class ConfigController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<ConfigController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly JellyfinSettings _jellyfinSettings;
|
||||
private readonly SubsonicSettings _subsonicSettings;
|
||||
private readonly DeezerSettings _deezerSettings;
|
||||
private readonly QobuzSettings _qobuzSettings;
|
||||
private readonly SquidWTFSettings _squidWtfSettings;
|
||||
private readonly MusicBrainzSettings _musicBrainzSettings;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly AdminHelperService _helperService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private const string CacheDirectory = "/app/cache/spotify";
|
||||
|
||||
public ConfigController(
|
||||
ILogger<ConfigController> logger,
|
||||
IConfiguration configuration,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
IOptions<SubsonicSettings> subsonicSettings,
|
||||
IOptions<DeezerSettings> deezerSettings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||
IOptions<MusicBrainzSettings> musicBrainzSettings,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||
AdminHelperService helperService,
|
||||
RedisCacheService cache)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_subsonicSettings = subsonicSettings.Value;
|
||||
_deezerSettings = deezerSettings.Value;
|
||||
_qobuzSettings = qobuzSettings.Value;
|
||||
_squidWtfSettings = squidWtfSettings.Value;
|
||||
_musicBrainzSettings = musicBrainzSettings.Value;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
_helperService = helperService;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
[HttpGet("config")]
|
||||
public IActionResult GetConfig()
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
||||
musicService = _configuration.GetValue<string>("MusicService") ?? "SquidWTF",
|
||||
explicitFilter = _configuration.GetValue<string>("ExplicitFilter") ?? "All",
|
||||
enableExternalPlaylists = _configuration.GetValue<bool>("EnableExternalPlaylists", false),
|
||||
playlistsDirectory = _configuration.GetValue<string>("PlaylistsDirectory") ?? "(not set)",
|
||||
redisEnabled = _configuration.GetValue<bool>("Redis:Enabled", false),
|
||||
spotifyApi = new
|
||||
{
|
||||
enabled = _spotifyApiSettings.Enabled,
|
||||
sessionCookie = AdminHelperService.MaskValue(_spotifyApiSettings.SessionCookie, showLast: 8),
|
||||
sessionCookieSetDate = _spotifyApiSettings.SessionCookieSetDate,
|
||||
cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes,
|
||||
rateLimitDelayMs = _spotifyApiSettings.RateLimitDelayMs,
|
||||
preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching
|
||||
},
|
||||
spotifyImport = new
|
||||
{
|
||||
enabled = _spotifyImportSettings.Enabled,
|
||||
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
|
||||
playlists = _spotifyImportSettings.Playlists.Select(p => new
|
||||
{
|
||||
name = p.Name,
|
||||
id = p.Id,
|
||||
localTracksPosition = p.LocalTracksPosition.ToString()
|
||||
})
|
||||
},
|
||||
jellyfin = new
|
||||
{
|
||||
url = _jellyfinSettings.Url,
|
||||
apiKey = AdminHelperService.MaskValue(_jellyfinSettings.ApiKey),
|
||||
userId = _jellyfinSettings.UserId ?? "(not set)",
|
||||
libraryId = _jellyfinSettings.LibraryId
|
||||
},
|
||||
library = new
|
||||
{
|
||||
downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache")
|
||||
: Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "permanent"),
|
||||
keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"),
|
||||
storageMode = _subsonicSettings.StorageMode.ToString(),
|
||||
cacheDurationHours = _subsonicSettings.CacheDurationHours,
|
||||
downloadMode = _subsonicSettings.DownloadMode.ToString()
|
||||
},
|
||||
deezer = new
|
||||
{
|
||||
arl = AdminHelperService.MaskValue(_deezerSettings.Arl, showLast: 8),
|
||||
arlFallback = AdminHelperService.MaskValue(_deezerSettings.ArlFallback, showLast: 8),
|
||||
quality = _deezerSettings.Quality ?? "FLAC"
|
||||
},
|
||||
qobuz = new
|
||||
{
|
||||
userAuthToken = AdminHelperService.MaskValue(_qobuzSettings.UserAuthToken, showLast: 8),
|
||||
userId = _qobuzSettings.UserId,
|
||||
quality = _qobuzSettings.Quality ?? "FLAC"
|
||||
},
|
||||
squidWtf = new
|
||||
{
|
||||
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
||||
},
|
||||
musicBrainz = new
|
||||
{
|
||||
enabled = _musicBrainzSettings.Enabled,
|
||||
username = _musicBrainzSettings.Username ?? "(not set)",
|
||||
password = AdminHelperService.MaskValue(_musicBrainzSettings.Password),
|
||||
baseUrl = _musicBrainzSettings.BaseUrl,
|
||||
rateLimitMs = _musicBrainzSettings.RateLimitMs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update configuration by modifying .env file
|
||||
/// </summary>
|
||||
[HttpPost("config")]
|
||||
public async Task<IActionResult> UpdateConfig([FromBody] ConfigUpdateRequest request)
|
||||
{
|
||||
if (request == null || request.Updates == null || request.Updates.Count == 0)
|
||||
{
|
||||
return BadRequest(new { error = "No updates provided" });
|
||||
}
|
||||
|
||||
_logger.LogDebug("Config update requested: {Count} changes", request.Updates.Count);
|
||||
|
||||
try
|
||||
{
|
||||
// Check if .env file exists
|
||||
if (!System.IO.File.Exists(_helperService.GetEnvFilePath()))
|
||||
{
|
||||
_logger.LogWarning(".env file not found at {Path}, creating new file", _helperService.GetEnvFilePath());
|
||||
}
|
||||
|
||||
// Read current .env file or create new one
|
||||
var envContent = new Dictionary<string, string>();
|
||||
|
||||
if (System.IO.File.Exists(_helperService.GetEnvFilePath()))
|
||||
{
|
||||
var lines = await System.IO.File.ReadAllLinesAsync(_helperService.GetEnvFilePath());
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
|
||||
continue;
|
||||
|
||||
var eqIndex = line.IndexOf('=');
|
||||
if (eqIndex > 0)
|
||||
{
|
||||
var key = line[..eqIndex].Trim();
|
||||
var value = line[(eqIndex + 1)..].Trim();
|
||||
envContent[key] = value;
|
||||
}
|
||||
}
|
||||
_logger.LogDebug("Loaded {Count} existing env vars from {Path}", envContent.Count, _helperService.GetEnvFilePath());
|
||||
}
|
||||
|
||||
// Apply updates with validation
|
||||
var appliedUpdates = new List<string>();
|
||||
foreach (var (key, value) in request.Updates)
|
||||
{
|
||||
// Validate key format
|
||||
if (!AdminHelperService.IsValidEnvKey(key))
|
||||
{
|
||||
_logger.LogWarning("Invalid env key rejected: {Key}", key);
|
||||
return BadRequest(new { error = $"Invalid environment variable key: {key}" });
|
||||
}
|
||||
|
||||
envContent[key] = value;
|
||||
appliedUpdates.Add(key);
|
||||
_logger.LogInformation(" Setting {Key} = {Value}", key,
|
||||
key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL")
|
||||
? "***" + (value.Length > 8 ? value[^8..] : "")
|
||||
: value);
|
||||
|
||||
// Auto-set cookie date when Spotify session cookie is updated
|
||||
if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE";
|
||||
var dateValue = DateTime.UtcNow.ToString("o"); // ISO 8601 format
|
||||
envContent[dateKey] = dateValue;
|
||||
appliedUpdates.Add(dateKey);
|
||||
_logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
await System.IO.File.WriteAllTextAsync(_helperService.GetEnvFilePath(), newContent + "\n");
|
||||
|
||||
_logger.LogDebug("Config file updated successfully at {Path}", _helperService.GetEnvFilePath());
|
||||
|
||||
// Invalidate playlist summary cache if playlists were updated
|
||||
if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS"))
|
||||
{
|
||||
_helperService.InvalidatePlaylistSummaryCache();
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Configuration updated. Restart container to apply changes.",
|
||||
updatedKeys = appliedUpdates,
|
||||
requiresRestart = true,
|
||||
envFilePath = _helperService.GetEnvFilePath()
|
||||
});
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Permission denied writing to .env file at {Path}", _helperService.GetEnvFilePath());
|
||||
return StatusCode(500, new {
|
||||
error = "Permission denied",
|
||||
details = "Cannot write to .env file. Check file permissions and volume mount.",
|
||||
path = _helperService.GetEnvFilePath()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update configuration at {Path}", _helperService.GetEnvFilePath());
|
||||
return StatusCode(500, new {
|
||||
error = "Failed to update configuration",
|
||||
details = ex.Message,
|
||||
path = _helperService.GetEnvFilePath()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a new playlist to the configuration
|
||||
/// </summary>
|
||||
[HttpPost("cache/clear")]
|
||||
public async Task<IActionResult> ClearCache()
|
||||
{
|
||||
_logger.LogDebug("Cache clear requested from admin UI");
|
||||
|
||||
var clearedFiles = 0;
|
||||
var clearedRedisKeys = 0;
|
||||
|
||||
// Clear file cache
|
||||
if (Directory.Exists(CacheDirectory))
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(CacheDirectory, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.File.Delete(file);
|
||||
clearedFiles++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete cache file {File}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear ALL Redis cache keys for Spotify playlists
|
||||
// This includes matched tracks, ordered tracks, missing tracks, playlist items, etc.
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var keysToDelete = new[]
|
||||
{
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
|
||||
$"spotify:matched:{playlist.Name}", // Legacy key
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name)
|
||||
};
|
||||
|
||||
foreach (var key in keysToDelete)
|
||||
{
|
||||
if (await _cache.DeleteAsync(key))
|
||||
{
|
||||
clearedRedisKeys++;
|
||||
_logger.LogInformation("Cleared Redis cache key: {Key}", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all search cache keys (pattern-based deletion)
|
||||
var searchKeysDeleted = await _cache.DeleteByPatternAsync("search:*");
|
||||
clearedRedisKeys += searchKeysDeleted;
|
||||
|
||||
// Clear all image cache keys (pattern-based deletion)
|
||||
var imageKeysDeleted = await _cache.DeleteByPatternAsync("image:*");
|
||||
clearedRedisKeys += imageKeysDeleted;
|
||||
|
||||
_logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys (including {SearchKeys} search keys, {ImageKeys} image keys)",
|
||||
clearedFiles, clearedRedisKeys, searchKeysDeleted, imageKeysDeleted);
|
||||
|
||||
return Ok(new {
|
||||
message = "Cache cleared successfully",
|
||||
filesDeleted = clearedFiles,
|
||||
redisKeysDeleted = clearedRedisKeys
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restart the allstarr container to apply configuration changes
|
||||
/// </summary>
|
||||
[HttpPost("restart")]
|
||||
public async Task<IActionResult> RestartContainer()
|
||||
{
|
||||
_logger.LogDebug("Container restart requested from admin UI");
|
||||
|
||||
try
|
||||
{
|
||||
// Use Docker socket to restart the container
|
||||
var socketPath = "/var/run/docker.sock";
|
||||
|
||||
if (!System.IO.File.Exists(socketPath))
|
||||
{
|
||||
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
|
||||
return StatusCode(503, new {
|
||||
error = "Docker socket not available",
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
});
|
||||
}
|
||||
|
||||
// Get container ID from hostname (Docker sets hostname to container ID by default)
|
||||
// Or use the well-known container name
|
||||
var containerId = Environment.MachineName;
|
||||
var containerName = "allstarr";
|
||||
|
||||
_logger.LogDebug("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName);
|
||||
|
||||
// Create Unix socket HTTP client
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = async (context, cancellationToken) =>
|
||||
{
|
||||
var socket = new System.Net.Sockets.Socket(
|
||||
System.Net.Sockets.AddressFamily.Unix,
|
||||
System.Net.Sockets.SocketType.Stream,
|
||||
System.Net.Sockets.ProtocolType.Unspecified);
|
||||
|
||||
var endpoint = new System.Net.Sockets.UnixDomainSocketEndPoint(socketPath);
|
||||
await socket.ConnectAsync(endpoint, cancellationToken);
|
||||
|
||||
return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
};
|
||||
|
||||
using var dockerClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost")
|
||||
};
|
||||
|
||||
// Try to restart by container name first, then by ID
|
||||
var response = await dockerClient.PostAsync($"/containers/{containerName}/restart?t=5", null);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// Try by container ID
|
||||
response = await dockerClient.PostAsync($"/containers/{containerId}/restart?t=5", null);
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Container restart initiated successfully");
|
||||
return Ok(new { message = "Restarting container...", success = true });
|
||||
}
|
||||
else
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new {
|
||||
error = "Failed to restart container",
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error restarting container");
|
||||
return StatusCode(500, new {
|
||||
error = "Failed to restart container",
|
||||
details = ex.Message,
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize cookie date to current date if cookie exists but date is not set
|
||||
/// </summary>
|
||||
[HttpPost("config/init-cookie-date")]
|
||||
public async Task<IActionResult> InitCookieDate()
|
||||
{
|
||||
// Only init if cookie exists but date is not set
|
||||
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
return BadRequest(new { error = "No cookie set" });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_spotifyApiSettings.SessionCookieSetDate))
|
||||
{
|
||||
return Ok(new { message = "Cookie date already set", date = _spotifyApiSettings.SessionCookieSetDate });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Initializing cookie date to current date (cookie existed without date tracking)");
|
||||
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = DateTime.UtcNow.ToString("o")
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all Jellyfin users
|
||||
/// </summary>
|
||||
[HttpGet("export-env")]
|
||||
public IActionResult ExportEnv()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(_helperService.GetEnvFilePath()))
|
||||
{
|
||||
return NotFound(new { error = ".env file not found" });
|
||||
}
|
||||
|
||||
var envContent = System.IO.File.ReadAllText(_helperService.GetEnvFilePath());
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(envContent);
|
||||
|
||||
return File(bytes, "text/plain", ".env");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export .env file");
|
||||
return StatusCode(500, new { error = "Failed to export .env file", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import .env file from upload
|
||||
/// </summary>
|
||||
[HttpPost("import-env")]
|
||||
public async Task<IActionResult> ImportEnv([FromForm] IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { error = "No file provided" });
|
||||
}
|
||||
|
||||
if (!file.FileName.EndsWith(".env"))
|
||||
{
|
||||
return BadRequest(new { error = "File must be a .env file" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Read uploaded file
|
||||
using var reader = new StreamReader(file.OpenReadStream());
|
||||
var content = await reader.ReadToEndAsync();
|
||||
|
||||
// Validate it's a valid .env file (basic check)
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return BadRequest(new { error = ".env file is empty" });
|
||||
}
|
||||
|
||||
// Backup existing .env
|
||||
if (System.IO.File.Exists(_helperService.GetEnvFilePath()))
|
||||
{
|
||||
var backupPath = $"{_helperService.GetEnvFilePath()}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
System.IO.File.Copy(_helperService.GetEnvFilePath(), backupPath, true);
|
||||
_logger.LogDebug("Backed up existing .env to {BackupPath}", backupPath);
|
||||
}
|
||||
|
||||
// Write new .env file
|
||||
await System.IO.File.WriteAllTextAsync(_helperService.GetEnvFilePath(), content);
|
||||
|
||||
_logger.LogInformation(".env file imported successfully");
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
message = ".env file imported successfully. Restart the application for changes to take effect."
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to import .env file");
|
||||
return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets detailed memory usage statistics for debugging.
|
||||
/// </summary>
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Filters;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Common;
|
||||
using System.Runtime;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class DiagnosticsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<DiagnosticsController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly JellyfinSettings _jellyfinSettings;
|
||||
private readonly DeezerSettings _deezerSettings;
|
||||
private readonly QobuzSettings _qobuzSettings;
|
||||
private readonly SquidWTFSettings _squidWtfSettings;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly List<string> _squidWtfApiUrls;
|
||||
private static int _urlIndex = 0;
|
||||
private static readonly object _urlIndexLock = new();
|
||||
|
||||
public DiagnosticsController(
|
||||
ILogger<DiagnosticsController> logger,
|
||||
IConfiguration configuration,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
IOptions<DeezerSettings> deezerSettings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||
RedisCacheService cache)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_deezerSettings = deezerSettings.Value;
|
||||
_qobuzSettings = qobuzSettings.Value;
|
||||
_squidWtfSettings = squidWtfSettings.Value;
|
||||
_cache = cache;
|
||||
_squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||
}
|
||||
|
||||
private static List<string> DecodeSquidWtfUrls()
|
||||
{
|
||||
var encodedUrls = new[]
|
||||
{
|
||||
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm",
|
||||
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=",
|
||||
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=",
|
||||
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==",
|
||||
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==",
|
||||
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==",
|
||||
"aHR0cDovL2h1bmQucXFkbC5zaXRl",
|
||||
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=",
|
||||
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=",
|
||||
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ=="
|
||||
};
|
||||
return encodedUrls.Select(encoded => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded))).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public IActionResult GetStatus()
|
||||
{
|
||||
// Determine Spotify auth status based on configuration only
|
||||
// DO NOT call Spotify API here - this endpoint is polled frequently
|
||||
var spotifyAuthStatus = "not_configured";
|
||||
string? spotifyUser = null;
|
||||
|
||||
if (_spotifyApiSettings.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
// If cookie is set, assume it's working until proven otherwise
|
||||
// Actual validation happens when playlists are fetched
|
||||
spotifyAuthStatus = "configured";
|
||||
spotifyUser = "(cookie set)";
|
||||
}
|
||||
else if (_spotifyApiSettings.Enabled)
|
||||
{
|
||||
spotifyAuthStatus = "missing_cookie";
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
version = "1.0.3",
|
||||
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
||||
jellyfinUrl = _jellyfinSettings.Url,
|
||||
spotify = new
|
||||
{
|
||||
apiEnabled = _spotifyApiSettings.Enabled,
|
||||
authStatus = spotifyAuthStatus,
|
||||
user = spotifyUser,
|
||||
hasCookie = !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie),
|
||||
cookieSetDate = _spotifyApiSettings.SessionCookieSetDate,
|
||||
cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes,
|
||||
preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching
|
||||
},
|
||||
spotifyImport = new
|
||||
{
|
||||
enabled = _spotifyImportSettings.Enabled,
|
||||
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
|
||||
playlistCount = _spotifyImportSettings.Playlists.Count
|
||||
},
|
||||
deezer = new
|
||||
{
|
||||
hasArl = !string.IsNullOrEmpty(_deezerSettings.Arl),
|
||||
quality = _deezerSettings.Quality ?? "FLAC"
|
||||
},
|
||||
qobuz = new
|
||||
{
|
||||
hasToken = !string.IsNullOrEmpty(_qobuzSettings.UserAuthToken),
|
||||
quality = _qobuzSettings.Quality ?? "FLAC"
|
||||
},
|
||||
squidWtf = new
|
||||
{
|
||||
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a random SquidWTF base URL for searching (round-robin)
|
||||
/// </summary>
|
||||
[HttpGet("squidwtf-base-url")]
|
||||
public IActionResult GetSquidWtfBaseUrl()
|
||||
{
|
||||
if (_squidWtfApiUrls.Count == 0)
|
||||
{
|
||||
return NotFound(new { error = "No SquidWTF base URLs configured" });
|
||||
}
|
||||
|
||||
string baseUrl;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
baseUrl = _squidWtfApiUrls[_urlIndex];
|
||||
_urlIndex = (_urlIndex + 1) % _squidWtfApiUrls.Count;
|
||||
}
|
||||
|
||||
return Ok(new { baseUrl });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current configuration including cache settings
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Get list of configured playlists with their current data
|
||||
/// </summary>
|
||||
[HttpGet("memory-stats")]
|
||||
public IActionResult GetMemoryStats()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get memory stats BEFORE GC
|
||||
var memoryBeforeGC = GC.GetTotalMemory(false);
|
||||
var gen0Before = GC.CollectionCount(0);
|
||||
var gen1Before = GC.CollectionCount(1);
|
||||
var gen2Before = GC.CollectionCount(2);
|
||||
|
||||
// Force garbage collection to get accurate numbers
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
var memoryAfterGC = GC.GetTotalMemory(false);
|
||||
var gen0After = GC.CollectionCount(0);
|
||||
var gen1After = GC.CollectionCount(1);
|
||||
var gen2After = GC.CollectionCount(2);
|
||||
|
||||
// Get process memory info
|
||||
var process = System.Diagnostics.Process.GetCurrentProcess();
|
||||
|
||||
return Ok(new {
|
||||
Timestamp = DateTime.UtcNow,
|
||||
BeforeGC = new {
|
||||
GCMemoryBytes = memoryBeforeGC,
|
||||
GCMemoryMB = Math.Round(memoryBeforeGC / (1024.0 * 1024.0), 2)
|
||||
},
|
||||
AfterGC = new {
|
||||
GCMemoryBytes = memoryAfterGC,
|
||||
GCMemoryMB = Math.Round(memoryAfterGC / (1024.0 * 1024.0), 2)
|
||||
},
|
||||
MemoryFreedMB = Math.Round((memoryBeforeGC - memoryAfterGC) / (1024.0 * 1024.0), 2),
|
||||
ProcessWorkingSetBytes = process.WorkingSet64,
|
||||
ProcessWorkingSetMB = Math.Round(process.WorkingSet64 / (1024.0 * 1024.0), 2),
|
||||
ProcessPrivateMemoryBytes = process.PrivateMemorySize64,
|
||||
ProcessPrivateMemoryMB = Math.Round(process.PrivateMemorySize64 / (1024.0 * 1024.0), 2),
|
||||
ProcessVirtualMemoryBytes = process.VirtualMemorySize64,
|
||||
ProcessVirtualMemoryMB = Math.Round(process.VirtualMemorySize64 / (1024.0 * 1024.0), 2),
|
||||
GCCollections = new {
|
||||
Gen0Before = gen0Before,
|
||||
Gen0After = gen0After,
|
||||
Gen0Triggered = gen0After - gen0Before,
|
||||
Gen1Before = gen1Before,
|
||||
Gen1After = gen1After,
|
||||
Gen1Triggered = gen1After - gen1Before,
|
||||
Gen2Before = gen2Before,
|
||||
Gen2After = gen2After,
|
||||
Gen2Triggered = gen2After - gen2Before
|
||||
},
|
||||
GCMode = GCSettings.IsServerGC ? "Server" : "Workstation",
|
||||
GCLatencyMode = GCSettings.LatencyMode.ToString()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces garbage collection to free up memory (emergency use only).
|
||||
/// </summary>
|
||||
[HttpPost("force-gc")]
|
||||
public IActionResult ForceGarbageCollection()
|
||||
{
|
||||
try
|
||||
{
|
||||
var memoryBefore = GC.GetTotalMemory(false);
|
||||
var processBefore = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
|
||||
|
||||
// Force full garbage collection
|
||||
GC.Collect(2, GCCollectionMode.Forced);
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect(2, GCCollectionMode.Forced);
|
||||
|
||||
var memoryAfter = GC.GetTotalMemory(false);
|
||||
var processAfter = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
|
||||
|
||||
return Ok(new {
|
||||
Timestamp = DateTime.UtcNow,
|
||||
MemoryFreedMB = Math.Round((memoryBefore - memoryAfter) / (1024.0 * 1024.0), 2),
|
||||
ProcessMemoryFreedMB = Math.Round((processBefore - processAfter) / (1024.0 * 1024.0), 2),
|
||||
BeforeGCMB = Math.Round(memoryBefore / (1024.0 * 1024.0), 2),
|
||||
AfterGCMB = Math.Round(memoryAfter / (1024.0 * 1024.0), 2),
|
||||
BeforeProcessMB = Math.Round(processBefore / (1024.0 * 1024.0), 2),
|
||||
AfterProcessMB = Math.Round(processAfter / (1024.0 * 1024.0), 2)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets current active sessions for debugging.
|
||||
/// </summary>
|
||||
[HttpGet("sessions")]
|
||||
public IActionResult GetActiveSessions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sessionManager = HttpContext.RequestServices.GetService<JellyfinSessionManager>();
|
||||
if (sessionManager == null)
|
||||
{
|
||||
return BadRequest(new { error = "Session manager not available" });
|
||||
}
|
||||
|
||||
var sessionInfo = sessionManager.GetSessionsInfo();
|
||||
return Ok(sessionInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to trigger GC after large file operations to prevent memory leaks.
|
||||
/// </summary>
|
||||
[HttpGet("debug/endpoint-usage")]
|
||||
public async Task<IActionResult> GetEndpointUsage(
|
||||
[FromQuery] int top = 100,
|
||||
[FromQuery] string? since = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||
|
||||
if (!System.IO.File.Exists(logFile))
|
||||
{
|
||||
return Ok(new {
|
||||
message = "No endpoint usage data available",
|
||||
endpoints = new object[0]
|
||||
});
|
||||
}
|
||||
|
||||
var lines = await System.IO.File.ReadAllLinesAsync(logFile);
|
||||
var usage = new Dictionary<string, int>();
|
||||
DateTime? sinceDate = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate))
|
||||
{
|
||||
sinceDate = parsedDate;
|
||||
}
|
||||
|
||||
foreach (var line in lines.Skip(1)) // Skip header
|
||||
{
|
||||
var parts = line.Split(',');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
var timestamp = parts[0];
|
||||
var method = parts[1];
|
||||
var endpoint = parts[2];
|
||||
|
||||
// Combine method and endpoint for better clarity
|
||||
var fullEndpoint = $"{method} {endpoint}";
|
||||
|
||||
// Filter by date if specified
|
||||
if (sinceDate.HasValue && DateTime.TryParse(timestamp, out var logDate))
|
||||
{
|
||||
if (logDate < sinceDate.Value)
|
||||
continue;
|
||||
}
|
||||
|
||||
usage[fullEndpoint] = usage.GetValueOrDefault(fullEndpoint, 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
var topEndpoints = usage
|
||||
.OrderByDescending(kv => kv.Value)
|
||||
.Take(top)
|
||||
.Select(kv => new { endpoint = kv.Key, count = kv.Value })
|
||||
.ToArray();
|
||||
|
||||
return Ok(new {
|
||||
totalEndpoints = usage.Count,
|
||||
totalRequests = usage.Values.Sum(),
|
||||
since = since,
|
||||
top = top,
|
||||
endpoints = topEndpoints
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting endpoint usage");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the endpoint usage log file.
|
||||
/// </summary>
|
||||
[HttpDelete("debug/endpoint-usage")]
|
||||
public IActionResult ClearEndpointUsage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||
|
||||
if (System.IO.File.Exists(logFile))
|
||||
{
|
||||
System.IO.File.Delete(logFile);
|
||||
_logger.LogDebug("Cleared endpoint usage log via admin endpoint");
|
||||
|
||||
return Ok(new {
|
||||
message = "Endpoint usage log cleared successfully",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
return Ok(new {
|
||||
message = "No endpoint usage log file found",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error clearing endpoint usage log");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Saves a manual mapping to file for persistence across restarts.
|
||||
/// Manual mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using allstarr.Filters;
|
||||
using allstarr.Services.Admin;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class DownloadsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<DownloadsController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public DownloadsController(
|
||||
ILogger<DownloadsController> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
[HttpGet("downloads")]
|
||||
public IActionResult GetDownloads()
|
||||
{
|
||||
try
|
||||
{
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
|
||||
_logger.LogDebug("📂 Checking kept folder: {Path}", keptPath);
|
||||
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
|
||||
|
||||
if (!Directory.Exists(keptPath))
|
||||
{
|
||||
_logger.LogWarning("Kept folder does not exist: {Path}", keptPath);
|
||||
return Ok(new { files = new List<object>(), totalSize = 0, count = 0 });
|
||||
}
|
||||
|
||||
var files = new List<object>();
|
||||
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()))
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug("📂 Found {Count} audio files in kept folder", allFiles.Count);
|
||||
|
||||
foreach (var filePath in allFiles)
|
||||
{
|
||||
_logger.LogDebug("📂 Processing file: {Path}", filePath);
|
||||
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
var relativePath = Path.GetRelativePath(keptPath, filePath);
|
||||
|
||||
// Parse artist/album/track from path structure
|
||||
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var artist = parts.Length > 0 ? parts[0] : "";
|
||||
var album = parts.Length > 1 ? parts[1] : "";
|
||||
var fileName = parts.Length > 2 ? parts[^1] : Path.GetFileName(filePath);
|
||||
|
||||
files.Add(new
|
||||
{
|
||||
path = relativePath,
|
||||
fullPath = filePath,
|
||||
artist,
|
||||
album,
|
||||
fileName,
|
||||
size = fileInfo.Length,
|
||||
sizeFormatted = AdminHelperService.FormatFileSize(fileInfo.Length),
|
||||
lastModified = fileInfo.LastWriteTimeUtc,
|
||||
extension = fileInfo.Extension
|
||||
});
|
||||
|
||||
totalSize += fileInfo.Length;
|
||||
}
|
||||
|
||||
_logger.LogDebug("📂 Returning {Count} kept files, total size: {Size}", files.Count, AdminHelperService.FormatFileSize(totalSize));
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName),
|
||||
totalSize,
|
||||
totalSizeFormatted = AdminHelperService.FormatFileSize(totalSize),
|
||||
count = files.Count
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to list kept downloads");
|
||||
return StatusCode(500, new { error = "Failed to list kept downloads" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE /api/admin/downloads
|
||||
/// Deletes a specific kept file and cleans up empty folders
|
||||
/// </summary>
|
||||
[HttpDelete("downloads")]
|
||||
public IActionResult DeleteDownload([FromQuery] string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return BadRequest(new { error = "Path is required" });
|
||||
}
|
||||
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var fullPath = Path.Combine(keptPath, path);
|
||||
|
||||
_logger.LogDebug("🗑️ Delete request for: {Path}", fullPath);
|
||||
|
||||
// Security: Ensure the path is within the kept directory
|
||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||
var normalizedKeptPath = Path.GetFullPath(keptPath);
|
||||
|
||||
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
|
||||
{
|
||||
_logger.LogWarning("🗑️ Invalid path (outside kept folder): {Path}", normalizedFullPath);
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogWarning("🗑️ File not found: {Path}", fullPath);
|
||||
return NotFound(new { error = "File not found" });
|
||||
}
|
||||
|
||||
System.IO.File.Delete(fullPath);
|
||||
_logger.LogDebug("🗑️ Deleted file: {Path}", fullPath);
|
||||
|
||||
// Clean up empty directories (Album folder, then Artist folder if empty)
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
while (directory != null && directory != keptPath && directory.StartsWith(keptPath))
|
||||
{
|
||||
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
_logger.LogInformation("🗑️ Deleted empty directory: {Dir}", directory);
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("🗑️ Directory not empty or doesn't exist, stopping cleanup: {Dir}", directory);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "File deleted successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete file: {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to delete file" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/downloads/file
|
||||
/// Downloads a specific file from the kept folder
|
||||
/// </summary>
|
||||
[HttpGet("downloads/file")]
|
||||
public IActionResult DownloadFile([FromQuery] string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return BadRequest(new { error = "Path is required" });
|
||||
}
|
||||
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var fullPath = Path.Combine(keptPath, path);
|
||||
|
||||
// Security: Ensure the path is within the kept directory
|
||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||
var normalizedKeptPath = Path.GetFullPath(keptPath);
|
||||
|
||||
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
{
|
||||
return NotFound(new { error = "File not found" });
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(fullPath);
|
||||
var fileStream = System.IO.File.OpenRead(fullPath);
|
||||
|
||||
return File(fileStream, "application/octet-stream", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to download file: {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to download file" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all Spotify track mappings (paginated)
|
||||
/// </summary>
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Admin;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Filters;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class JellyfinAdminController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<JellyfinAdminController> _logger;
|
||||
private readonly JellyfinSettings _jellyfinSettings;
|
||||
private readonly HttpClient _jellyfinHttpClient;
|
||||
private readonly AdminHelperService _helperService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
|
||||
public JellyfinAdminController(
|
||||
ILogger<JellyfinAdminController> logger,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AdminHelperService helperService,
|
||||
RedisCacheService cache,
|
||||
IConfiguration configuration,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings)
|
||||
{
|
||||
_logger = logger;
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
||||
_helperService = helperService;
|
||||
_cache = cache;
|
||||
_configuration = configuration;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
}
|
||||
|
||||
[HttpGet("jellyfin/users")]
|
||||
public async Task<IActionResult> GetJellyfinUsers()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Users";
|
||||
|
||||
var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to fetch Jellyfin users: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch users from Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var users = new List<object>();
|
||||
|
||||
foreach (var user in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var id = user.GetProperty("Id").GetString();
|
||||
var name = user.GetProperty("Name").GetString();
|
||||
|
||||
users.Add(new { id, name });
|
||||
}
|
||||
|
||||
return Ok(new { users });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Jellyfin users");
|
||||
return StatusCode(500, new { error = "Failed to fetch users", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all Jellyfin libraries (virtual folders)
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/libraries")]
|
||||
public async Task<IActionResult> GetJellyfinLibraries()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Library/VirtualFolders";
|
||||
|
||||
var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to fetch Jellyfin libraries: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch libraries from Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var libraries = new List<object>();
|
||||
|
||||
foreach (var lib in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var name = lib.GetProperty("Name").GetString();
|
||||
var itemId = lib.TryGetProperty("ItemId", out var id) ? id.GetString() : null;
|
||||
var collectionType = lib.TryGetProperty("CollectionType", out var ct) ? ct.GetString() : null;
|
||||
|
||||
libraries.Add(new { id = itemId, name, collectionType });
|
||||
}
|
||||
|
||||
return Ok(new { libraries });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Jellyfin libraries");
|
||||
return StatusCode(500, new { error = "Failed to fetch libraries", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all playlists from the user's Spotify account
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/playlists")]
|
||||
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Build URL with optional userId filter
|
||||
var url = $"{_jellyfinSettings.Url}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ProviderIds,ChildCount,RecursiveItemCount,SongCount";
|
||||
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
url += $"&UserId={userId}";
|
||||
}
|
||||
|
||||
var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to fetch Jellyfin playlists: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch playlists from Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var playlists = new List<object>();
|
||||
|
||||
// Read current playlists from .env file for accurate linked status
|
||||
var configuredPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync();
|
||||
|
||||
if (doc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var id = item.GetProperty("Id").GetString();
|
||||
var name = item.GetProperty("Name").GetString();
|
||||
|
||||
// Try multiple fields for track count - Jellyfin may use different fields
|
||||
var childCount = 0;
|
||||
if (item.TryGetProperty("ChildCount", out var cc) && cc.ValueKind == JsonValueKind.Number)
|
||||
childCount = cc.GetInt32();
|
||||
else if (item.TryGetProperty("SongCount", out var sc) && sc.ValueKind == JsonValueKind.Number)
|
||||
childCount = sc.GetInt32();
|
||||
else if (item.TryGetProperty("RecursiveItemCount", out var ric) && ric.ValueKind == JsonValueKind.Number)
|
||||
childCount = ric.GetInt32();
|
||||
|
||||
// Check if this playlist is configured in allstarr by Jellyfin ID
|
||||
var configuredPlaylist = configuredPlaylists
|
||||
.FirstOrDefault(p => p.JellyfinId.Equals(id, StringComparison.OrdinalIgnoreCase));
|
||||
var isConfigured = configuredPlaylist != null;
|
||||
var linkedSpotifyId = configuredPlaylist?.Id;
|
||||
|
||||
// Only fetch detailed track stats for configured Spotify playlists
|
||||
// This avoids expensive queries for large non-Spotify playlists
|
||||
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
||||
if (isConfigured)
|
||||
{
|
||||
trackStats = await GetPlaylistTrackStats(id!);
|
||||
}
|
||||
|
||||
// Use actual track stats for configured playlists, otherwise use Jellyfin's count
|
||||
var actualTrackCount = isConfigured
|
||||
? trackStats.LocalTracks + trackStats.ExternalTracks
|
||||
: childCount;
|
||||
|
||||
playlists.Add(new
|
||||
{
|
||||
id,
|
||||
name,
|
||||
trackCount = actualTrackCount,
|
||||
linkedSpotifyId,
|
||||
isConfigured,
|
||||
localTracks = trackStats.LocalTracks,
|
||||
externalTracks = trackStats.ExternalTracks,
|
||||
externalAvailable = trackStats.ExternalAvailable
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { playlists });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Jellyfin playlists");
|
||||
return StatusCode(500, new { error = "Failed to fetch playlists", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get track statistics for a playlist (local vs external)
|
||||
/// </summary>
|
||||
private async Task<(int LocalTracks, int ExternalTracks, int ExternalAvailable)> GetPlaylistTrackStats(string playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Jellyfin requires a UserId to fetch playlist items
|
||||
// We'll use the first available user if not specified
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
// If no user configured, try to get the first user
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
var usersRequest = _helperService.CreateJellyfinRequest(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users");
|
||||
var usersResponse = await _jellyfinHttpClient.SendAsync(usersRequest);
|
||||
|
||||
if (usersResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var usersJson = await usersResponse.Content.ReadAsStringAsync();
|
||||
using var usersDoc = JsonDocument.Parse(usersJson);
|
||||
if (usersDoc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
userId = usersDoc.RootElement[0].GetProperty("Id").GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("No user ID available to fetch playlist items for {PlaylistId}", playlistId);
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistId}/Items?UserId={userId}&Fields=Path";
|
||||
var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode);
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var localTracks = 0;
|
||||
var externalTracks = 0;
|
||||
var externalAvailable = 0;
|
||||
|
||||
if (doc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
// Simpler detection: Check if Path exists and is not empty
|
||||
// External tracks from allstarr won't have a Path property
|
||||
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
|
||||
pathProp.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrEmpty(pathProp.GetString());
|
||||
|
||||
if (hasPath)
|
||||
{
|
||||
var pathStr = pathProp.GetString()!;
|
||||
// Check if it's a real file path (not a URL)
|
||||
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
|
||||
{
|
||||
localTracks++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// It's a URL or external source
|
||||
externalTracks++;
|
||||
externalAvailable++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No path means it's external
|
||||
externalTracks++;
|
||||
externalAvailable++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Playlist {PlaylistId} stats: {Local} local, {External} external",
|
||||
playlistId, localTracks, externalTracks);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No Items property in playlist response for {PlaylistId}", playlistId);
|
||||
}
|
||||
|
||||
return (localTracks, externalTracks, externalAvailable);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get track stats for playlist {PlaylistId}", playlistId);
|
||||
return (0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Link a Jellyfin playlist to a Spotify playlist
|
||||
/// </summary>
|
||||
[HttpPost("jellyfin/playlists/{jellyfinPlaylistId}/link")]
|
||||
public async Task<IActionResult> LinkPlaylist(string jellyfinPlaylistId, [FromBody] LinkPlaylistRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.SpotifyPlaylistId))
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyPlaylistId is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(request.Name))
|
||||
{
|
||||
return BadRequest(new { error = "Name is required" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Linking Jellyfin playlist {JellyfinId} to Spotify playlist {SpotifyId} with name {Name}",
|
||||
jellyfinPlaylistId, request.SpotifyPlaylistId, request.Name);
|
||||
|
||||
// Read current playlists from .env file (not in-memory config which is stale)
|
||||
var currentPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync();
|
||||
|
||||
// Check if already configured by Jellyfin ID
|
||||
var existingByJellyfinId = currentPlaylists
|
||||
.FirstOrDefault(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingByJellyfinId != null)
|
||||
{
|
||||
return BadRequest(new { error = $"This Jellyfin playlist is already linked to '{existingByJellyfinId.Name}'" });
|
||||
}
|
||||
|
||||
// Check if already configured by name
|
||||
var existingByName = currentPlaylists
|
||||
.FirstOrDefault(p => p.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingByName != null)
|
||||
{
|
||||
return BadRequest(new { error = $"Playlist name '{request.Name}' is already configured" });
|
||||
}
|
||||
|
||||
// Add the playlist to configuration
|
||||
currentPlaylists.Add(new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = request.Name,
|
||||
Id = request.SpotifyPlaylistId,
|
||||
JellyfinId = jellyfinPlaylistId,
|
||||
LocalTracksPosition = LocalTracksPosition.First, // Use Spotify order
|
||||
SyncSchedule = request.SyncSchedule ?? "0 8 * * *" // Default to daily 8 AM
|
||||
});
|
||||
|
||||
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] {
|
||||
p.Name,
|
||||
p.Id,
|
||||
p.JellyfinId,
|
||||
p.LocalTracksPosition.ToString().ToLower(),
|
||||
p.SyncSchedule ?? "0 8 * * *"
|
||||
}).ToArray()
|
||||
);
|
||||
|
||||
// Update .env file
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await _helperService.UpdateEnvConfigAsync(updateRequest.Updates);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unlink a playlist (remove from configuration)
|
||||
/// </summary>
|
||||
[HttpDelete("jellyfin/playlists/{name}/unlink")]
|
||||
public async Task<IActionResult> UnlinkPlaylist(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
return await _helperService.RemovePlaylistFromConfigAsync(decodedName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update playlist sync schedule
|
||||
/// </summary>
|
||||
[HttpPut("playlists/{name}/schedule")]
|
||||
public async Task<IActionResult> UpdatePlaylistSchedule(string name, [FromBody] UpdateScheduleRequest request)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SyncSchedule))
|
||||
{
|
||||
return BadRequest(new { error = "SyncSchedule is required" });
|
||||
}
|
||||
|
||||
// Basic cron validation
|
||||
var cronParts = request.SyncSchedule.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (cronParts.Length != 5)
|
||||
{
|
||||
return BadRequest(new { error = "Invalid cron format. Expected: minute hour day month dayofweek" });
|
||||
}
|
||||
|
||||
// Read current playlists
|
||||
var currentPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync();
|
||||
var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
return NotFound(new { error = $"Playlist '{decodedName}' not found" });
|
||||
}
|
||||
|
||||
// Update the schedule
|
||||
playlist.SyncSchedule = request.SyncSchedule.Trim();
|
||||
|
||||
// Save back to .env
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] {
|
||||
p.Name,
|
||||
p.Id,
|
||||
p.JellyfinId,
|
||||
p.LocalTracksPosition.ToString().ToLower(),
|
||||
p.SyncSchedule ?? "0 8 * * *"
|
||||
}).ToArray()
|
||||
);
|
||||
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await _helperService.UpdateEnvConfigAsync(updateRequest.Updates);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Subsonic;
|
||||
using allstarr.Services.Lyrics;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Filters;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
@@ -39,7 +40,9 @@ public class JellyfinController : ControllerBase
|
||||
private readonly PlaylistSyncService? _playlistSyncService;
|
||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||
private readonly LyricsPlusService? _lyricsPlusService;
|
||||
private readonly LrclibService? _lrclibService;
|
||||
private readonly LyricsOrchestrator? _lyricsOrchestrator;
|
||||
private readonly OdesliService _odesliService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly IConfiguration _configuration;
|
||||
@@ -64,7 +67,9 @@ public class JellyfinController : ControllerBase
|
||||
PlaylistSyncService? playlistSyncService = null,
|
||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||
SpotifyLyricsService? spotifyLyricsService = null,
|
||||
LrclibService? lrclibService = null)
|
||||
LyricsPlusService? lyricsPlusService = null,
|
||||
LrclibService? lrclibService = null,
|
||||
LyricsOrchestrator? lyricsOrchestrator = null)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
@@ -80,7 +85,9 @@ public class JellyfinController : ControllerBase
|
||||
_playlistSyncService = playlistSyncService;
|
||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||
_spotifyLyricsService = spotifyLyricsService;
|
||||
_lyricsPlusService = lyricsPlusService;
|
||||
_lrclibService = lrclibService;
|
||||
_lyricsOrchestrator = lyricsOrchestrator;
|
||||
_odesliService = odesliService;
|
||||
_cache = cache;
|
||||
_configuration = configuration;
|
||||
@@ -111,14 +118,14 @@ public class JellyfinController : ControllerBase
|
||||
[FromQuery] bool recursive = true,
|
||||
string? userId = null)
|
||||
{
|
||||
_logger.LogInformation("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
|
||||
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
|
||||
searchTerm, includeItemTypes, parentId, artistIds, userId);
|
||||
|
||||
// Cache search results in Redis only (no file persistence, 15 min TTL)
|
||||
// Only cache actual searches, not browse operations
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
|
||||
{
|
||||
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
|
||||
var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex);
|
||||
var cachedResult = await _cache.GetAsync<object>(cacheKey);
|
||||
|
||||
if (cachedResult != null)
|
||||
@@ -210,14 +217,14 @@ public class JellyfinController : ControllerBase
|
||||
return Unauthorized(new { error = "Authentication required" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Jellyfin returned {StatusCode}, returning empty result", statusCode);
|
||||
_logger.LogDebug("Jellyfin returned {StatusCode}, returning empty result", statusCode);
|
||||
return new JsonResult(new { Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
|
||||
}
|
||||
|
||||
// Update Spotify playlist counts if enabled and response contains playlists
|
||||
if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _))
|
||||
{
|
||||
_logger.LogInformation("Browse result has Items, checking for Spotify playlists to update counts");
|
||||
_logger.LogDebug("Browse result has Items, checking for Spotify playlists to update counts");
|
||||
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
|
||||
}
|
||||
|
||||
@@ -248,7 +255,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
var cleanQuery = searchTerm?.Trim().Trim('"') ?? "";
|
||||
_logger.LogInformation("Performing integrated search for: {Query}", cleanQuery);
|
||||
_logger.LogDebug("Performing integrated search for: {Query}", cleanQuery);
|
||||
|
||||
// Run local and external searches in parallel
|
||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||
@@ -269,7 +276,7 @@ public class JellyfinController : ControllerBase
|
||||
var externalResult = await externalTask;
|
||||
var playlistResult = await playlistTask;
|
||||
|
||||
_logger.LogInformation("Search results: Jellyfin={JellyfinCount}, External Songs={ExtSongs}, Albums={ExtAlbums}, Artists={ExtArtists}, Playlists={Playlists}",
|
||||
_logger.LogDebug("Search results: Jellyfin={JellyfinCount}, External Songs={ExtSongs}, Albums={ExtAlbums}, Artists={ExtArtists}, Playlists={Playlists}",
|
||||
jellyfinResult != null ? "found" : "null",
|
||||
externalResult.Songs.Count,
|
||||
externalResult.Albums.Count,
|
||||
@@ -279,53 +286,50 @@ public class JellyfinController : ControllerBase
|
||||
// Parse Jellyfin results into domain models
|
||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||
|
||||
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
|
||||
// Just interleave local and external results based on which source has better overall match
|
||||
// Sort all results by match score (local tracks get +10 boost)
|
||||
// This ensures best matches appear first regardless of source
|
||||
var allSongs = localSongs.Concat(externalResult.Songs)
|
||||
.Select(s => new { Song = s, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + (s.IsLocal ? 10.0 : 0.0) })
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Song)
|
||||
.ToList();
|
||||
|
||||
// Calculate average match score for each source to determine which should come first
|
||||
var localSongsAvgScore = localSongs.Any()
|
||||
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||
: 0.0;
|
||||
var externalSongsAvgScore = externalResult.Songs.Any()
|
||||
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||
: 0.0;
|
||||
var allAlbums = localAlbums.Concat(externalResult.Albums)
|
||||
.Select(a => new { Album = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + (a.IsLocal ? 10.0 : 0.0) })
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Album)
|
||||
.ToList();
|
||||
|
||||
var localAlbumsAvgScore = localAlbums.Any()
|
||||
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||
: 0.0;
|
||||
var externalAlbumsAvgScore = externalResult.Albums.Any()
|
||||
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||
: 0.0;
|
||||
var allArtists = localArtists.Concat(externalResult.Artists)
|
||||
.Select(a => new { Artist = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + (a.IsLocal ? 10.0 : 0.0) })
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Artist)
|
||||
.ToList();
|
||||
|
||||
var localArtistsAvgScore = localArtists.Any()
|
||||
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||
: 0.0;
|
||||
var externalArtistsAvgScore = externalResult.Artists.Any()
|
||||
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||
: 0.0;
|
||||
|
||||
// Interleave results: put better-matching source first, preserve original ordering within each source
|
||||
var allSongs = localSongsAvgScore >= externalSongsAvgScore
|
||||
? localSongs.Concat(externalResult.Songs).ToList()
|
||||
: externalResult.Songs.Concat(localSongs).ToList();
|
||||
|
||||
var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
|
||||
? localAlbums.Concat(externalResult.Albums).ToList()
|
||||
: externalResult.Albums.Concat(localAlbums).ToList();
|
||||
|
||||
var allArtists = localArtistsAvgScore >= externalArtistsAvgScore
|
||||
? localArtists.Concat(externalResult.Artists).ToList()
|
||||
: externalResult.Artists.Concat(localArtists).ToList();
|
||||
|
||||
// Log results for debugging
|
||||
// Log top results for debugging
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
|
||||
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
|
||||
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
|
||||
if (allSongs.Any())
|
||||
{
|
||||
var topSong = allSongs.First();
|
||||
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topSong.Title) + (topSong.IsLocal ? 10.0 : 0.0);
|
||||
_logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal}, score={Score:F2})",
|
||||
topSong.Title, topSong.IsLocal, topScore);
|
||||
}
|
||||
if (allAlbums.Any())
|
||||
{
|
||||
var topAlbum = allAlbums.First();
|
||||
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topAlbum.Title) + (topAlbum.IsLocal ? 10.0 : 0.0);
|
||||
_logger.LogDebug("💿 Top album: '{Title}' (local={IsLocal}, score={Score:F2})",
|
||||
topAlbum.Title, topAlbum.IsLocal, topScore);
|
||||
}
|
||||
if (allArtists.Any())
|
||||
{
|
||||
var topArtist = allArtists.First();
|
||||
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topArtist.Name) + (topArtist.IsLocal ? 10.0 : 0.0);
|
||||
_logger.LogDebug("🎤 Top artist: '{Name}' (local={IsLocal}, score={Score:F2})",
|
||||
topArtist.Name, topArtist.IsLocal, topScore);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Jellyfin format
|
||||
@@ -343,7 +347,7 @@ public class JellyfinController : ControllerBase
|
||||
mergedAlbums.AddRange(playlistItems);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Merged results (preserving source order): Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||
_logger.LogDebug("Merged and sorted results by score: Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
||||
|
||||
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
||||
@@ -374,7 +378,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to pre-fetch lyrics for search results");
|
||||
_logger.LogError(ex, "Failed to pre-fetch lyrics for search results");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -382,28 +386,28 @@ public class JellyfinController : ControllerBase
|
||||
// Filter by item types if specified
|
||||
var items = new List<Dictionary<string, object?>>();
|
||||
|
||||
_logger.LogInformation("Filtering by item types: {ItemTypes}", itemTypes == null ? "null" : string.Join(",", itemTypes));
|
||||
_logger.LogDebug("Filtering by item types: {ItemTypes}", itemTypes == null ? "null" : string.Join(",", itemTypes));
|
||||
|
||||
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicArtist"))
|
||||
{
|
||||
_logger.LogInformation("Adding {Count} artists to results", mergedArtists.Count);
|
||||
_logger.LogDebug("Adding {Count} artists to results", mergedArtists.Count);
|
||||
items.AddRange(mergedArtists);
|
||||
}
|
||||
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicAlbum") || itemTypes.Contains("Playlist"))
|
||||
{
|
||||
_logger.LogInformation("Adding {Count} albums to results", mergedAlbums.Count);
|
||||
_logger.LogDebug("Adding {Count} albums to results", mergedAlbums.Count);
|
||||
items.AddRange(mergedAlbums);
|
||||
}
|
||||
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio"))
|
||||
{
|
||||
_logger.LogInformation("Adding {Count} songs to results", mergedSongs.Count);
|
||||
_logger.LogDebug("Adding {Count} songs to results", mergedSongs.Count);
|
||||
items.AddRange(mergedSongs);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
var pagedItems = items.Skip(startIndex).Take(limit).ToList();
|
||||
|
||||
_logger.LogInformation("Returning {Count} items (total: {Total})", pagedItems.Count, items.Count);
|
||||
_logger.LogDebug("Returning {Count} items (total: {Total})", pagedItems.Count, items.Count);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -418,12 +422,12 @@ public class JellyfinController : ControllerBase
|
||||
// Cache search results in Redis (15 min TTL, no file persistence)
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
|
||||
{
|
||||
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
|
||||
await _cache.SetAsync(cacheKey, response, TimeSpan.FromMinutes(15));
|
||||
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' (15 min TTL)", searchTerm);
|
||||
var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex);
|
||||
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
|
||||
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm, CacheExtensions.SearchResultsTTL.TotalMinutes);
|
||||
}
|
||||
|
||||
_logger.LogInformation("About to serialize response...");
|
||||
_logger.LogDebug("About to serialize response...");
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
@@ -612,7 +616,7 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||
|
||||
_logger.LogInformation("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}",
|
||||
_logger.LogDebug("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}",
|
||||
provider, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
||||
|
||||
// Check if asking for audio (album tracks)
|
||||
@@ -633,7 +637,7 @@ public class JellyfinController : ControllerBase
|
||||
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
|
||||
var artist = await _metadataService.GetArtistAsync(provider, externalId);
|
||||
|
||||
_logger.LogInformation("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
|
||||
_logger.LogDebug("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
|
||||
|
||||
// Fill artist info
|
||||
if (artist != null)
|
||||
@@ -664,13 +668,13 @@ public class JellyfinController : ControllerBase
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] int startIndex = 0)
|
||||
{
|
||||
_logger.LogInformation("GetArtists called: searchTerm={SearchTerm}, limit={Limit}", searchTerm, limit);
|
||||
_logger.LogDebug("GetArtists called: searchTerm={SearchTerm}, limit={Limit}", searchTerm, limit);
|
||||
|
||||
// If there's a search term, integrate external results
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var cleanQuery = searchTerm.Trim().Trim('"');
|
||||
_logger.LogInformation("Searching artists for: {Query}", cleanQuery);
|
||||
_logger.LogDebug("Searching artists for: {Query}", cleanQuery);
|
||||
|
||||
// Run local and external searches in parallel
|
||||
var jellyfinTask = _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers);
|
||||
@@ -681,7 +685,7 @@ public class JellyfinController : ControllerBase
|
||||
var (jellyfinResult, _) = await jellyfinTask;
|
||||
var externalArtists = await externalTask;
|
||||
|
||||
_logger.LogInformation("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}",
|
||||
_logger.LogDebug("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}",
|
||||
jellyfinResult != null ? "found" : "null", externalArtists.Count);
|
||||
|
||||
// Parse Jellyfin artists
|
||||
@@ -698,7 +702,7 @@ public class JellyfinController : ControllerBase
|
||||
// Show ALL matches (local + external) sorted by best match first
|
||||
var mergedArtists = localArtists.Concat(externalArtists).ToList();
|
||||
|
||||
_logger.LogInformation("Returning {Count} total artists (local + external, no deduplication)", mergedArtists.Count);
|
||||
_logger.LogDebug("Returning {Count} total artists (local + external, no deduplication)", mergedArtists.Count);
|
||||
|
||||
// Convert to Jellyfin format
|
||||
var artistItems = mergedArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
||||
@@ -883,14 +887,7 @@ public class JellyfinController : ControllerBase
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, jellyfinUrl);
|
||||
|
||||
// Forward auth headers
|
||||
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
|
||||
}
|
||||
else if (Request.Headers.TryGetValue("Authorization", out var auth))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
|
||||
}
|
||||
AuthHeaderHelper.ForwardAuthHeaders(Request.Headers, request);
|
||||
|
||||
// Forward Range header for seeking
|
||||
if (Request.Headers.TryGetValue("Range", out var range))
|
||||
@@ -963,14 +960,14 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (localPath != null && System.IO.File.Exists(localPath))
|
||||
{
|
||||
// Update last access time for cache cleanup
|
||||
// Update last write time for cache cleanup (extends cache lifetime)
|
||||
try
|
||||
{
|
||||
System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow);
|
||||
System.IO.File.SetLastWriteTimeUtc(localPath, DateTime.UtcNow);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to update last access time for {Path}", localPath);
|
||||
_logger.LogError(ex, "Failed to update last write time for {Path}", localPath);
|
||||
}
|
||||
|
||||
var stream = System.IO.File.OpenRead(localPath);
|
||||
@@ -1103,7 +1100,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch cover art from {Url}", coverUrl);
|
||||
_logger.LogError(ex, "Failed to fetch cover art from {Url}", coverUrl);
|
||||
// Return placeholder on exception
|
||||
return await GetPlaceholderImageAsync();
|
||||
}
|
||||
@@ -1144,7 +1141,7 @@ public class JellyfinController : ControllerBase
|
||||
[HttpGet("Items/{itemId}/Lyrics")]
|
||||
public async Task<IActionResult> GetLyrics(string itemId)
|
||||
{
|
||||
_logger.LogInformation("🎵 GetLyrics called for itemId: {ItemId}", itemId);
|
||||
_logger.LogDebug("🎵 GetLyrics called for itemId: {ItemId}", itemId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
@@ -1153,18 +1150,18 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||
|
||||
_logger.LogInformation("🎵 Lyrics request: itemId={ItemId}, isExternal={IsExternal}, provider={Provider}, externalId={ExternalId}",
|
||||
_logger.LogDebug("🎵 Lyrics request: itemId={ItemId}, isExternal={IsExternal}, provider={Provider}, externalId={ExternalId}",
|
||||
itemId, isExternal, provider, externalId);
|
||||
|
||||
// For local tracks, check if Jellyfin already has embedded lyrics
|
||||
if (!isExternal)
|
||||
{
|
||||
_logger.LogInformation("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId);
|
||||
_logger.LogDebug("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId);
|
||||
|
||||
// Try to get lyrics from Jellyfin first (it reads embedded lyrics from files)
|
||||
var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers);
|
||||
|
||||
_logger.LogInformation("Jellyfin lyrics check result: statusCode={StatusCode}, hasLyrics={HasLyrics}",
|
||||
_logger.LogDebug("Jellyfin lyrics check result: statusCode={StatusCode}, hasLyrics={HasLyrics}",
|
||||
statusCode, jellyfinLyrics != null);
|
||||
|
||||
if (jellyfinLyrics != null && statusCode == 200)
|
||||
@@ -1173,7 +1170,7 @@ public class JellyfinController : ControllerBase
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
|
||||
}
|
||||
|
||||
_logger.LogInformation("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode);
|
||||
_logger.LogWarning("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode);
|
||||
}
|
||||
|
||||
// Get song metadata for lyrics search
|
||||
@@ -1197,7 +1194,7 @@ public class JellyfinController : ControllerBase
|
||||
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
_logger.LogInformation("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache",
|
||||
_logger.LogDebug("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache",
|
||||
spotifyTrackId, provider, externalId);
|
||||
}
|
||||
else
|
||||
@@ -1225,7 +1222,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
_logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
|
||||
_logger.LogDebug("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
|
||||
provider, externalId, spotifyTrackId);
|
||||
}
|
||||
}
|
||||
@@ -1274,50 +1271,53 @@ public class JellyfinController : ControllerBase
|
||||
searchArtists.Add(searchArtist);
|
||||
}
|
||||
|
||||
// Use orchestrator for clean, modular lyrics fetching
|
||||
LyricsInfo? lyrics = null;
|
||||
|
||||
if (_lyricsOrchestrator != null)
|
||||
{
|
||||
lyrics = await _lyricsOrchestrator.GetLyricsAsync(
|
||||
trackName: searchTitle,
|
||||
artistNames: searchArtists.ToArray(),
|
||||
albumName: searchAlbum,
|
||||
durationSeconds: song.Duration ?? 0,
|
||||
spotifyTrackId: spotifyTrackId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to manual fetching if orchestrator not available
|
||||
_logger.LogWarning("LyricsOrchestrator not available, using fallback method");
|
||||
|
||||
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
|
||||
// Spotify lyrics only work for tracks from injected playlists that have been matched
|
||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
// Validate that this is a real Spotify ID (not spotify:local or other invalid formats)
|
||||
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
||||
|
||||
// Spotify track IDs are 22 characters, base62 encoded
|
||||
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
|
||||
{
|
||||
_logger.LogInformation("Trying Spotify lyrics for track ID: {SpotifyId} ({Artist} - {Title})",
|
||||
cleanSpotifyId, searchArtist, searchTitle);
|
||||
|
||||
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
||||
|
||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
|
||||
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
||||
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to LRCLIB if no Spotify lyrics
|
||||
if (lyrics == null)
|
||||
// Fall back to LyricsPlus
|
||||
if (lyrics == null && _lyricsPlusService != null)
|
||||
{
|
||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
||||
string.Join(", ", searchArtists),
|
||||
searchTitle);
|
||||
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||
if (lrclibService != null)
|
||||
lyrics = await _lyricsPlusService.GetLyricsAsync(
|
||||
searchTitle,
|
||||
searchArtists.ToArray(),
|
||||
searchAlbum,
|
||||
song.Duration ?? 0);
|
||||
}
|
||||
|
||||
// Fall back to LRCLIB
|
||||
if (lyrics == null && _lrclibService != null)
|
||||
{
|
||||
lyrics = await lrclibService.GetLyricsAsync(
|
||||
lyrics = await _lrclibService.GetLyricsAsync(
|
||||
searchTitle,
|
||||
searchArtists.ToArray(),
|
||||
searchAlbum,
|
||||
@@ -1342,7 +1342,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics))
|
||||
{
|
||||
_logger.LogInformation("Parsing synced lyrics (LRC format)");
|
||||
_logger.LogDebug("Parsing synced lyrics (LRC format)");
|
||||
// Parse LRC format: [mm:ss.xx] text
|
||||
// Skip ID tags like [ar:Artist], [ti:Title], etc.
|
||||
var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
@@ -1370,7 +1370,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
// Skip ID tags like [ar:Artist], [ti:Title], [length:2:23], etc.
|
||||
}
|
||||
_logger.LogInformation("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count);
|
||||
_logger.LogDebug("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(lyricsText))
|
||||
{
|
||||
@@ -1386,7 +1386,7 @@ public class JellyfinController : ControllerBase
|
||||
["Text"] = line.Trim()
|
||||
});
|
||||
}
|
||||
_logger.LogInformation("Split into {Count} plain lyric lines", lyricLines.Count);
|
||||
_logger.LogDebug("Split into {Count} plain lyric lines", lyricLines.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1411,14 +1411,14 @@ public class JellyfinController : ControllerBase
|
||||
Lyrics = lyricLines
|
||||
};
|
||||
|
||||
_logger.LogInformation("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced);
|
||||
_logger.LogDebug("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced);
|
||||
|
||||
// Log a sample of the response for debugging
|
||||
if (lyricLines.Count > 0)
|
||||
{
|
||||
var sampleLine = lyricLines[0];
|
||||
var hasStart = sampleLine.ContainsKey("Start");
|
||||
_logger.LogInformation("Sample line: Text='{Text}', HasStart={HasStart}",
|
||||
_logger.LogDebug("Sample line: Text='{Text}', HasStart={HasStart}",
|
||||
sampleLine.GetValueOrDefault("Text"), hasStart);
|
||||
}
|
||||
|
||||
@@ -1498,6 +1498,21 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
|
||||
|
||||
// Use orchestrator for prefetching
|
||||
if (_lyricsOrchestrator != null)
|
||||
{
|
||||
await _lyricsOrchestrator.PrefetchLyricsAsync(
|
||||
trackName: searchTitle,
|
||||
artistNames: searchArtists.ToArray(),
|
||||
albumName: searchAlbum,
|
||||
durationSeconds: song.Duration ?? 0,
|
||||
spotifyTrackId: spotifyTrackId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to manual prefetching if orchestrator not available
|
||||
_logger.LogWarning("LyricsOrchestrator not available for prefetch, using fallback method");
|
||||
|
||||
// Try Spotify lyrics if we have a valid Spotify track ID
|
||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
@@ -1516,6 +1531,22 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to LyricsPlus
|
||||
if (_lyricsPlusService != null)
|
||||
{
|
||||
var lyrics = await _lyricsPlusService.GetLyricsAsync(
|
||||
searchTitle,
|
||||
searchArtists.ToArray(),
|
||||
searchAlbum,
|
||||
song.Duration ?? 0);
|
||||
|
||||
if (lyrics != null)
|
||||
{
|
||||
_logger.LogDebug("✓ Prefetched LyricsPlus lyrics for {Artist} - {Title}", searchArtist, searchTitle);
|
||||
return; // Success, lyrics are now cached
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to LRCLIB
|
||||
if (_lrclibService != null)
|
||||
{
|
||||
@@ -1537,7 +1568,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error prefetching lyrics for track {ItemId}", itemId);
|
||||
_logger.LogError(ex, "Error prefetching lyrics for track {ItemId}", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1559,7 +1590,7 @@ public class JellyfinController : ControllerBase
|
||||
userId = Request.Query["userId"].ToString();
|
||||
}
|
||||
|
||||
_logger.LogInformation("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
||||
_logger.LogDebug("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
||||
userId, itemId, Request.Path);
|
||||
|
||||
// Check if this is an external playlist - trigger download
|
||||
@@ -1628,7 +1659,7 @@ public class JellyfinController : ControllerBase
|
||||
endpoint = $"{endpoint}?userId={userId}";
|
||||
}
|
||||
|
||||
_logger.LogInformation("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
|
||||
_logger.LogDebug("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
|
||||
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
|
||||
|
||||
@@ -1649,7 +1680,7 @@ public class JellyfinController : ControllerBase
|
||||
userId = Request.Query["userId"].ToString();
|
||||
}
|
||||
|
||||
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
||||
_logger.LogDebug("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
||||
userId, itemId, Request.Path);
|
||||
|
||||
// External items - remove from kept folder if it exists
|
||||
@@ -1686,7 +1717,7 @@ public class JellyfinController : ControllerBase
|
||||
endpoint = $"{endpoint}?userId={userId}";
|
||||
}
|
||||
|
||||
_logger.LogInformation("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
|
||||
_logger.LogDebug("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
|
||||
|
||||
var (result, statusCode) = await _proxyService.DeleteAsync(endpoint, Request.Headers);
|
||||
|
||||
@@ -1748,7 +1779,7 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId);
|
||||
_logger.LogDebug("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId);
|
||||
|
||||
// Check if this is an external playlist (Deezer/Qobuz) first
|
||||
if (PlaylistIdHelper.IsExternalPlaylist(playlistId))
|
||||
@@ -1788,7 +1819,7 @@ public class JellyfinController : ControllerBase
|
||||
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
||||
}
|
||||
|
||||
_logger.LogInformation("Proxying to Jellyfin: {Endpoint}", endpoint);
|
||||
_logger.LogDebug("Proxying to Jellyfin: {Endpoint}", endpoint);
|
||||
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||
|
||||
return HandleProxyResponse(result, statusCode);
|
||||
@@ -1834,15 +1865,15 @@ public class JellyfinController : ControllerBase
|
||||
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
||||
|
||||
// Cache for 1 hour (playlists can change, so don't cache too long)
|
||||
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
|
||||
// Cache for configurable duration (playlists can change)
|
||||
await _cache.SetAsync(cacheKey, imageBytes, CacheExtensions.PlaylistImagesTTL);
|
||||
_logger.LogDebug("Cached playlist image for {PlaylistId}", playlistId);
|
||||
|
||||
return File(imageBytes, contentType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get playlist image {PlaylistId}", playlistId);
|
||||
_logger.LogError(ex, "Failed to get playlist image {PlaylistId}", playlistId);
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
@@ -1870,7 +1901,7 @@ public class JellyfinController : ControllerBase
|
||||
// Reset stream position
|
||||
Request.Body.Position = 0;
|
||||
|
||||
_logger.LogInformation("Authentication request received");
|
||||
_logger.LogDebug("Authentication request received");
|
||||
// DO NOT log request body or detailed headers - contains password
|
||||
|
||||
// Forward to Jellyfin server with client headers - completely transparent proxy
|
||||
@@ -1933,14 +1964,14 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to post session capabilities after auth");
|
||||
_logger.LogError(ex, "Failed to post session capabilities after auth");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
|
||||
_logger.LogError("Authentication failed - status {StatusCode}", statusCode);
|
||||
}
|
||||
|
||||
// Return Jellyfin's exact response
|
||||
@@ -2023,7 +2054,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get similar items for external song {ItemId}", itemId);
|
||||
_logger.LogError(ex, "Failed to get similar items for external song {ItemId}", itemId);
|
||||
return _responseBuilder.CreateJsonResponse(new
|
||||
{
|
||||
Items = Array.Empty<object>(),
|
||||
@@ -2132,7 +2163,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create instant mix for external song {ItemId}", itemId);
|
||||
_logger.LogError(ex, "Failed to create instant mix for external song {ItemId}", itemId);
|
||||
return _responseBuilder.CreateJsonResponse(new
|
||||
{
|
||||
Items = Array.Empty<object>(),
|
||||
@@ -2212,7 +2243,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
else if (statusCode == 401)
|
||||
{
|
||||
_logger.LogDebug("⚠ Jellyfin returned 401 for capabilities (token expired)");
|
||||
_logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2294,7 +2325,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to prefetch lyrics for external track {ItemId}", itemId);
|
||||
_logger.LogError(ex, "Failed to prefetch lyrics for external track {ItemId}", itemId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2344,7 +2375,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId);
|
||||
_logger.LogError(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2388,7 +2419,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId);
|
||||
_logger.LogError("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -2414,7 +2445,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send playback start, trying basic");
|
||||
_logger.LogError(ex, "Failed to send playback start, trying basic");
|
||||
// Fall back to basic playback start
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
@@ -2427,7 +2458,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to report playback start");
|
||||
_logger.LogError(ex, "Failed to report playback start");
|
||||
return NoContent(); // Return success anyway to not break playback
|
||||
}
|
||||
}
|
||||
@@ -2540,7 +2571,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to report playback progress");
|
||||
_logger.LogError(ex, "Failed to report playback progress");
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -2561,7 +2592,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
Request.Body.Position = 0;
|
||||
|
||||
_logger.LogDebug("⏹️ Playback STOPPED reported");
|
||||
_logger.LogInformation("⏹️ Playback STOPPED reported");
|
||||
|
||||
// Parse the body to check if it's an external track
|
||||
var doc = JsonDocument.Parse(body);
|
||||
@@ -2657,7 +2688,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
else if (statusCode == 401)
|
||||
{
|
||||
_logger.LogDebug("Playback stop returned 401 (token expired)");
|
||||
_logger.LogWarning("Playback stop returned 401 (token expired)");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2668,7 +2699,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to report playback stopped");
|
||||
_logger.LogError(ex, "Failed to report playback stopped");
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -2690,7 +2721,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to ping playback session");
|
||||
_logger.LogError(ex, "Failed to ping playback session");
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -2778,7 +2809,7 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
LocalAddress = Request.Host.ToString(),
|
||||
ServerName = serverName ?? "Allstarr",
|
||||
Version = version ?? "1.0.0",
|
||||
Version = version ?? "1.0.3",
|
||||
ProductName = "Allstarr (Jellyfin Proxy)",
|
||||
OperatingSystem = Environment.OSVersion.Platform.ToString(),
|
||||
Id = _settings.DeviceId,
|
||||
@@ -2804,6 +2835,13 @@ public class JellyfinController : ControllerBase
|
||||
[HttpPost("{**path}", Order = 100)]
|
||||
public async Task<IActionResult> ProxyRequest(string path)
|
||||
{
|
||||
// Block admin API routes - these should be handled by admin controllers, not proxied to Jellyfin
|
||||
if (path.StartsWith("api/admin", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("Admin route {Path} reached ProxyRequest - this should be handled by admin controllers", path);
|
||||
return NotFound(new { error = "Admin endpoint not found" });
|
||||
}
|
||||
|
||||
// Log session-related requests prominently to debug missing capabilities call
|
||||
if (path.Contains("session", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Contains("capabilit", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -2862,7 +2900,7 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
var playlistId = parts[1];
|
||||
|
||||
_logger.LogInformation("=== PLAYLIST REQUEST ===");
|
||||
_logger.LogDebug("=== PLAYLIST REQUEST ===");
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
||||
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
|
||||
@@ -2907,23 +2945,7 @@ public class JellyfinController : ControllerBase
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
// Forward auth headers from client
|
||||
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
|
||||
}
|
||||
else if (Request.Headers.TryGetValue("Authorization", out var auth))
|
||||
{
|
||||
var authValue = auth.ToString();
|
||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
|
||||
authValue.Contains("Token=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authValue);
|
||||
}
|
||||
else
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Authorization", authValue);
|
||||
}
|
||||
}
|
||||
AuthHeaderHelper.ForwardAuthHeaders(Request.Headers, request);
|
||||
|
||||
var response = await _proxyService.HttpClient.SendAsync(request);
|
||||
|
||||
@@ -2938,7 +2960,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to proxy binary request for {Path}", path);
|
||||
_logger.LogError(ex, "Failed to proxy binary request for {Path}", path);
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
@@ -2948,12 +2970,12 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
_logger.LogInformation("ProxyRequest intercepting search request: Path={Path}, SearchTerm={SearchTerm}", path, searchTerm);
|
||||
_logger.LogDebug("ProxyRequest intercepting search request: Path={Path}, SearchTerm={SearchTerm}", path, searchTerm);
|
||||
|
||||
// Item search: /users/{userId}/items or /items
|
||||
if (path.EndsWith("/items", StringComparison.OrdinalIgnoreCase) || path.Equals("items", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Redirecting to SearchItems");
|
||||
_logger.LogDebug("Redirecting to SearchItems");
|
||||
return await SearchItems(
|
||||
searchTerm: searchTerm,
|
||||
includeItemTypes: Request.Query["IncludeItemTypes"],
|
||||
@@ -2994,7 +3016,7 @@ public class JellyfinController : ControllerBase
|
||||
Request.EnableBuffering();
|
||||
|
||||
// Log request details for debugging
|
||||
_logger.LogInformation("POST request to {Path}: Method={Method}, ContentType={ContentType}, ContentLength={ContentLength}",
|
||||
_logger.LogDebug("POST request to {Path}: Method={Method}, ContentType={ContentType}, ContentLength={ContentLength}",
|
||||
fullPath, Request.Method, Request.ContentType, Request.ContentLength);
|
||||
|
||||
// Read body using StreamReader with proper encoding
|
||||
@@ -3018,7 +3040,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("POST body received from client for {Path}: {BodyLength} bytes, ContentType={ContentType}",
|
||||
_logger.LogDebug("POST body received from client for {Path}: {BodyLength} bytes, ContentType={ContentType}",
|
||||
fullPath, body.Length, Request.ContentType);
|
||||
|
||||
// Always log body content for playback endpoints to debug the issue
|
||||
@@ -3075,7 +3097,7 @@ public class JellyfinController : ControllerBase
|
||||
result.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
result.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
_logger.LogInformation("Response has Items property, checking for Spotify playlists to update counts");
|
||||
_logger.LogDebug("Response has Items property, checking for Spotify playlists to update counts");
|
||||
result = await UpdateSpotifyPlaylistCounts(result);
|
||||
}
|
||||
|
||||
@@ -3084,7 +3106,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Proxy request failed for {Path}", path);
|
||||
_logger.LogError(ex, "Proxy request failed for {Path}", path);
|
||||
return _responseBuilder.CreateError(502, $"Proxy error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
@@ -3146,7 +3168,7 @@ public class JellyfinController : ControllerBase
|
||||
var modified = false;
|
||||
var updatedItems = new List<Dictionary<string, object>>();
|
||||
|
||||
_logger.LogInformation("Checking {Count} items for Spotify playlists", itemsArray.Count);
|
||||
_logger.LogDebug("Checking {Count} items for Spotify playlists", itemsArray.Count);
|
||||
|
||||
foreach (var item in itemsArray)
|
||||
{
|
||||
@@ -3176,10 +3198,10 @@ public class JellyfinController : ControllerBase
|
||||
var playlistName = playlistConfig.Name;
|
||||
|
||||
// Get matched external tracks (tracks that were successfully downloaded/matched)
|
||||
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
|
||||
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
|
||||
_logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks",
|
||||
matchedTracksKey, matchedTracks?.Count ?? 0);
|
||||
|
||||
// Fallback to legacy cache format
|
||||
@@ -3204,7 +3226,7 @@ public class JellyfinController : ControllerBase
|
||||
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
|
||||
if (fileItems != null && fileItems.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count);
|
||||
_logger.LogDebug("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count);
|
||||
// Use file cache count directly
|
||||
itemDict["ChildCount"] = fileItems.Count;
|
||||
modified = true;
|
||||
@@ -3237,13 +3259,13 @@ public class JellyfinController : ControllerBase
|
||||
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
||||
{
|
||||
localTracksCount = localItems.GetArrayLength();
|
||||
_logger.LogInformation("Found {Count} total items in Jellyfin playlist {Name}",
|
||||
_logger.LogDebug("Found {Count} total items in Jellyfin playlist {Name}",
|
||||
localTracksCount, playlistName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get local tracks count for {Name}", playlistName);
|
||||
_logger.LogError(ex, "Failed to get local tracks count for {Name}", playlistName);
|
||||
}
|
||||
|
||||
// Count external matched tracks (not local)
|
||||
@@ -3262,7 +3284,7 @@ public class JellyfinController : ControllerBase
|
||||
// Update ChildCount to show actual available tracks
|
||||
itemDict["ChildCount"] = totalAvailableCount;
|
||||
modified = true;
|
||||
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
|
||||
_logger.LogDebug("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
|
||||
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
|
||||
}
|
||||
else
|
||||
@@ -3288,7 +3310,7 @@ public class JellyfinController : ControllerBase
|
||||
return response;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Modified {Count} Spotify playlists, rebuilding response",
|
||||
_logger.LogDebug("Modified {Count} Spotify playlists, rebuilding response",
|
||||
updatedItems.Count(i => i.ContainsKey("ChildCount")));
|
||||
|
||||
// Rebuild the response with updated items
|
||||
@@ -3304,7 +3326,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to update Spotify playlist counts");
|
||||
_logger.LogError(ex, "Failed to update Spotify playlist counts");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -3336,7 +3358,7 @@ public class JellyfinController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't let logging failures break the request
|
||||
_logger.LogDebug(ex, "Failed to log endpoint usage");
|
||||
_logger.LogError(ex, "Failed to log endpoint usage");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3460,26 +3482,26 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
/// <summary>
|
||||
/// New mode: Gets playlist tracks with correct ordering using direct Spotify API data.
|
||||
/// Optimized to only re-match when Jellyfin playlist changes (cheap check).
|
||||
/// </summary>
|
||||
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
|
||||
{
|
||||
// Check Redis cache first for fast serving
|
||||
var cacheKey = $"spotify:playlist:items:{spotifyPlaylistName}";
|
||||
// Check if Jellyfin playlist has changed (cheap API call)
|
||||
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}";
|
||||
var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
|
||||
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
|
||||
|
||||
var jellyfinPlaylistChanged = cachedJellyfinSignature != currentJellyfinSignature;
|
||||
|
||||
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
|
||||
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
|
||||
|
||||
if (cachedItems != null && cachedItems.Count > 0)
|
||||
if (cachedItems != null && cachedItems.Count > 0 && !jellyfinPlaylistChanged)
|
||||
{
|
||||
_logger.LogInformation("✅ Loaded {Count} playlist items from Redis cache for {Playlist}",
|
||||
_logger.LogDebug("✅ Loaded {Count} playlist items from Redis cache for {Playlist} (Jellyfin unchanged)",
|
||||
cachedItems.Count, spotifyPlaylistName);
|
||||
|
||||
// Log sample item to verify Spotify IDs are present
|
||||
if (cachedItems.Count > 0 && cachedItems[0].ContainsKey("ProviderIds"))
|
||||
{
|
||||
var providerIds = cachedItems[0]["ProviderIds"] as Dictionary<string, object>;
|
||||
var hasSpotifyId = providerIds?.ContainsKey("Spotify") ?? false;
|
||||
_logger.LogDebug("Sample cached item has Spotify ID: {HasSpotifyId}", hasSpotifyId);
|
||||
}
|
||||
|
||||
return new JsonResult(new
|
||||
{
|
||||
Items = cachedItems,
|
||||
@@ -3488,15 +3510,20 @@ public class JellyfinController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
if (jellyfinPlaylistChanged)
|
||||
{
|
||||
_logger.LogInformation("🔄 Jellyfin playlist changed for {Playlist} - re-matching tracks", spotifyPlaylistName);
|
||||
}
|
||||
|
||||
// Check file cache as fallback
|
||||
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
|
||||
if (fileItems != null && fileItems.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("✅ Loaded {Count} playlist items from file cache for {Playlist}",
|
||||
_logger.LogDebug("✅ Loaded {Count} playlist items from file cache for {Playlist}",
|
||||
fileItems.Count, spotifyPlaylistName);
|
||||
|
||||
// Restore to Redis cache
|
||||
await _cache.SetAsync(cacheKey, fileItems, TimeSpan.FromHours(24));
|
||||
await _cache.SetAsync(cacheKey, fileItems, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||
|
||||
return new JsonResult(new
|
||||
{
|
||||
@@ -3507,17 +3534,17 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// Check for ordered matched tracks from SpotifyTrackMatchingService
|
||||
var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}";
|
||||
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(spotifyPlaylistName);
|
||||
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
||||
|
||||
if (orderedTracks == null || orderedTracks.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
|
||||
_logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
|
||||
spotifyPlaylistName);
|
||||
return null; // Fall back to legacy mode
|
||||
}
|
||||
|
||||
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
||||
_logger.LogInformation("Using {Count} ordered matched tracks for {Playlist}",
|
||||
orderedTracks.Count, spotifyPlaylistName);
|
||||
|
||||
// Get existing Jellyfin playlist items (RAW - don't convert!)
|
||||
@@ -3529,8 +3556,17 @@ public class JellyfinController : ControllerBase
|
||||
return null; // Fall back to legacy mode
|
||||
}
|
||||
|
||||
// Request MediaSources field to get bitrate info
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||
// Pass through all requested fields from the original request
|
||||
var queryString = Request.QueryString.Value ?? "";
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
|
||||
|
||||
// Append the original query string (which includes Fields parameter)
|
||||
if (!string.IsNullOrEmpty(queryString))
|
||||
{
|
||||
// Remove the leading ? if present
|
||||
queryString = queryString.TrimStart('?');
|
||||
playlistItemsUrl = $"{playlistItemsUrl}&{queryString}";
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
||||
playlistId, userId);
|
||||
@@ -3597,7 +3633,7 @@ public class JellyfinController : ControllerBase
|
||||
var localUsedCount = 0;
|
||||
var externalUsedCount = 0;
|
||||
|
||||
_logger.LogInformation("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
|
||||
_logger.LogDebug("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
|
||||
|
||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||
{
|
||||
@@ -3681,15 +3717,17 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||
_logger.LogDebug("🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
|
||||
|
||||
// Save to file cache for persistence across restarts
|
||||
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
||||
|
||||
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
|
||||
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
||||
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||
|
||||
// Cache the Jellyfin playlist signature to detect future changes
|
||||
await _cache.SetAsync(jellyfinSignatureCacheKey, currentJellyfinSignature, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||
|
||||
// Return raw Jellyfin response format
|
||||
return new JsonResult(new
|
||||
@@ -3710,7 +3748,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (cachedTracks != null && cachedTracks.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Returning {Count} cached matched tracks from Redis for {Playlist}",
|
||||
_logger.LogInformation("Returning {Count} cached matched tracks from Redis for {Playlist}",
|
||||
cachedTracks.Count, spotifyPlaylistName);
|
||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||
}
|
||||
@@ -3721,8 +3759,8 @@ public class JellyfinController : ControllerBase
|
||||
cachedTracks = await LoadMatchedTracksFromFile(spotifyPlaylistName);
|
||||
if (cachedTracks != null && cachedTracks.Count > 0)
|
||||
{
|
||||
// Restore to Redis with 1 hour TTL
|
||||
await _cache.SetAsync(cacheKey, cachedTracks, TimeSpan.FromHours(1));
|
||||
// Restore to Redis with configurable TTL
|
||||
await _cache.SetAsync(cacheKey, cachedTracks, CacheExtensions.SpotifyMatchedTracksTTL);
|
||||
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist}",
|
||||
cachedTracks.Count, spotifyPlaylistName);
|
||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||
@@ -3739,7 +3777,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks");
|
||||
_logger.LogInformation("No UserId configured - may not be able to fetch existing playlist tracks");
|
||||
}
|
||||
|
||||
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
|
||||
@@ -3772,7 +3810,7 @@ public class JellyfinController : ControllerBase
|
||||
_logger.LogWarning("No existing tracks found in Jellyfin playlist - may need UserId parameter");
|
||||
}
|
||||
|
||||
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
|
||||
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(spotifyPlaylistName);
|
||||
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
|
||||
|
||||
// Fallback to file cache if Redis is empty
|
||||
@@ -3784,7 +3822,7 @@ public class JellyfinController : ControllerBase
|
||||
if (missingTracks != null && missingTracks.Count > 0)
|
||||
{
|
||||
await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365));
|
||||
_logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)",
|
||||
_logger.LogDebug("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)",
|
||||
missingTracks.Count, spotifyPlaylistName);
|
||||
}
|
||||
}
|
||||
@@ -3796,7 +3834,7 @@ public class JellyfinController : ControllerBase
|
||||
return _responseBuilder.CreateItemsResponse(existingTracks);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {Count} missing tracks for {Playlist}",
|
||||
_logger.LogDebug("Matching {Count} missing tracks for {Playlist}",
|
||||
missingTracks.Count, spotifyPlaylistName);
|
||||
|
||||
// Match missing tracks sequentially with rate limiting (excluding ones we already have locally)
|
||||
@@ -3855,7 +3893,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
_logger.LogError(ex, "Failed to match track: {Title} - {Artist}",
|
||||
track.Title, track.PrimaryArtist);
|
||||
}
|
||||
}
|
||||
@@ -3879,7 +3917,7 @@ public class JellyfinController : ControllerBase
|
||||
finalTracks.AddRange(existingTracks);
|
||||
}
|
||||
|
||||
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
||||
await _cache.SetAsync(cacheKey, finalTracks, CacheExtensions.SpotifyMatchedTracksTTL);
|
||||
|
||||
// Also save to file cache for persistence across restarts
|
||||
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
|
||||
@@ -3917,13 +3955,13 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
// Build kept folder path: Artist/Album/
|
||||
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||
var keptArtistPath = Path.Combine(keptBasePath, AdminHelperService.SanitizeFileName(song.Artist));
|
||||
var keptAlbumPath = Path.Combine(keptArtistPath, AdminHelperService.SanitizeFileName(song.Album));
|
||||
|
||||
// Check if track already exists in kept folder
|
||||
if (Directory.Exists(keptAlbumPath))
|
||||
{
|
||||
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
||||
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
|
||||
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
|
||||
if (existingFiles.Length > 0)
|
||||
{
|
||||
@@ -3936,19 +3974,19 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
// Look for the track in cache folder first
|
||||
var cacheBasePath = "/tmp/allstarr-cache";
|
||||
var cacheArtistPath = Path.Combine(cacheBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||
var cacheAlbumPath = Path.Combine(cacheArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||
var cacheArtistPath = Path.Combine(cacheBasePath, AdminHelperService.SanitizeFileName(song.Artist));
|
||||
var cacheAlbumPath = Path.Combine(cacheArtistPath, AdminHelperService.SanitizeFileName(song.Album));
|
||||
|
||||
string? sourceFilePath = null;
|
||||
|
||||
if (Directory.Exists(cacheAlbumPath))
|
||||
{
|
||||
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
||||
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
|
||||
var cacheFiles = Directory.GetFiles(cacheAlbumPath, $"*{sanitizedTitle}*");
|
||||
if (cacheFiles.Length > 0)
|
||||
{
|
||||
sourceFilePath = cacheFiles[0];
|
||||
_logger.LogInformation("Found track in cache folder: {Path}", sourceFilePath);
|
||||
_logger.LogDebug("Found track in cache folder: {Path}", sourceFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3962,7 +4000,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
|
||||
_logger.LogError(ex, "Failed to download track {ItemId}", itemId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3983,7 +4021,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
|
||||
_logger.LogInformation("✓ Copied track to kept folder: {Path}", keptFilePath);
|
||||
_logger.LogDebug("✓ Copied track to kept folder: {Path}", keptFilePath);
|
||||
|
||||
// Also copy cover art if it exists
|
||||
var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
|
||||
@@ -4044,7 +4082,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to check favorite status for {ItemId}", itemId);
|
||||
_logger.LogError(ex, "Failed to check favorite status for {ItemId}", itemId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -4083,7 +4121,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to mark track as favorited: {ItemId}", itemId);
|
||||
_logger.LogError(ex, "Failed to mark track as favorited: {ItemId}", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4109,7 +4147,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to remove track from favorites: {ItemId}", itemId);
|
||||
_logger.LogError(ex, "Failed to remove track from favorites: {ItemId}", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4145,7 +4183,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to mark track for deletion: {ItemId}", itemId);
|
||||
_logger.LogError(ex, "Failed to mark track for deletion: {ItemId}", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4190,7 +4228,7 @@ public class JellyfinController : ControllerBase
|
||||
var updatedJson = JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
||||
|
||||
_logger.LogInformation("Processed {Count} pending deletions", toDelete.Count);
|
||||
_logger.LogDebug("Processed {Count} pending deletions", toDelete.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -4213,18 +4251,18 @@ public class JellyfinController : ControllerBase
|
||||
if (song == null) return;
|
||||
|
||||
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||
var keptArtistPath = Path.Combine(keptBasePath, AdminHelperService.SanitizeFileName(song.Artist));
|
||||
var keptAlbumPath = Path.Combine(keptArtistPath, AdminHelperService.SanitizeFileName(song.Album));
|
||||
|
||||
if (!Directory.Exists(keptAlbumPath)) return;
|
||||
|
||||
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
||||
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
|
||||
var trackFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
|
||||
|
||||
foreach (var trackFile in trackFiles)
|
||||
{
|
||||
System.IO.File.Delete(trackFile);
|
||||
_logger.LogInformation("✓ Deleted track from kept folder: {Path}", trackFile);
|
||||
_logger.LogDebug("✓ Deleted track from kept folder: {Path}", trackFile);
|
||||
}
|
||||
|
||||
// Clean up empty directories
|
||||
@@ -4242,7 +4280,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete track {ItemId}", itemId);
|
||||
_logger.LogError(ex, "Failed to delete track {ItemId}", itemId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4271,14 +4309,14 @@ public class JellyfinController : ControllerBase
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
var tracks = JsonSerializer.Deserialize<List<allstarr.Models.Spotify.MissingTrack>>(json);
|
||||
|
||||
_logger.LogInformation("Loaded {Count} missing tracks from file cache for {Playlist} (age: {Age:F1}h)",
|
||||
_logger.LogDebug("Loaded {Count} missing tracks from file cache for {Playlist} (age: {Age:F1}h)",
|
||||
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
|
||||
|
||||
return tracks;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load missing tracks from file for {Playlist}", playlistName);
|
||||
_logger.LogError(ex, "Failed to load missing tracks from file for {Playlist}", playlistName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4295,7 +4333,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
_logger.LogDebug("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath);
|
||||
_logger.LogInformation("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4309,7 +4347,7 @@ public class JellyfinController : ControllerBase
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
|
||||
_logger.LogInformation("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
var tracks = JsonSerializer.Deserialize<List<Song>>(json);
|
||||
@@ -4321,7 +4359,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load matched tracks from file for {Playlist}", playlistName);
|
||||
_logger.LogError(ex, "Failed to load matched tracks from file for {Playlist}", playlistName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -4351,6 +4389,54 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a signature (hash) of the Jellyfin playlist to detect changes.
|
||||
/// This is a cheap operation compared to re-matching all tracks.
|
||||
/// Signature includes: track count + concatenated track IDs.
|
||||
/// </summary>
|
||||
private async Task<string> GetJellyfinPlaylistSignatureAsync(string playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = _settings.UserId;
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
playlistItemsUrl += $"&UserId={userId}";
|
||||
}
|
||||
|
||||
var (response, _) = await _proxyService.GetJsonAsync(playlistItemsUrl, null, Request.Headers);
|
||||
|
||||
if (response != null && response.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
var trackIds = new List<string>();
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
if (item.TryGetProperty("Id", out var idEl))
|
||||
{
|
||||
trackIds.Add(idEl.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
// Create signature: count + sorted IDs (sorted for consistency)
|
||||
trackIds.Sort();
|
||||
var signature = $"{trackIds.Count}:{string.Join(",", trackIds)}";
|
||||
|
||||
// Hash it to keep it compact
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(signature));
|
||||
return Convert.ToHexString(hashBytes);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get Jellyfin playlist signature for {PlaylistId}", playlistId);
|
||||
}
|
||||
|
||||
// Return empty string if failed (will trigger re-match)
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
@@ -4367,7 +4453,7 @@ public class JellyfinController : ControllerBase
|
||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogInformation("💾 Saved {Count} playlist items to file cache for {Playlist}",
|
||||
_logger.LogDebug("💾 Saved {Count} playlist items to file cache for {Playlist}",
|
||||
items.Count, playlistName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -4397,7 +4483,7 @@ public class JellyfinController : ControllerBase
|
||||
// Check if cache is too old (more than 24 hours)
|
||||
if (fileAge.TotalHours > 24)
|
||||
{
|
||||
_logger.LogInformation("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
|
||||
_logger.LogDebug("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
|
||||
playlistName, fileAge.TotalHours);
|
||||
return null;
|
||||
}
|
||||
@@ -4407,68 +4493,20 @@ public class JellyfinController : ControllerBase
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
|
||||
|
||||
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
|
||||
_logger.LogDebug("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
|
||||
items?.Count ?? 0, playlistName, fileAge.TotalHours);
|
||||
|
||||
return items;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
|
||||
_logger.LogError(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Calculates artist match score ensuring ALL artists are present.
|
||||
/// Penalizes if artist counts don't match or if any artist is missing.
|
||||
/// </summary>
|
||||
private static double CalculateArtistMatchScore(List<string> spotifyArtists, string songMainArtist, List<string> songContributors)
|
||||
{
|
||||
if (spotifyArtists.Count == 0 || string.IsNullOrEmpty(songMainArtist))
|
||||
return 0;
|
||||
|
||||
// Build list of all song artists (main + contributors)
|
||||
var allSongArtists = new List<string> { songMainArtist };
|
||||
allSongArtists.AddRange(songContributors);
|
||||
|
||||
// If artist counts differ significantly, penalize
|
||||
var countDiff = Math.Abs(spotifyArtists.Count - allSongArtists.Count);
|
||||
if (countDiff > 1) // Allow 1 artist difference (sometimes features are listed differently)
|
||||
return 0;
|
||||
|
||||
// Check that each Spotify artist has a good match in song artists
|
||||
var spotifyScores = new List<double>();
|
||||
foreach (var spotifyArtist in spotifyArtists)
|
||||
{
|
||||
var bestMatch = allSongArtists.Max(songArtist =>
|
||||
FuzzyMatcher.CalculateSimilarity(spotifyArtist, songArtist));
|
||||
spotifyScores.Add(bestMatch);
|
||||
}
|
||||
|
||||
// Check that each song artist has a good match in Spotify artists
|
||||
var songScores = new List<double>();
|
||||
foreach (var songArtist in allSongArtists)
|
||||
{
|
||||
var bestMatch = spotifyArtists.Max(spotifyArtist =>
|
||||
FuzzyMatcher.CalculateSimilarity(songArtist, spotifyArtist));
|
||||
songScores.Add(bestMatch);
|
||||
}
|
||||
|
||||
// Average all scores - this ensures ALL artists must match well
|
||||
var allScores = spotifyScores.Concat(songScores);
|
||||
var avgScore = allScores.Average();
|
||||
|
||||
// Penalize if any individual artist match is poor (< 70)
|
||||
var minScore = allScores.Min();
|
||||
if (minScore < 70)
|
||||
avgScore *= 0.7; // 30% penalty for poor individual match
|
||||
|
||||
return avgScore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts device information from Authorization header.
|
||||
/// </summary>
|
||||
@@ -4545,7 +4583,7 @@ public class JellyfinController : ControllerBase
|
||||
// Search through each playlist's matched tracks cache
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
var cacheKey = $"spotify:matched:ordered:{playlist.Name}";
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name);
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
|
||||
|
||||
if (matchedTracks == null || matchedTracks.Count == 0)
|
||||
@@ -4571,7 +4609,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error finding Spotify ID for external track");
|
||||
_logger.LogError(ex, "Error finding Spotify ID for external track");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using allstarr.Models.Admin;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Filters;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class LyricsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<LyricsController> _logger;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly AdminHelperService _adminHelper;
|
||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public LyricsController(
|
||||
ILogger<LyricsController> logger,
|
||||
RedisCacheService cache,
|
||||
AdminHelperService adminHelper,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
IServiceProvider serviceProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_adminHelper = adminHelper;
|
||||
_playlistFetcher = playlistFetcher;
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Save manual lyrics ID mapping for a track
|
||||
/// </summary>
|
||||
[HttpPost("lyrics/map")]
|
||||
public async Task<IActionResult> SaveLyricsMapping([FromBody] LyricsMappingRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Artist) || string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
return BadRequest(new { error = "Artist and Title are required" });
|
||||
}
|
||||
|
||||
if (request.LyricsId <= 0)
|
||||
{
|
||||
return BadRequest(new { error = "Valid LyricsId is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Store lyrics mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var mappingKey = $"lyrics:manual-map:{request.Artist}:{request.Title}";
|
||||
await _cache.SetStringAsync(mappingKey, request.LyricsId.ToString());
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await _adminHelper.SaveLyricsMappingToFileAsync(request.Artist, request.Title, request.Album ?? "", request.DurationSeconds, request.LyricsId);
|
||||
|
||||
_logger.LogInformation("Manual lyrics mapping saved: {Artist} - {Title} → Lyrics ID {LyricsId}",
|
||||
request.Artist, request.Title, request.LyricsId);
|
||||
|
||||
// Optionally fetch and cache the lyrics immediately
|
||||
try
|
||||
{
|
||||
var lyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.LrclibService>();
|
||||
if (lyricsService != null)
|
||||
{
|
||||
var lyricsInfo = await lyricsService.GetLyricsByIdAsync(request.LyricsId);
|
||||
if (lyricsInfo != null && !string.IsNullOrEmpty(lyricsInfo.PlainLyrics))
|
||||
{
|
||||
// Cache the lyrics using the standard cache key
|
||||
var lyricsCacheKey = $"lyrics:{request.Artist}:{request.Title}:{request.Album ?? ""}:{request.DurationSeconds}";
|
||||
await _cache.SetAsync(lyricsCacheKey, lyricsInfo.PlainLyrics);
|
||||
_logger.LogDebug("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics mapping saved and lyrics cached successfully",
|
||||
lyricsId = request.LyricsId,
|
||||
cached = true,
|
||||
lyrics = new
|
||||
{
|
||||
id = lyricsInfo.Id,
|
||||
trackName = lyricsInfo.TrackName,
|
||||
artistName = lyricsInfo.ArtistName,
|
||||
albumName = lyricsInfo.AlbumName,
|
||||
duration = lyricsInfo.Duration,
|
||||
instrumental = lyricsInfo.Instrumental
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch lyrics after mapping, but mapping was saved");
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics mapping saved successfully",
|
||||
lyricsId = request.LyricsId,
|
||||
cached = false
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save lyrics mapping");
|
||||
return StatusCode(500, new { error = "Failed to save lyrics mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get manual lyrics mappings
|
||||
/// </summary>
|
||||
[HttpGet("lyrics/mappings")]
|
||||
public async Task<IActionResult> GetLyricsMappings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsFile = "/app/cache/lyrics_mappings.json";
|
||||
|
||||
if (!System.IO.File.Exists(mappingsFile))
|
||||
{
|
||||
return Ok(new { mappings = new List<object>() });
|
||||
}
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
|
||||
var mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json) ?? new List<LyricsMappingEntry>();
|
||||
|
||||
return Ok(new { mappings });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get lyrics mappings");
|
||||
return StatusCode(500, new { error = "Failed to get lyrics mappings" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID
|
||||
/// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP
|
||||
/// </summary>
|
||||
[HttpGet("lyrics/spotify/test")]
|
||||
public async Task<IActionResult> TestSpotifyLyrics([FromQuery] string trackId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(trackId))
|
||||
{
|
||||
return BadRequest(new { error = "trackId parameter is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var spotifyLyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
||||
|
||||
if (spotifyLyricsService == null)
|
||||
{
|
||||
return StatusCode(500, new { error = "Spotify lyrics service not available" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Testing Spotify lyrics for track ID: {TrackId}", trackId);
|
||||
|
||||
var result = await spotifyLyricsService.GetLyricsByTrackIdAsync(trackId);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
error = "No lyrics found",
|
||||
trackId,
|
||||
message = "Lyrics may not be available for this track, or the Spotify API is not configured correctly"
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
trackId = result.SpotifyTrackId,
|
||||
syncType = result.SyncType,
|
||||
lineCount = result.Lines.Count,
|
||||
language = result.Language,
|
||||
provider = result.Provider,
|
||||
providerDisplayName = result.ProviderDisplayName,
|
||||
lines = result.Lines.Select(l => new
|
||||
{
|
||||
startTimeMs = l.StartTimeMs,
|
||||
endTimeMs = l.EndTimeMs,
|
||||
words = l.Words
|
||||
}).ToList(),
|
||||
// Also show LRC format
|
||||
lrcFormat = string.Join("\n", result.Lines.Select(l =>
|
||||
{
|
||||
var timestamp = TimeSpan.FromMilliseconds(l.StartTimeMs);
|
||||
var mm = (int)timestamp.TotalMinutes;
|
||||
var ss = timestamp.Seconds;
|
||||
var ms = timestamp.Milliseconds / 10;
|
||||
return $"[{mm:D2}:{ss:D2}.{ms:D2}]{l.Words}";
|
||||
}))
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Spotify lyrics for track {TrackId}", trackId);
|
||||
return StatusCode(500, new { error = $"Failed to fetch lyrics: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefetch lyrics for a specific playlist
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/prefetch-lyrics")]
|
||||
public async Task<IActionResult> PrefetchPlaylistLyrics(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
try
|
||||
{
|
||||
var lyricsPrefetchService = _serviceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
|
||||
if (lyricsPrefetchService == null)
|
||||
{
|
||||
return StatusCode(500, new { error = "Lyrics prefetch service not available" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Starting lyrics prefetch for playlist: {Playlist}", decodedName);
|
||||
|
||||
var (fetched, cached, missing) = await lyricsPrefetchService.PrefetchPlaylistLyricsAsync(
|
||||
decodedName,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics prefetch complete",
|
||||
playlist = decodedName,
|
||||
fetched,
|
||||
cached,
|
||||
missing,
|
||||
total = fetched + cached + missing
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to prefetch lyrics for playlist {Playlist}", decodedName);
|
||||
return StatusCode(500, new { error = $"Failed to prefetch lyrics: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the cached playlist summary so it will be regenerated on next request
|
||||
/// </summary>
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using allstarr.Models.Admin;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Filters;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class MappingController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<MappingController> _logger;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly AdminHelperService _adminHelper;
|
||||
|
||||
public MappingController(
|
||||
ILogger<MappingController> logger,
|
||||
RedisCacheService cache,
|
||||
AdminHelperService adminHelper)
|
||||
{
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_adminHelper = adminHelper;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Save lyrics mapping to file for persistence across restarts.
|
||||
/// Lyrics mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
[HttpGet("mappings/tracks")]
|
||||
public async Task<IActionResult> GetAllTrackMappings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsDir = "/app/cache/mappings";
|
||||
var allMappings = new List<object>();
|
||||
|
||||
if (!Directory.Exists(mappingsDir))
|
||||
{
|
||||
return Ok(new { mappings = allMappings, totalCount = 0 });
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(mappingsDir, "*_mappings.json");
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(file);
|
||||
var playlistMappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
|
||||
|
||||
if (playlistMappings != null)
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
var playlistName = fileName.Replace("_mappings", "").Replace("_", " ");
|
||||
|
||||
foreach (var mapping in playlistMappings.Values)
|
||||
{
|
||||
allMappings.Add(new
|
||||
{
|
||||
playlist = playlistName,
|
||||
spotifyId = mapping.SpotifyId,
|
||||
type = !string.IsNullOrEmpty(mapping.JellyfinId) ? "jellyfin" : "external",
|
||||
jellyfinId = mapping.JellyfinId,
|
||||
externalProvider = mapping.ExternalProvider,
|
||||
externalId = mapping.ExternalId,
|
||||
createdAt = mapping.CreatedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read mapping file {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
mappings = allMappings.OrderBy(m => ((dynamic)m).playlist).ThenBy(m => ((dynamic)m).createdAt),
|
||||
totalCount = allMappings.Count,
|
||||
jellyfinCount = allMappings.Count(m => ((dynamic)m).type == "jellyfin"),
|
||||
externalCount = allMappings.Count(m => ((dynamic)m).type == "external")
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get track mappings");
|
||||
return StatusCode(500, new { error = "Failed to get track mappings" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a manual track mapping
|
||||
/// </summary>
|
||||
[HttpDelete("mappings/tracks")]
|
||||
public async Task<IActionResult> DeleteTrackMapping([FromQuery] string playlist, [FromQuery] string spotifyId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(playlist) || string.IsNullOrEmpty(spotifyId))
|
||||
{
|
||||
return BadRequest(new { error = "playlist and spotifyId parameters are required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mappingsDir = "/app/cache/mappings";
|
||||
var safeName = AdminHelperService.SanitizeFileName(playlist);
|
||||
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
|
||||
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
return NotFound(new { error = "Mapping file not found for playlist" });
|
||||
}
|
||||
|
||||
// Load existing mappings
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
var mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
|
||||
|
||||
if (mappings == null || !mappings.ContainsKey(spotifyId))
|
||||
{
|
||||
return NotFound(new { error = "Mapping not found" });
|
||||
}
|
||||
|
||||
// Remove the mapping
|
||||
mappings.Remove(spotifyId);
|
||||
|
||||
// Save back to file (or delete file if empty)
|
||||
if (mappings.Count == 0)
|
||||
{
|
||||
System.IO.File.Delete(filePath);
|
||||
_logger.LogInformation("🗑️ Deleted empty mapping file for playlist {Playlist}", playlist);
|
||||
}
|
||||
else
|
||||
{
|
||||
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
|
||||
_logger.LogInformation("🗑️ Deleted mapping: {Playlist} - {SpotifyId}", playlist, spotifyId);
|
||||
}
|
||||
|
||||
// Also remove from Redis cache
|
||||
var cacheKey = $"manual:mapping:{playlist}:{spotifyId}";
|
||||
await _cache.DeleteAsync(cacheKey);
|
||||
|
||||
return Ok(new { success = true, message = "Mapping deleted successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete track mapping for {Playlist} - {SpotifyId}", playlist, spotifyId);
|
||||
return StatusCode(500, new { error = "Failed to delete track mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID
|
||||
/// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP
|
||||
/// </summary>
|
||||
}
|
||||
@@ -0,0 +1,1516 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Models.Admin;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services;
|
||||
using allstarr.Filters;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class PlaylistController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<PlaylistController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly JellyfinSettings _jellyfinSettings;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||
private readonly SpotifyTrackMatchingService? _matchingService;
|
||||
private readonly SpotifyMappingService _mappingService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly HttpClient _jellyfinHttpClient;
|
||||
private readonly AdminHelperService _helperService;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private const string CacheDirectory = "/app/cache/spotify";
|
||||
|
||||
public PlaylistController(
|
||||
ILogger<PlaylistController> logger,
|
||||
IConfiguration configuration,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
SpotifyMappingService mappingService,
|
||||
RedisCacheService cache,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AdminHelperService helperService,
|
||||
IServiceProvider serviceProvider,
|
||||
SpotifyTrackMatchingService? matchingService = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
_playlistFetcher = playlistFetcher;
|
||||
_matchingService = matchingService;
|
||||
_mappingService = mappingService;
|
||||
_cache = cache;
|
||||
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
||||
_helperService = helperService;
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
[HttpGet("playlists")]
|
||||
public async Task<IActionResult> GetPlaylists([FromQuery] bool refresh = false)
|
||||
{
|
||||
var playlistCacheFile = "/app/cache/admin_playlists_summary.json";
|
||||
|
||||
// Check file cache first (5 minute TTL) unless refresh is requested
|
||||
if (!refresh && System.IO.File.Exists(playlistCacheFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(playlistCacheFile);
|
||||
var age = DateTime.UtcNow - fileInfo.LastWriteTimeUtc;
|
||||
|
||||
if (age.TotalMinutes < 5)
|
||||
{
|
||||
var cachedJson = await System.IO.File.ReadAllTextAsync(playlistCacheFile);
|
||||
var cachedData = JsonSerializer.Deserialize<Dictionary<string, object>>(cachedJson);
|
||||
_logger.LogDebug("📦 Returning cached playlist summary (age: {Age:F1}m)", age.TotalMinutes);
|
||||
return Ok(cachedData);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("🔄 Cache expired (age: {Age:F1}m), refreshing...", age.TotalMinutes);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read cached playlist summary");
|
||||
}
|
||||
}
|
||||
else if (refresh)
|
||||
{
|
||||
_logger.LogDebug("🔄 Force refresh requested for playlist summary");
|
||||
}
|
||||
|
||||
var playlists = new List<object>();
|
||||
|
||||
// Read playlists directly from .env file to get the latest configuration
|
||||
// (IOptions is cached and doesn't reload after .env changes)
|
||||
var configuredPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync();
|
||||
|
||||
foreach (var config in configuredPlaylists)
|
||||
{
|
||||
var playlistInfo = new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = config.Name,
|
||||
["id"] = config.Id,
|
||||
["jellyfinId"] = config.JellyfinId,
|
||||
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
||||
["syncSchedule"] = config.SyncSchedule ?? "0 8 * * *",
|
||||
["trackCount"] = 0,
|
||||
["localTracks"] = 0,
|
||||
["externalTracks"] = 0,
|
||||
["lastFetched"] = null as DateTime?,
|
||||
["cacheAge"] = null as string
|
||||
};
|
||||
|
||||
// Get Spotify playlist track count from cache OR fetch it fresh
|
||||
var cacheFilePath = Path.Combine(CacheDirectory, $"{AdminHelperService.SanitizeFileName(config.Name)}_spotify.json");
|
||||
int spotifyTrackCount = 0;
|
||||
|
||||
if (System.IO.File.Exists(cacheFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(cacheFilePath);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("tracks", out var tracks))
|
||||
{
|
||||
spotifyTrackCount = tracks.GetArrayLength();
|
||||
playlistInfo["trackCount"] = spotifyTrackCount;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("fetchedAt", out var fetchedAt))
|
||||
{
|
||||
var fetchedTime = fetchedAt.GetDateTime();
|
||||
playlistInfo["lastFetched"] = fetchedTime;
|
||||
var age = DateTime.UtcNow - fetchedTime;
|
||||
playlistInfo["cacheAge"] = age.TotalHours < 1
|
||||
? $"{age.TotalMinutes:F0}m"
|
||||
: $"{age.TotalHours:F1}h";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read cache for playlist {Name}", config.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// If cache doesn't exist or failed to read, fetch track count from Spotify API
|
||||
if (spotifyTrackCount == 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
spotifyTrackCount = spotifyTracks.Count;
|
||||
playlistInfo["trackCount"] = spotifyTrackCount;
|
||||
_logger.LogDebug("Fetched {Count} tracks from Spotify for playlist {Name}", spotifyTrackCount, config.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch Spotify track count for playlist {Name}", config.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate stats from playlist items cache (source of truth)
|
||||
// This is fast and always accurate
|
||||
if (spotifyTrackCount > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to use the pre-built playlist cache
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name);
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
{
|
||||
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||
}
|
||||
catch (Exception cacheEx)
|
||||
{
|
||||
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name);
|
||||
}
|
||||
|
||||
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||
{
|
||||
// Calculate stats from the actual playlist cache
|
||||
var localCount = 0;
|
||||
var externalCount = 0;
|
||||
|
||||
foreach (var item in cachedPlaylistItems)
|
||||
{
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (providerIds != null)
|
||||
{
|
||||
// Check if it's external (has squidwtf, deezer, qobuz, or tidal key)
|
||||
var isExternal = providerIds.ContainsKey("squidwtf") ||
|
||||
providerIds.ContainsKey("deezer") ||
|
||||
providerIds.ContainsKey("qobuz") ||
|
||||
providerIds.ContainsKey("tidal");
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
externalCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
localCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var missingCount = spotifyTrackCount - (localCount + externalCount);
|
||||
|
||||
playlistInfo["localTracks"] = localCount;
|
||||
playlistInfo["externalMatched"] = externalCount;
|
||||
playlistInfo["externalMissing"] = missingCount;
|
||||
playlistInfo["externalTotal"] = externalCount + missingCount;
|
||||
playlistInfo["totalInJellyfin"] = localCount + externalCount;
|
||||
playlistInfo["totalPlayable"] = localCount + externalCount;
|
||||
|
||||
_logger.LogDebug("📊 Calculated stats from playlist cache for {Name}: {Local} local, {External} external, {Missing} missing",
|
||||
config.Name, localCount, externalCount, missingCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No playlist cache - calculate from global mappings as fallback
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
var localCount = 0;
|
||||
var externalCount = 0;
|
||||
var missingCount = 0;
|
||||
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
var mapping = await _mappingService.GetMappingAsync(track.SpotifyId);
|
||||
|
||||
if (mapping != null)
|
||||
{
|
||||
if (mapping.TargetType == "local")
|
||||
{
|
||||
localCount++;
|
||||
}
|
||||
else if (mapping.TargetType == "external")
|
||||
{
|
||||
externalCount++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
missingCount++;
|
||||
}
|
||||
}
|
||||
|
||||
playlistInfo["localTracks"] = localCount;
|
||||
playlistInfo["externalMatched"] = externalCount;
|
||||
playlistInfo["externalMissing"] = missingCount;
|
||||
playlistInfo["externalTotal"] = externalCount + missingCount;
|
||||
playlistInfo["totalInJellyfin"] = localCount + externalCount;
|
||||
playlistInfo["totalPlayable"] = localCount + externalCount;
|
||||
|
||||
_logger.LogDebug("📊 Calculated stats from global mappings for {Name}: {Local} local, {External} external, {Missing} missing",
|
||||
config.Name, localCount, externalCount, missingCount);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to calculate playlist stats for {Name}", config.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// LEGACY FALLBACK: Only used if global mappings fail
|
||||
// This is the old slow path - kept for backwards compatibility
|
||||
if (!string.IsNullOrEmpty(config.JellyfinId) &&
|
||||
(int)(playlistInfo["totalPlayable"] ?? 0) == 0 &&
|
||||
spotifyTrackCount > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Jellyfin requires UserId parameter to fetch playlist items
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
// If no user configured, try to get the first user
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
var usersRequest = _helperService.CreateJellyfinRequest(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users");
|
||||
var usersResponse = await _jellyfinHttpClient.SendAsync(usersRequest);
|
||||
|
||||
if (usersResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var usersJson = await usersResponse.Content.ReadAsStringAsync();
|
||||
using var usersDoc = JsonDocument.Parse(usersJson);
|
||||
if (usersDoc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
userId = usersDoc.RootElement[0].GetProperty("Id").GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("No user ID available to fetch playlist items for {Name}", config.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Playlists/{config.JellyfinId}/Items?UserId={userId}&Fields=Path";
|
||||
var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
|
||||
|
||||
_logger.LogDebug("Fetching Jellyfin playlist items for {Name} from {Url}", config.Name, url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var jellyfinJson = await response.Content.ReadAsStringAsync();
|
||||
using var jellyfinDoc = JsonDocument.Parse(jellyfinJson);
|
||||
|
||||
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
// Get Spotify tracks to match against
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
|
||||
// Try to use the pre-built playlist cache first (includes manual mappings!)
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name);
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
{
|
||||
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||
}
|
||||
catch (Exception cacheEx)
|
||||
{
|
||||
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}",
|
||||
config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||
|
||||
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||
{
|
||||
// Use the pre-built cache which respects manual mappings
|
||||
// spotifyTracks already fetched above - reuse it
|
||||
var localCount = 0;
|
||||
var externalCount = 0;
|
||||
var missingCount = 0;
|
||||
|
||||
// Count tracks by checking provider keys
|
||||
foreach (var item in cachedPlaylistItems)
|
||||
{
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (providerIds != null)
|
||||
{
|
||||
// Check if it's external (has squidwtf, deezer, qobuz, or tidal key)
|
||||
var hasSquidWTF = providerIds.ContainsKey("squidwtf");
|
||||
var hasDeezer = providerIds.ContainsKey("deezer");
|
||||
var hasQobuz = providerIds.ContainsKey("qobuz");
|
||||
var hasTidal = providerIds.ContainsKey("tidal");
|
||||
var isExternal = hasSquidWTF || hasDeezer || hasQobuz || hasTidal;
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
externalCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Local track (has Jellyfin, MusicBrainz, or other metadata keys)
|
||||
localCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate missing tracks: total Spotify tracks minus matched tracks
|
||||
// The playlist cache only contains successfully matched tracks (local + external)
|
||||
// So missing = total - (local + external)
|
||||
missingCount = spotifyTracks.Count - (localCount + externalCount);
|
||||
|
||||
playlistInfo["localTracks"] = localCount;
|
||||
playlistInfo["externalMatched"] = externalCount;
|
||||
playlistInfo["externalMissing"] = missingCount;
|
||||
playlistInfo["externalTotal"] = externalCount + missingCount;
|
||||
playlistInfo["totalInJellyfin"] = localCount + externalCount; // Tracks actually in the Jellyfin playlist
|
||||
playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served
|
||||
|
||||
_logger.LogDebug("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||
config.Name, spotifyTracks.Count, localCount, externalCount, missingCount, localCount + externalCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: Build list of local tracks from Jellyfin (match by name only)
|
||||
var localTracks = new List<(string Title, string Artist)>();
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var title = 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() ?? "";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(title))
|
||||
{
|
||||
localTracks.Add((title, artist));
|
||||
}
|
||||
}
|
||||
|
||||
// Get matched external tracks cache once
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(config.Name);
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
var matchedSpotifyIds = new HashSet<string>(
|
||||
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
);
|
||||
|
||||
var localCount = 0;
|
||||
var externalMatchedCount = 0;
|
||||
var externalMissingCount = 0;
|
||||
|
||||
// Match each Spotify track to determine if it's local, external, or missing
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
var isLocal = false;
|
||||
var hasExternalMapping = false;
|
||||
|
||||
// FIRST: Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
// Manual Jellyfin mapping exists - this track is definitely local
|
||||
isLocal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
// External manual mapping exists
|
||||
hasExternalMapping = true;
|
||||
}
|
||||
else if (localTracks.Count > 0)
|
||||
{
|
||||
// SECOND: No manual mapping, try fuzzy matching with local tracks
|
||||
var bestMatch = localTracks
|
||||
.Select(local => new
|
||||
{
|
||||
Local = local,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Local,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Use 70% threshold (same as playback matching)
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
||||
{
|
||||
isLocal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocal)
|
||||
{
|
||||
localCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if external track is matched (either manual mapping or auto-matched)
|
||||
if (hasExternalMapping || matchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
externalMatchedCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
externalMissingCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playlistInfo["localTracks"] = localCount;
|
||||
playlistInfo["externalMatched"] = externalMatchedCount;
|
||||
playlistInfo["externalMissing"] = externalMissingCount;
|
||||
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
|
||||
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
|
||||
playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served
|
||||
|
||||
_logger.LogWarning("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||
config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount, localCount + externalMatchedCount);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No Items property in Jellyfin response for {Name}", config.Name);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Failed to get Jellyfin playlist {Name}: {StatusCode}",
|
||||
config.Name, response.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Playlist {Name} has no JellyfinId configured", config.Name);
|
||||
}
|
||||
|
||||
playlists.Add(playlistInfo);
|
||||
}
|
||||
|
||||
// Save to file cache
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
var cacheFile = Path.Combine(cacheDir, "admin_playlists_summary.json");
|
||||
|
||||
var response = new { playlists };
|
||||
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false });
|
||||
await System.IO.File.WriteAllTextAsync(cacheFile, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved playlist summary to cache");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save playlist summary cache");
|
||||
}
|
||||
|
||||
return Ok(new { playlists });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get tracks for a specific playlist with local/external status
|
||||
/// </summary>
|
||||
[HttpGet("playlists/{name}/tracks")]
|
||||
public async Task<IActionResult> GetPlaylistTracks(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
// Get Spotify tracks
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
|
||||
|
||||
var tracksWithStatus = new List<object>();
|
||||
|
||||
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
|
||||
// This cache includes all matched tracks with proper provider IDs
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
{
|
||||
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||
}
|
||||
catch (Exception cacheEx)
|
||||
{
|
||||
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
_logger.LogDebug("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}",
|
||||
decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||
|
||||
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||
{
|
||||
// Build a map of Spotify ID -> cached item for quick lookup
|
||||
var spotifyIdToItem = new Dictionary<string, Dictionary<string, object?>>();
|
||||
|
||||
foreach (var item in cachedPlaylistItems)
|
||||
{
|
||||
// Try to get Spotify ID from ProviderIds (works for both local and external)
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId))
|
||||
{
|
||||
spotifyIdToItem[spotifyId] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match each Spotify track to its cached item
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
bool? isLocal = null;
|
||||
string? externalProvider = null;
|
||||
bool isManualMapping = false;
|
||||
string? manualMappingType = null;
|
||||
string? manualMappingId = null;
|
||||
|
||||
Dictionary<string, object?>? cachedItem = null;
|
||||
|
||||
// Try to match by Spotify ID only (no position-based fallback!)
|
||||
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out cachedItem))
|
||||
{
|
||||
_logger.LogDebug("Matched track {Title} by Spotify ID", track.Title);
|
||||
}
|
||||
|
||||
// Check if track is in the playlist cache first
|
||||
if (cachedItem != null)
|
||||
{
|
||||
// Track is in the playlist cache - determine type from ProviderIds
|
||||
if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (providerIds != null)
|
||||
{
|
||||
_logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys));
|
||||
|
||||
// Check for external provider keys (case-insensitive)
|
||||
// External providers: squidwtf, deezer, qobuz, tidal
|
||||
var hasSquidWTF = providerIds.Keys.Any(k => k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase));
|
||||
var hasDeezer = providerIds.Keys.Any(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase));
|
||||
var hasQobuz = providerIds.Keys.Any(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase));
|
||||
var hasTidal = providerIds.Keys.Any(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (hasSquidWTF)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "squidwtf";
|
||||
_logger.LogDebug("✓ Track {Title} identified as SquidWTF from cache", track.Title);
|
||||
}
|
||||
else if (hasDeezer)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "deezer";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Deezer from cache", track.Title);
|
||||
}
|
||||
else if (hasQobuz)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "qobuz";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Qobuz from cache", track.Title);
|
||||
}
|
||||
else if (hasTidal)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "tidal";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Tidal from cache", track.Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No external provider key found - it's a local Jellyfin track
|
||||
isLocal = true;
|
||||
_logger.LogDebug("✓ Track {Title} identified as LOCAL from cache", track.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
isLocal = true;
|
||||
_logger.LogDebug("✓ Track {Title} identified as LOCAL (ProviderIds null)", track.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Track is in cache but has NO ProviderIds - treat as local
|
||||
isLocal = true;
|
||||
_logger.LogDebug("✓ Track {Title} identified as LOCAL (in cache, no ProviderIds)", track.Title);
|
||||
}
|
||||
|
||||
// Check if this is a manual mapping (for display purposes)
|
||||
var globalMapping = await _mappingService.GetMappingAsync(track.SpotifyId);
|
||||
if (globalMapping != null && globalMapping.Source == "manual")
|
||||
{
|
||||
isManualMapping = true;
|
||||
manualMappingType = globalMapping.TargetType == "local" ? "jellyfin" : "external";
|
||||
manualMappingId = globalMapping.TargetType == "local" ? globalMapping.LocalId : globalMapping.ExternalId;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Track NOT in playlist cache - check if there's a MANUAL global mapping
|
||||
var globalMapping = await _mappingService.GetMappingAsync(track.SpotifyId);
|
||||
|
||||
if (globalMapping != null && globalMapping.Source == "manual")
|
||||
{
|
||||
// Manual mapping exists - trust it even if not in cache yet
|
||||
_logger.LogDebug("✓ Track {Title} has MANUAL global mapping: {Type}", track.Title, globalMapping.TargetType);
|
||||
|
||||
if (globalMapping.TargetType == "local")
|
||||
{
|
||||
isLocal = true;
|
||||
isManualMapping = true;
|
||||
manualMappingType = "jellyfin";
|
||||
manualMappingId = globalMapping.LocalId;
|
||||
}
|
||||
else if (globalMapping.TargetType == "external")
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = globalMapping.ExternalProvider;
|
||||
isManualMapping = true;
|
||||
manualMappingType = "external";
|
||||
manualMappingId = globalMapping.ExternalId;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No manual mapping and not in cache - it's missing
|
||||
// (Auto mappings don't count if track isn't in the playlist cache)
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
_logger.LogDebug("✗ Track {Title} ({SpotifyId}) is MISSING (not in cache, no manual mapping)", track.Title, track.SpotifyId);
|
||||
}
|
||||
}
|
||||
|
||||
// Check lyrics status
|
||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
|
||||
|
||||
tracksWithStatus.Add(new
|
||||
{
|
||||
position = track.Position,
|
||||
title = track.Title,
|
||||
artists = track.Artists,
|
||||
album = track.Album,
|
||||
isrc = track.Isrc,
|
||||
spotifyId = track.SpotifyId,
|
||||
durationMs = track.DurationMs,
|
||||
albumArtUrl = track.AlbumArtUrl,
|
||||
isLocal = isLocal,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null,
|
||||
isManualMapping = isManualMapping,
|
||||
manualMappingType = manualMappingType,
|
||||
manualMappingId = manualMappingId,
|
||||
hasLyrics = hasLyrics
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
name = decodedName,
|
||||
trackCount = spotifyTracks.Count,
|
||||
tracks = tracksWithStatus
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: Cache not available, use matched tracks cache
|
||||
_logger.LogWarning("Playlist cache not available for {Playlist}, using fallback", decodedName);
|
||||
|
||||
var fallbackMatchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
|
||||
var fallbackMatchedTracks = await _cache.GetAsync<List<MatchedTrack>>(fallbackMatchedTracksKey);
|
||||
var fallbackMatchedSpotifyIds = new HashSet<string>(
|
||||
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
);
|
||||
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
bool? isLocal = null;
|
||||
string? externalProvider = null;
|
||||
|
||||
// Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
isLocal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
string? provider = null;
|
||||
|
||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||
{
|
||||
provider = providerEl.GetString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(provider))
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = provider;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||
}
|
||||
}
|
||||
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF";
|
||||
}
|
||||
else
|
||||
{
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
tracksWithStatus.Add(new
|
||||
{
|
||||
position = track.Position,
|
||||
title = track.Title,
|
||||
artists = track.Artists,
|
||||
album = track.Album,
|
||||
isrc = track.Isrc,
|
||||
spotifyId = track.SpotifyId,
|
||||
durationMs = track.DurationMs,
|
||||
albumArtUrl = track.AlbumArtUrl,
|
||||
isLocal = isLocal,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
name = decodedName,
|
||||
trackCount = spotifyTracks.Count,
|
||||
tracks = tracksWithStatus
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger a manual refresh of all playlists
|
||||
/// </summary>
|
||||
[HttpPost("playlists/refresh")]
|
||||
public async Task<IActionResult> RefreshPlaylists()
|
||||
{
|
||||
_logger.LogInformation("Manual playlist refresh triggered from admin UI");
|
||||
await _playlistFetcher.TriggerFetchAsync();
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
_helperService.InvalidatePlaylistSummaryCache();
|
||||
|
||||
// Clear ALL playlist stats caches
|
||||
var configuredPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync();
|
||||
foreach (var playlist in configuredPlaylists)
|
||||
{
|
||||
var statsCacheKey = $"spotify:playlist:stats:{playlist.Name}";
|
||||
await _cache.DeleteAsync(statsCacheKey);
|
||||
}
|
||||
_logger.LogInformation("Cleared stats cache for all {Count} playlists", configuredPlaylists.Count);
|
||||
|
||||
return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-match tracks when LOCAL library has changed (checks if Jellyfin playlist changed).
|
||||
/// This is a lightweight operation that reuses cached Spotify data.
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/match")]
|
||||
public async Task<IActionResult> MatchPlaylistTracks(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Re-match tracks triggered for playlist: {Name} (checking for local changes)", decodedName);
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "Track matching service is not available" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Clear the Jellyfin playlist signature cache to force re-checking if local tracks changed
|
||||
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{decodedName}";
|
||||
await _cache.DeleteAsync(jellyfinSignatureCacheKey);
|
||||
_logger.LogDebug("Cleared Jellyfin signature cache to force change detection");
|
||||
|
||||
// Clear the matched results cache to force re-matching
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
|
||||
await _cache.DeleteAsync(matchedTracksKey);
|
||||
_logger.LogDebug("Cleared matched tracks cache");
|
||||
|
||||
// Clear the playlist items cache
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
|
||||
await _cache.DeleteAsync(playlistItemsCacheKey);
|
||||
_logger.LogDebug("Cleared playlist items cache");
|
||||
|
||||
// Trigger matching (will use cached Spotify data if still valid)
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
_helperService.InvalidatePlaylistSummaryCache();
|
||||
|
||||
// Clear playlist stats cache to force recalculation from new mappings
|
||||
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
|
||||
await _cache.DeleteAsync(statsCacheKey);
|
||||
_logger.LogDebug("Cleared stats cache for {Name}", decodedName);
|
||||
|
||||
return Ok(new {
|
||||
message = $"Re-matching tracks for {decodedName} (checking local changes)",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to trigger track matching for {Name}", decodedName);
|
||||
return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild playlist from scratch when REMOTE (Spotify) playlist has changed.
|
||||
/// Clears all caches including Spotify data and forces fresh fetch.
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/clear-cache")]
|
||||
public async Task<IActionResult> ClearPlaylistCache(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Rebuild from scratch triggered for playlist: {Name} (clearing Spotify cache)", decodedName);
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "Track matching service is not available" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Clear ALL cache keys for this playlist (including Spotify data)
|
||||
var cacheKeys = new[]
|
||||
{
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName), // Pre-built items cache
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName), // Ordered matched tracks
|
||||
$"spotify:matched:{decodedName}", // Legacy matched tracks
|
||||
CacheKeyBuilder.BuildSpotifyMissingTracksKey(decodedName), // Missing tracks
|
||||
$"spotify:playlist:jellyfin-signature:{decodedName}", // Jellyfin signature
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(decodedName) // Spotify playlist data
|
||||
};
|
||||
|
||||
foreach (var key in cacheKeys)
|
||||
{
|
||||
await _cache.DeleteAsync(key);
|
||||
_logger.LogDebug("Cleared cache key: {Key}", key);
|
||||
}
|
||||
|
||||
// Delete file caches
|
||||
var safeName = AdminHelperService.SanitizeFileName(decodedName);
|
||||
var filesToDelete = new[]
|
||||
{
|
||||
Path.Combine(CacheDirectory, $"{safeName}_items.json"),
|
||||
Path.Combine(CacheDirectory, $"{safeName}_matched.json")
|
||||
};
|
||||
|
||||
foreach (var file in filesToDelete)
|
||||
{
|
||||
if (System.IO.File.Exists(file))
|
||||
{
|
||||
System.IO.File.Delete(file);
|
||||
_logger.LogDebug("Deleted cache file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ Cleared all caches for playlist: {Name} (including Spotify data)", decodedName);
|
||||
|
||||
// Trigger rebuild (will fetch fresh Spotify data)
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
_helperService.InvalidatePlaylistSummaryCache();
|
||||
|
||||
// Clear playlist stats cache to force recalculation from new mappings
|
||||
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
|
||||
await _cache.DeleteAsync(statsCacheKey);
|
||||
_logger.LogDebug("Cleared stats cache for {Name}", decodedName);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = $"Rebuilding {decodedName} from scratch (fetching fresh Spotify data)",
|
||||
timestamp = DateTime.UtcNow,
|
||||
clearedKeys = cacheKeys.Length,
|
||||
clearedFiles = filesToDelete.Count(f => System.IO.File.Exists(f))
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to clear cache for {Name}", decodedName);
|
||||
return StatusCode(500, new { error = "Failed to clear cache", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search Jellyfin library for tracks (for manual mapping)
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/search")]
|
||||
public async Task<IActionResult> SearchJellyfinTracks([FromQuery] string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return BadRequest(new { error = "Query is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
// Build URL with UserId if available
|
||||
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
url += $"&UserId={userId}";
|
||||
}
|
||||
|
||||
var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
|
||||
|
||||
_logger.LogDebug("Searching Jellyfin: {Url}", url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var tracks = new List<object>();
|
||||
if (doc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
// Verify it's actually an Audio item
|
||||
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
||||
if (type != "Audio")
|
||||
{
|
||||
_logger.LogWarning("Skipping non-audio item: {Type}", type);
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.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() ?? "";
|
||||
}
|
||||
|
||||
tracks.Add(new { id, title, artist, album });
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { tracks });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to search Jellyfin tracks");
|
||||
return StatusCode(500, new { error = "Search failed" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get track details by Jellyfin ID (for URL-based mapping)
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/track/{id}")]
|
||||
public async Task<IActionResult> GetJellyfinTrack(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return BadRequest(new { error = "Track ID is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
url += $"?UserId={userId}";
|
||||
}
|
||||
|
||||
var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
|
||||
|
||||
_logger.LogDebug("Fetching Jellyfin track {Id} from {Url}", id, url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}",
|
||||
id, response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var item = doc.RootElement;
|
||||
|
||||
// Verify it's an Audio item
|
||||
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
||||
if (type != "Audio")
|
||||
{
|
||||
_logger.LogWarning("Item {Id} is not an Audio track, it's a {Type}", id, type);
|
||||
return BadRequest(new { error = $"Item is not an audio track (it's a {type})" });
|
||||
}
|
||||
|
||||
var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.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() ?? "";
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found Jellyfin track: {Title} by {Artist}", title, artist);
|
||||
|
||||
return Ok(new { id = trackId, title, artist, album });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get Jellyfin track {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to get track details" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save manual track mapping (local Jellyfin or external provider)
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/map")]
|
||||
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SpotifyId))
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyId is required" });
|
||||
}
|
||||
|
||||
// Validate that either Jellyfin mapping or external mapping is provided
|
||||
var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId);
|
||||
var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId);
|
||||
|
||||
if (!hasJellyfinMapping && !hasExternalMapping)
|
||||
{
|
||||
return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" });
|
||||
}
|
||||
|
||||
if (hasJellyfinMapping && hasExternalMapping)
|
||||
{
|
||||
return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string? normalizedProvider = null;
|
||||
|
||||
if (hasJellyfinMapping)
|
||||
{
|
||||
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||
await _cache.SetAsync(mappingKey, request.JellyfinId!);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await _helperService.SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null);
|
||||
|
||||
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
||||
decodedName, request.SpotifyId, request.JellyfinId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
|
||||
await _cache.SetAsync(externalMappingKey, externalMapping);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await _helperService.SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
|
||||
|
||||
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
||||
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
||||
}
|
||||
|
||||
// Clear all related caches to force rebuild
|
||||
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
|
||||
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
|
||||
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
|
||||
|
||||
await _cache.DeleteAsync(matchedCacheKey);
|
||||
await _cache.DeleteAsync(orderedCacheKey);
|
||||
await _cache.DeleteAsync(playlistItemsKey);
|
||||
await _cache.DeleteAsync(statsCacheKey);
|
||||
|
||||
// Also delete file caches to force rebuild
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
var safeName = AdminHelperService.SanitizeFileName(decodedName);
|
||||
var matchedFile = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||
var itemsFile = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
var statsFile = Path.Combine(cacheDir, $"{safeName}_stats.json");
|
||||
|
||||
if (System.IO.File.Exists(matchedFile))
|
||||
{
|
||||
System.IO.File.Delete(matchedFile);
|
||||
_logger.LogInformation("Deleted matched tracks file cache for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
if (System.IO.File.Exists(itemsFile))
|
||||
{
|
||||
System.IO.File.Delete(itemsFile);
|
||||
_logger.LogDebug("Deleted playlist items file cache for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
if (System.IO.File.Exists(statsFile))
|
||||
{
|
||||
System.IO.File.Delete(statsFile);
|
||||
_logger.LogDebug("Deleted stats file cache for {Playlist}", decodedName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete file caches for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
||||
|
||||
// Fetch external provider track details to return to the UI (only for external mappings)
|
||||
string? trackTitle = null;
|
||||
string? trackArtist = null;
|
||||
string? trackAlbum = null;
|
||||
|
||||
if (hasExternalMapping && normalizedProvider != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
||||
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
||||
|
||||
if (externalSong != null)
|
||||
{
|
||||
trackTitle = externalSong.Title;
|
||||
trackArtist = externalSong.Artist;
|
||||
trackAlbum = externalSong.Album;
|
||||
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Failed to fetch external track metadata for {Provider} ID {Id}",
|
||||
normalizedProvider, request.ExternalId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch external track metadata, but mapping was saved");
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger immediate playlist rebuild with the new mapping
|
||||
if (_matchingService != null)
|
||||
{
|
||||
_logger.LogInformation("Triggering immediate playlist rebuild for {Playlist} with new manual mapping", decodedName);
|
||||
|
||||
// Run rebuild in background with timeout to avoid blocking the response
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); // 2 minute timeout
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
_logger.LogInformation("✓ Playlist {Playlist} rebuilt successfully with manual mapping", decodedName);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Playlist rebuild for {Playlist} timed out after 2 minutes", decodedName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to rebuild playlist {Playlist} after manual mapping", decodedName);
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Matching service not available - playlist will rebuild on next scheduled run");
|
||||
}
|
||||
|
||||
// Return success with track details if available
|
||||
var mappedTrack = new
|
||||
{
|
||||
id = request.ExternalId,
|
||||
title = trackTitle ?? "Unknown",
|
||||
artist = trackArtist ?? "Unknown",
|
||||
album = trackAlbum ?? "Unknown",
|
||||
isLocal = false,
|
||||
externalProvider = request.ExternalProvider!.ToLowerInvariant()
|
||||
};
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Mapping saved and playlist rebuild triggered",
|
||||
track = mappedTrack,
|
||||
rebuildTriggered = _matchingService != null
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save manual mapping");
|
||||
return StatusCode(500, new { error = "Failed to save mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger track matching for all playlists
|
||||
/// </summary>
|
||||
[HttpPost("playlists/match-all")]
|
||||
public async Task<IActionResult> MatchAllPlaylistTracks()
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for all playlists");
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "Track matching service is not available" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _matchingService.TriggerMatchingAsync();
|
||||
return Ok(new { message = "Track matching triggered for all playlists", timestamp = DateTime.UtcNow });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to trigger track matching for all playlists");
|
||||
return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current configuration (safe values only)
|
||||
/// </summary>
|
||||
[HttpPost("playlists")]
|
||||
public async Task<IActionResult> AddPlaylist([FromBody] AddPlaylistRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Name) || string.IsNullOrEmpty(request.SpotifyId))
|
||||
{
|
||||
return BadRequest(new { error = "Name and SpotifyId are required" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Adding playlist: {Name} ({SpotifyId})", request.Name, request.SpotifyId);
|
||||
|
||||
// Get current playlists
|
||||
var currentPlaylists = _spotifyImportSettings.Playlists.ToList();
|
||||
|
||||
// Check for duplicates
|
||||
if (currentPlaylists.Any(p => p.Id == request.SpotifyId || p.Name == request.Name))
|
||||
{
|
||||
return BadRequest(new { error = "Playlist with this name or ID already exists" });
|
||||
}
|
||||
|
||||
// Add new playlist
|
||||
currentPlaylists.Add(new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = request.Name,
|
||||
Id = request.SpotifyId,
|
||||
LocalTracksPosition = request.LocalTracksPosition == "last"
|
||||
? LocalTracksPosition.Last
|
||||
: LocalTracksPosition.First
|
||||
});
|
||||
|
||||
// Convert to JSON format for env var
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
||||
);
|
||||
|
||||
// Update .env file
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await _helperService.UpdateEnvConfigAsync(updateRequest.Updates);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a playlist from the configuration
|
||||
/// </summary>
|
||||
[HttpDelete("playlists/{name}")]
|
||||
public async Task<IActionResult> RemovePlaylist(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Removing playlist: {Name}", decodedName);
|
||||
|
||||
// Read current playlists from .env file (not stale in-memory config)
|
||||
var currentPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync();
|
||||
var playlist = currentPlaylists.FirstOrDefault(p => p.Name == decodedName);
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
return NotFound(new { error = "Playlist not found" });
|
||||
}
|
||||
|
||||
currentPlaylists.Remove(playlist);
|
||||
|
||||
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
||||
);
|
||||
|
||||
// Update .env file
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await _helperService.UpdateEnvConfigAsync(updateRequest.Updates);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Save lyrics mapping to file for persistence across restarts.
|
||||
/// Lyrics mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Models.Admin;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Filters;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class SpotifyAdminController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<SpotifyAdminController> _logger;
|
||||
private readonly SpotifyApiClient _spotifyClient;
|
||||
private readonly SpotifyMappingService _mappingService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly AdminHelperService _helperService;
|
||||
|
||||
public SpotifyAdminController(
|
||||
ILogger<SpotifyAdminController> logger,
|
||||
SpotifyApiClient spotifyClient,
|
||||
SpotifyMappingService mappingService,
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||
AdminHelperService helperService)
|
||||
{
|
||||
_logger = logger;
|
||||
_spotifyClient = spotifyClient;
|
||||
_mappingService = mappingService;
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
_helperService = helperService;
|
||||
}
|
||||
|
||||
[HttpGet("spotify/user-playlists")]
|
||||
public async Task<IActionResult> GetSpotifyUserPlaylists()
|
||||
{
|
||||
if (!_spotifyApiSettings.Enabled || string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
return BadRequest(new { error = "Spotify API not configured. Please set sp_dc session cookie." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get list of already-configured Spotify playlist IDs
|
||||
var configuredPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync();
|
||||
var linkedSpotifyIds = new HashSet<string>(
|
||||
configuredPlaylists.Select(p => p.Id),
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
// Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
|
||||
var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
|
||||
|
||||
if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
|
||||
{
|
||||
return Ok(new { playlists = new List<object>() });
|
||||
}
|
||||
|
||||
var playlists = spotifyPlaylists.Select(p => new
|
||||
{
|
||||
id = p.SpotifyId,
|
||||
name = p.Name,
|
||||
trackCount = p.TotalTracks,
|
||||
owner = p.OwnerName ?? "",
|
||||
isPublic = p.Public,
|
||||
isLinked = linkedSpotifyIds.Contains(p.SpotifyId)
|
||||
}).ToList();
|
||||
|
||||
return Ok(new { playlists });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Spotify user playlists");
|
||||
return StatusCode(500, new { error = "Failed to fetch Spotify playlists", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all playlists from Jellyfin
|
||||
/// </summary>
|
||||
[HttpGet("spotify/sync")]
|
||||
public async Task<IActionResult> TriggerSpotifySync([FromServices] IEnumerable<IHostedService> hostedServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_spotifyImportSettings.Enabled)
|
||||
{
|
||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Manual Spotify sync triggered via admin endpoint");
|
||||
|
||||
// Find the SpotifyMissingTracksFetcher service
|
||||
var fetcherService = hostedServices
|
||||
.OfType<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (fetcherService == null)
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyMissingTracksFetcher service not found" });
|
||||
}
|
||||
|
||||
// Trigger the sync in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use reflection to call the private ExecuteOnceAsync method
|
||||
var method = fetcherService.GetType().GetMethod("ExecuteOnceAsync",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
await (Task)method.Invoke(fetcherService, new object[] { CancellationToken.None })!;
|
||||
_logger.LogInformation("Manual Spotify sync completed successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyMissingTracksFetcher");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during manual Spotify sync");
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(new {
|
||||
message = "Spotify sync started in background",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering Spotify sync");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger endpoint to force Spotify track matching.
|
||||
/// </summary>
|
||||
[HttpGet("spotify/match")]
|
||||
public async Task<IActionResult> TriggerSpotifyMatch([FromServices] IEnumerable<IHostedService> hostedServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_spotifyApiSettings.Enabled)
|
||||
{
|
||||
return BadRequest(new { error = "Spotify API is not enabled" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Manual Spotify track matching triggered via admin endpoint");
|
||||
|
||||
// Find the SpotifyTrackMatchingService
|
||||
var matchingService = hostedServices
|
||||
.OfType<allstarr.Services.Spotify.SpotifyTrackMatchingService>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyTrackMatchingService not found" });
|
||||
}
|
||||
|
||||
// Trigger matching in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use reflection to call the private ExecuteOnceAsync method
|
||||
var method = matchingService.GetType().GetMethod("ExecuteOnceAsync",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
await (Task)method.Invoke(matchingService, new object[] { CancellationToken.None })!;
|
||||
_logger.LogInformation("Manual Spotify track matching completed successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyTrackMatchingService");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during manual Spotify track matching");
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(new {
|
||||
message = "Spotify track matching started in background",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering Spotify track matching");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear Spotify playlist cache to force re-matching.
|
||||
/// </summary>
|
||||
[HttpPost("spotify/clear-cache")]
|
||||
public async Task<IActionResult> ClearSpotifyCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
var clearedKeys = new List<string>();
|
||||
|
||||
// Clear Redis cache for all configured playlists
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var keys = new[]
|
||||
{
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name)
|
||||
};
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
await _cache.DeleteAsync(key);
|
||||
clearedKeys.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count);
|
||||
|
||||
return Ok(new {
|
||||
message = "Spotify cache cleared successfully",
|
||||
clearedKeys = clearedKeys,
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error clearing Spotify cache");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Gets endpoint usage statistics from the log file.
|
||||
/// </summary>
|
||||
[HttpGet("spotify/mappings")]
|
||||
public async Task<IActionResult> GetSpotifyMappings(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
[FromQuery] bool enrichMetadata = true,
|
||||
[FromQuery] string? targetType = null,
|
||||
[FromQuery] string? source = null,
|
||||
[FromQuery] string? search = null,
|
||||
[FromQuery] string? sortBy = null,
|
||||
[FromQuery] string? sortOrder = "asc")
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get all mappings (we'll filter and sort in memory for now)
|
||||
var allMappings = await _mappingService.GetAllMappingsAsync(0, int.MaxValue);
|
||||
var stats = await _mappingService.GetStatsAsync();
|
||||
|
||||
// Enrich metadata for external tracks that are missing it
|
||||
if (enrichMetadata)
|
||||
{
|
||||
await EnrichExternalMappingsMetadataAsync(allMappings);
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
var filteredMappings = allMappings.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrEmpty(targetType) && targetType != "all")
|
||||
{
|
||||
filteredMappings = filteredMappings.Where(m =>
|
||||
m.TargetType.Equals(targetType, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(source) && source != "all")
|
||||
{
|
||||
filteredMappings = filteredMappings.Where(m =>
|
||||
m.Source.Equals(source, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
{
|
||||
var searchLower = search.ToLower();
|
||||
filteredMappings = filteredMappings.Where(m =>
|
||||
m.SpotifyId.ToLower().Contains(searchLower) ||
|
||||
(m.Metadata?.Title?.ToLower().Contains(searchLower) ?? false) ||
|
||||
(m.Metadata?.Artist?.ToLower().Contains(searchLower) ?? false));
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (!string.IsNullOrEmpty(sortBy))
|
||||
{
|
||||
var isDescending = sortOrder?.ToLower() == "desc";
|
||||
|
||||
filteredMappings = sortBy.ToLower() switch
|
||||
{
|
||||
"title" => isDescending
|
||||
? filteredMappings.OrderByDescending(m => m.Metadata?.Title ?? "")
|
||||
: filteredMappings.OrderBy(m => m.Metadata?.Title ?? ""),
|
||||
"artist" => isDescending
|
||||
? filteredMappings.OrderByDescending(m => m.Metadata?.Artist ?? "")
|
||||
: filteredMappings.OrderBy(m => m.Metadata?.Artist ?? ""),
|
||||
"spotifyid" => isDescending
|
||||
? filteredMappings.OrderByDescending(m => m.SpotifyId)
|
||||
: filteredMappings.OrderBy(m => m.SpotifyId),
|
||||
"type" => isDescending
|
||||
? filteredMappings.OrderByDescending(m => m.TargetType)
|
||||
: filteredMappings.OrderBy(m => m.TargetType),
|
||||
"source" => isDescending
|
||||
? filteredMappings.OrderByDescending(m => m.Source)
|
||||
: filteredMappings.OrderBy(m => m.Source),
|
||||
"created" => isDescending
|
||||
? filteredMappings.OrderByDescending(m => m.CreatedAt)
|
||||
: filteredMappings.OrderBy(m => m.CreatedAt),
|
||||
_ => filteredMappings
|
||||
};
|
||||
}
|
||||
|
||||
var filteredList = filteredMappings.ToList();
|
||||
var totalCount = filteredList.Count;
|
||||
|
||||
// Apply pagination
|
||||
var skip = (page - 1) * pageSize;
|
||||
var pagedMappings = filteredList.Skip(skip).Take(pageSize).ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
mappings = pagedMappings,
|
||||
pagination = new
|
||||
{
|
||||
page,
|
||||
pageSize,
|
||||
totalCount,
|
||||
totalPages = (int)Math.Ceiling((double)totalCount / pageSize)
|
||||
},
|
||||
stats
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get Spotify mappings");
|
||||
return StatusCode(500, new { error = "Failed to get mappings" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific Spotify track mapping
|
||||
/// </summary>
|
||||
[HttpGet("spotify/mappings/{spotifyId}")]
|
||||
public async Task<IActionResult> GetSpotifyMapping(string spotifyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mapping = await _mappingService.GetMappingAsync(spotifyId);
|
||||
if (mapping == null)
|
||||
{
|
||||
return NotFound(new { error = "Mapping not found" });
|
||||
}
|
||||
|
||||
return Ok(mapping);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get Spotify mapping for {SpotifyId}", spotifyId);
|
||||
return StatusCode(500, new { error = "Failed to get mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a Spotify track mapping (manual override)
|
||||
/// </summary>
|
||||
[HttpPost("spotify/mappings")]
|
||||
public async Task<IActionResult> SaveSpotifyMapping([FromBody] SpotifyMappingRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var metadata = request.Metadata != null ? new TrackMetadata
|
||||
{
|
||||
Title = request.Metadata.Title,
|
||||
Artist = request.Metadata.Artist,
|
||||
Album = request.Metadata.Album,
|
||||
ArtworkUrl = request.Metadata.ArtworkUrl,
|
||||
DurationMs = request.Metadata.DurationMs
|
||||
} : null;
|
||||
|
||||
var success = await _mappingService.SaveManualMappingAsync(
|
||||
request.SpotifyId,
|
||||
request.TargetType,
|
||||
request.LocalId,
|
||||
request.ExternalProvider,
|
||||
request.ExternalId,
|
||||
metadata);
|
||||
|
||||
if (success)
|
||||
{
|
||||
_logger.LogInformation("Saved manual mapping: {SpotifyId} → {TargetType}",
|
||||
request.SpotifyId, request.TargetType);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
return StatusCode(500, new { error = "Failed to save mapping" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save Spotify mapping");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a Spotify track mapping
|
||||
/// </summary>
|
||||
[HttpDelete("spotify/mappings/{spotifyId}")]
|
||||
public async Task<IActionResult> DeleteSpotifyMapping(string spotifyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await _mappingService.DeleteMappingAsync(spotifyId);
|
||||
if (success)
|
||||
{
|
||||
_logger.LogInformation("Deleted mapping for {SpotifyId}", spotifyId);
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
return NotFound(new { error = "Mapping not found" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete Spotify mapping for {SpotifyId}", spotifyId);
|
||||
return StatusCode(500, new { error = "Failed to delete mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics about Spotify track mappings
|
||||
/// </summary>
|
||||
[HttpGet("spotify/mappings/stats")]
|
||||
public async Task<IActionResult> GetSpotifyMappingStats()
|
||||
{
|
||||
try
|
||||
{
|
||||
var stats = await _mappingService.GetStatsAsync();
|
||||
return Ok(stats);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get Spotify mapping stats");
|
||||
return StatusCode(500, new { error = "Failed to get stats" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches metadata for external mappings that are missing title/artist/artwork
|
||||
/// </summary>
|
||||
private async Task EnrichExternalMappingsMetadataAsync(List<SpotifyTrackMapping> mappings)
|
||||
{
|
||||
var metadataService = _serviceProvider.GetService<IMusicMetadataService>();
|
||||
if (metadataService == null)
|
||||
{
|
||||
_logger.LogWarning("No metadata service available for enrichment");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var mapping in mappings)
|
||||
{
|
||||
// Skip if not external or already has metadata
|
||||
if (mapping.TargetType != "external" ||
|
||||
string.IsNullOrEmpty(mapping.ExternalProvider) ||
|
||||
string.IsNullOrEmpty(mapping.ExternalId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already has complete metadata
|
||||
if (mapping.Metadata != null &&
|
||||
!string.IsNullOrEmpty(mapping.Metadata.Title) &&
|
||||
!string.IsNullOrEmpty(mapping.Metadata.Artist))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Fetch track details from external provider
|
||||
var song = await metadataService.GetSongAsync(mapping.ExternalProvider.ToLowerInvariant(), mapping.ExternalId);
|
||||
|
||||
if (song != null)
|
||||
{
|
||||
// Update metadata
|
||||
if (mapping.Metadata == null)
|
||||
{
|
||||
mapping.Metadata = new TrackMetadata();
|
||||
}
|
||||
|
||||
mapping.Metadata.Title = song.Title;
|
||||
mapping.Metadata.Artist = song.Artist;
|
||||
mapping.Metadata.Album = song.Album;
|
||||
mapping.Metadata.ArtworkUrl = song.CoverArtUrl;
|
||||
mapping.Metadata.DurationMs = song.Duration.HasValue ? song.Duration.Value * 1000 : null;
|
||||
|
||||
// Save enriched metadata back to cache
|
||||
await _mappingService.SaveMappingAsync(mapping);
|
||||
|
||||
_logger.LogDebug("Enriched metadata for {SpotifyId} from {Provider}: {Title} by {Artist}",
|
||||
mapping.SpotifyId, mapping.ExternalProvider, song.Title, song.Artist);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to enrich metadata for {SpotifyId} from {Provider}:{ExternalId}",
|
||||
mapping.SpotifyId, mapping.ExternalProvider, mapping.ExternalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -145,14 +145,14 @@ public class SubsonicController : ControllerBase
|
||||
|
||||
if (localPath != null && System.IO.File.Exists(localPath))
|
||||
{
|
||||
// Update last access time for cache cleanup
|
||||
// Update last write time for cache cleanup (extends cache lifetime)
|
||||
try
|
||||
{
|
||||
System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow);
|
||||
System.IO.File.SetLastWriteTimeUtc(localPath, DateTime.UtcNow);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to update last access time for {Path}", localPath);
|
||||
_logger.LogError(ex, "Failed to update last write time for {Path}", localPath);
|
||||
}
|
||||
|
||||
var stream = System.IO.File.OpenRead(localPath);
|
||||
@@ -590,8 +590,8 @@ public class SubsonicController : ControllerBase
|
||||
var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync();
|
||||
var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
||||
|
||||
// Cache for 1 hour (playlists can change, so don't cache too long)
|
||||
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
|
||||
// Cache for configurable duration (playlists can change)
|
||||
await _cache.SetAsync(cacheKey, imageBytes, CacheExtensions.PlaylistImagesTTL);
|
||||
_logger.LogDebug("Cached playlist cover art for {Id}", id);
|
||||
|
||||
return File(imageBytes, contentType);
|
||||
|
||||
@@ -10,13 +10,24 @@ namespace allstarr.Filters;
|
||||
public class AdminPortFilter : IActionFilter
|
||||
{
|
||||
private const int AdminPort = 5275;
|
||||
private readonly ILogger<AdminPortFilter> _logger;
|
||||
|
||||
public AdminPortFilter(ILogger<AdminPortFilter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
var requestPort = context.HttpContext.Connection.LocalPort;
|
||||
|
||||
_logger.LogDebug("AdminPortFilter: Request to {Path} on port {Port} (admin port is {AdminPort})",
|
||||
context.HttpContext.Request.Path, requestPort, AdminPort);
|
||||
|
||||
if (requestPort != AdminPort)
|
||||
{
|
||||
_logger.LogWarning("Admin endpoint {Path} accessed on wrong port {Port}, rejecting",
|
||||
context.HttpContext.Request.Path, requestPort);
|
||||
context.Result = new NotFoundResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ public class ApiKeyAuthFilter : IAsyncActionFilter
|
||||
?? request.Headers["X-Emby-Token"].FirstOrDefault();
|
||||
|
||||
// Validate API key
|
||||
if (string.IsNullOrEmpty(apiKey) || !string.Equals(apiKey, _settings.ApiKey, StringComparison.Ordinal))
|
||||
if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(_settings.ApiKey) || !FixedTimeEquals(apiKey, _settings.ApiKey))
|
||||
{
|
||||
_logger.LogWarning("Unauthorized access attempt to {Path} from {IP}",
|
||||
request.Path,
|
||||
@@ -46,7 +46,21 @@ public class ApiKeyAuthFilter : IAsyncActionFilter
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("API key authentication successful for {Path}", request.Path);
|
||||
_logger.LogInformation("API key authentication successful for {Path}", request.Path);
|
||||
await next();
|
||||
}
|
||||
|
||||
// Use a robust constant-time comparison by comparing fixed-length hashes of the inputs.
|
||||
// This avoids leaking lengths and uses the platform's fixed-time compare helper.
|
||||
private static bool FixedTimeEquals(string a, string b)
|
||||
{
|
||||
if (a == null || b == null) return false;
|
||||
|
||||
// Compute SHA-256 hashes and compare them in constant time
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var aHash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(a));
|
||||
var bHash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(b));
|
||||
|
||||
return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(aHash, bHash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public class WebSocketProxyMiddleware
|
||||
_logger = logger;
|
||||
_sessionManager = sessionManager;
|
||||
|
||||
_logger.LogDebug("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
|
||||
_logger.LogInformation("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
@@ -110,7 +110,9 @@ public class WebSocketProxyMiddleware
|
||||
jellyfinWsUrl += context.Request.QueryString.Value;
|
||||
}
|
||||
|
||||
_logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl);
|
||||
// Build masked query string for safe logging
|
||||
var maskedQuery = BuildMaskedQuery(context.Request.QueryString.Value);
|
||||
_logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin WebSocket: {BaseUrl}{MaskedQuery}", jellyfinWsUrl.Split('?')[0], maskedQuery);
|
||||
|
||||
// Connect to Jellyfin WebSocket
|
||||
serverWebSocket = new ClientWebSocket();
|
||||
@@ -139,10 +141,10 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
|
||||
// Set user agent
|
||||
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
|
||||
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0.3");
|
||||
|
||||
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
||||
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
||||
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
||||
|
||||
// Start bidirectional proxying
|
||||
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
||||
@@ -158,7 +160,7 @@ public class WebSocketProxyMiddleware
|
||||
// 403 is expected when tokens expire or session ends - don't spam logs
|
||||
if (wsEx.Message.Contains("403"))
|
||||
{
|
||||
_logger.LogDebug("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
|
||||
_logger.LogWarning("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -180,7 +182,7 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error closing client WebSocket");
|
||||
_logger.LogError(ex, "Error closing client WebSocket");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +194,7 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error closing server WebSocket");
|
||||
_logger.LogError(ex, "Error closing server WebSocket");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +204,7 @@ public class WebSocketProxyMiddleware
|
||||
// CRITICAL: Notify session manager that client disconnected
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogDebug("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
||||
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
||||
await _sessionManager.RemoveSessionAsync(deviceId);
|
||||
}
|
||||
|
||||
@@ -210,6 +212,32 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for building a masked query string for logging. Redacts sensitive keys.
|
||||
public static string BuildMaskedQuery(string? queryString)
|
||||
{
|
||||
if (string.IsNullOrEmpty(queryString)) return string.Empty;
|
||||
|
||||
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||
var parts = new List<string>();
|
||||
foreach (var kv in query)
|
||||
{
|
||||
var key = kv.Key;
|
||||
var value = kv.Value.ToString();
|
||||
if (string.Equals(key, "api_key", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, "token", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, "auth", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
parts.Add($"{key}=<redacted>");
|
||||
}
|
||||
else
|
||||
{
|
||||
parts.Add($"{key}={value}");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty;
|
||||
}
|
||||
|
||||
private async Task ProxyMessagesAsync(
|
||||
WebSocket source,
|
||||
WebSocket destination,
|
||||
@@ -247,7 +275,7 @@ public class WebSocketProxyMiddleware
|
||||
if (direction == "Server→Client")
|
||||
{
|
||||
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
|
||||
_logger.LogTrace("📥 WEBSOCKET {Direction}: {Preview}",
|
||||
_logger.LogDebug("📥 WEBSOCKET {Direction}: {Preview}",
|
||||
direction,
|
||||
messageText.Length > 500 ? messageText[..500] + "..." : messageText);
|
||||
}
|
||||
@@ -282,7 +310,7 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction);
|
||||
_logger.LogError(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
namespace allstarr.Models.Admin;
|
||||
|
||||
public class ManualMappingRequest
|
||||
{
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string? JellyfinId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
}
|
||||
|
||||
public class LyricsMappingRequest
|
||||
{
|
||||
public string Artist { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string? Album { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
public int LyricsId { get; set; }
|
||||
}
|
||||
|
||||
public class ManualMappingEntry
|
||||
{
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string? JellyfinId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class LyricsMappingEntry
|
||||
{
|
||||
public string Artist { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string? Album { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
public int LyricsId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class ConfigUpdateRequest
|
||||
{
|
||||
public Dictionary<string, string> Updates { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AddPlaylistRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string SpotifyId { get; set; } = string.Empty;
|
||||
public string LocalTracksPosition { get; set; } = "first";
|
||||
}
|
||||
|
||||
public class LinkPlaylistRequest
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
||||
public string SyncSchedule { get; set; } = "0 8 * * *";
|
||||
}
|
||||
|
||||
public class UpdateScheduleRequest
|
||||
{
|
||||
public string SyncSchedule { get; set; } = string.Empty;
|
||||
}
|
||||
public class SpotifyMappingRequest
|
||||
{
|
||||
public required string SpotifyId { get; set; }
|
||||
public required string TargetType { get; set; } // "local" or "external"
|
||||
public string? LocalId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public TrackMetadataRequest? Metadata { get; set; }
|
||||
}
|
||||
|
||||
public class TrackMetadataRequest
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public string? Artist { get; set; }
|
||||
public string? Album { get; set; }
|
||||
public string? ArtworkUrl { get; set; }
|
||||
public int? DurationMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for updating configuration
|
||||
/// </summary>
|
||||
@@ -19,6 +19,12 @@ public class Song
|
||||
/// All artists for this track (main + featured). For display in Jellyfin clients.
|
||||
/// </summary>
|
||||
public List<string> Artists { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// All artist IDs corresponding to the Artists list. Index-matched with Artists.
|
||||
/// </summary>
|
||||
public List<string> ArtistIds { get; set; } = new();
|
||||
|
||||
public string Album { get; set; } = string.Empty;
|
||||
public string? AlbumId { get; set; }
|
||||
public int? Duration { get; set; } // In seconds
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace allstarr.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Cache TTL (Time To Live) settings for various data types.
|
||||
/// All values are configurable via Web UI and require restart to apply.
|
||||
/// </summary>
|
||||
public class CacheSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Search results cache duration in minutes.
|
||||
/// Default: 120 minutes (2 hours)
|
||||
/// </summary>
|
||||
public int SearchResultsMinutes { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Playlist cover images cache duration in hours.
|
||||
/// Default: 168 hours (1 week)
|
||||
/// </summary>
|
||||
public int PlaylistImagesHours { get; set; } = 168;
|
||||
|
||||
/// <summary>
|
||||
/// Spotify playlist items cache duration in hours.
|
||||
/// Default: 168 hours (1 week, until next cron job)
|
||||
/// </summary>
|
||||
public int SpotifyPlaylistItemsHours { get; set; } = 168;
|
||||
|
||||
/// <summary>
|
||||
/// Spotify matched tracks cache duration in days.
|
||||
/// This is the mapping of Spotify IDs to local/external tracks.
|
||||
/// Default: 30 days
|
||||
/// </summary>
|
||||
public int SpotifyMatchedTracksDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Lyrics cache duration in days.
|
||||
/// Default: 14 days (2 weeks)
|
||||
/// </summary>
|
||||
public int LyricsDays { get; set; } = 14;
|
||||
|
||||
/// <summary>
|
||||
/// Genre data cache duration in days.
|
||||
/// Default: 30 days
|
||||
/// </summary>
|
||||
public int GenreDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// External metadata (SquidWTF albums/artists) cache duration in days.
|
||||
/// Default: 7 days
|
||||
/// </summary>
|
||||
public int MetadataDays { get; set; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Odesli Spotify ID lookup cache duration in days.
|
||||
/// Default: 60 days
|
||||
/// </summary>
|
||||
public int OdesliLookupDays { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Jellyfin proxy images cache duration in days.
|
||||
/// Default: 14 days (2 weeks)
|
||||
/// </summary>
|
||||
public int ProxyImagesDays { get; set; } = 14;
|
||||
|
||||
// Helper methods to get TimeSpan values
|
||||
public TimeSpan SearchResultsTTL => TimeSpan.FromMinutes(SearchResultsMinutes);
|
||||
public TimeSpan PlaylistImagesTTL => TimeSpan.FromHours(PlaylistImagesHours);
|
||||
public TimeSpan SpotifyPlaylistItemsTTL => TimeSpan.FromHours(SpotifyPlaylistItemsHours);
|
||||
public TimeSpan SpotifyMatchedTracksTTL => TimeSpan.FromDays(SpotifyMatchedTracksDays);
|
||||
public TimeSpan LyricsTTL => TimeSpan.FromDays(LyricsDays);
|
||||
public TimeSpan GenreTTL => TimeSpan.FromDays(GenreDays);
|
||||
public TimeSpan MetadataTTL => TimeSpan.FromDays(MetadataDays);
|
||||
public TimeSpan OdesliLookupTTL => TimeSpan.FromDays(OdesliLookupDays);
|
||||
public TimeSpan ProxyImagesTTL => TimeSpan.FromDays(ProxyImagesDays);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public class JellyfinSettings
|
||||
/// <summary>
|
||||
/// Client version reported to Jellyfin
|
||||
/// </summary>
|
||||
public string ClientVersion { get; set; } = "1.0.0";
|
||||
public string ClientVersion { get; set; } = "1.0.3";
|
||||
|
||||
/// <summary>
|
||||
/// Device ID reported to Jellyfin
|
||||
|
||||
@@ -18,18 +18,6 @@ public class SpotifyApiSettings
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Spotify Client ID from https://developer.spotify.com/dashboard
|
||||
/// Used for OAuth token refresh and API access.
|
||||
/// </summary>
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Spotify Client Secret from https://developer.spotify.com/dashboard
|
||||
/// Optional - only needed for certain OAuth flows.
|
||||
/// </summary>
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Spotify session cookie (sp_dc).
|
||||
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.
|
||||
|
||||
@@ -45,6 +45,14 @@ public class SpotifyPlaylistConfig
|
||||
/// Where to position local tracks: "first" or "last"
|
||||
/// </summary>
|
||||
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
|
||||
|
||||
/// <summary>
|
||||
/// Cron schedule for syncing this playlist with Spotify
|
||||
/// Format: minute hour day month dayofweek
|
||||
/// Example: "0 8 * * *" = 8 AM every day
|
||||
/// Default: "0 8 * * *" (daily at 8 AM)
|
||||
/// </summary>
|
||||
public string SyncSchedule { get; set; } = "0 8 * * *";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,12 +6,12 @@ namespace allstarr.Models.Settings;
|
||||
public class SquidWTFSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// No user auth should be needed for this site.
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Preferred audio quality: FLAC, MP3_320, MP3_128
|
||||
/// If not specified or unavailable, the highest available quality will be used.
|
||||
/// Preferred audio quality:
|
||||
/// - HI_RES or HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest quality)
|
||||
/// - FLAC or LOSSLESS: 16-bit/44.1kHz FLAC (CD quality, default)
|
||||
/// - HIGH: 320kbps AAC (high quality, smaller files)
|
||||
/// - LOW: 96kbps AAC (low quality, smallest files)
|
||||
/// If not specified or unavailable, LOSSLESS will be used.
|
||||
/// </summary>
|
||||
public string? Quality { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace allstarr.Models.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a global mapping from a Spotify track ID to either a local Jellyfin track or an external provider track.
|
||||
/// This is a permanent mapping that speeds up playlist matching.
|
||||
/// </summary>
|
||||
public class SpotifyTrackMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// Spotify track ID (e.g., "3n3Ppam7vgaVa1iaRUc9Lp")
|
||||
/// </summary>
|
||||
public required string SpotifyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target type: "local" or "external"
|
||||
/// </summary>
|
||||
public required string TargetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Jellyfin item ID (if TargetType is "local")
|
||||
/// </summary>
|
||||
public string? LocalId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// External provider name (if TargetType is "external")
|
||||
/// </summary>
|
||||
public string? ExternalProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// External provider track ID (if TargetType is "external")
|
||||
/// </summary>
|
||||
public string? ExternalId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Track metadata for display purposes
|
||||
/// </summary>
|
||||
public TrackMetadata? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How this mapping was created: "auto" or "manual"
|
||||
/// </summary>
|
||||
public required string Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this mapping was created
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this mapping was last updated (for manual overrides)
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this mapping was last validated (checked if target still exists)
|
||||
/// </summary>
|
||||
public DateTime? LastValidatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this mapping needs validation
|
||||
/// Local: every 7 days, External: every playlist sync
|
||||
/// </summary>
|
||||
public bool NeedsValidation(bool isPlaylistSync = false)
|
||||
{
|
||||
if (!LastValidatedAt.HasValue) return true;
|
||||
|
||||
var timeSinceValidation = DateTime.UtcNow - LastValidatedAt.Value;
|
||||
|
||||
if (TargetType == "local")
|
||||
{
|
||||
// Local mappings: validate every 7 days
|
||||
return timeSinceValidation.TotalDays >= 7;
|
||||
}
|
||||
else if (TargetType == "external")
|
||||
{
|
||||
// External mappings: validate on every playlist sync
|
||||
return isPlaylistSync;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Track metadata for display in Admin UI
|
||||
/// </summary>
|
||||
public class TrackMetadata
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public string? Artist { get; set; }
|
||||
public string? Album { get; set; }
|
||||
public string? ArtworkUrl { get; set; }
|
||||
public int? DurationMs { get; set; }
|
||||
}
|
||||
+83
-20
@@ -13,9 +13,28 @@ using allstarr.Middleware;
|
||||
using allstarr.Filters;
|
||||
using Microsoft.Extensions.Http;
|
||||
using System.Text;
|
||||
using System.Net;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure forwarded headers for reverse proxy support (nginx, etc.)
|
||||
// This allows ASP.NET Core to read X-Forwarded-For, X-Real-IP, etc.
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
|
||||
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
|
||||
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedHost;
|
||||
|
||||
// Clear known networks and proxies to accept headers from any proxy
|
||||
// This is safe when running behind a trusted reverse proxy (nginx)
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
|
||||
// Trust X-Forwarded-* headers from any source
|
||||
// Only do this if your reverse proxy is properly configured and trusted
|
||||
options.ForwardLimit = null;
|
||||
});
|
||||
|
||||
// Decode SquidWTF API base URLs once at startup
|
||||
var squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||
static List<string> DecodeSquidWtfUrls()
|
||||
@@ -118,6 +137,9 @@ builder.Services.AddProblemDetails();
|
||||
// Admin port filter (restricts admin API to port 5275)
|
||||
builder.Services.AddScoped<allstarr.Filters.AdminPortFilter>();
|
||||
|
||||
// Admin helper service (shared utilities for admin controllers)
|
||||
builder.Services.AddSingleton<allstarr.Services.Admin.AdminHelperService>();
|
||||
|
||||
// Configuration - register both settings, active one determined by backend type
|
||||
builder.Services.Configure<SubsonicSettings>(
|
||||
builder.Configuration.GetSection("Subsonic"));
|
||||
@@ -131,6 +153,8 @@ builder.Services.Configure<SquidWTFSettings>(
|
||||
builder.Configuration.GetSection("SquidWTF"));
|
||||
builder.Services.Configure<RedisSettings>(
|
||||
builder.Configuration.GetSection("Redis"));
|
||||
builder.Services.Configure<CacheSettings>(
|
||||
builder.Configuration.GetSection("Cache"));
|
||||
// Configure Spotify Import settings with custom playlist parsing from env var
|
||||
builder.Services.Configure<SpotifyImportSettings>(options =>
|
||||
{
|
||||
@@ -144,7 +168,7 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
||||
#pragma warning restore CS0618
|
||||
|
||||
// Parse SPOTIFY_IMPORT_PLAYLISTS env var (JSON array format)
|
||||
// Format: [["Name","SpotifyId","JellyfinId","first|last"],["Name2","SpotifyId2","JellyfinId2","first|last"]]
|
||||
// Format: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],["Name2","SpotifyId2","JellyfinId2","first|last","cronSchedule"]]
|
||||
var playlistsEnv = builder.Configuration.GetValue<string>("SpotifyImport:Playlists");
|
||||
if (!string.IsNullOrWhiteSpace(playlistsEnv))
|
||||
{
|
||||
@@ -171,10 +195,11 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
||||
LocalTracksPosition = arr.Length >= 4 &&
|
||||
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
||||
? LocalTracksPosition.Last
|
||||
: LocalTracksPosition.First
|
||||
: LocalTracksPosition.First,
|
||||
SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * *"
|
||||
};
|
||||
options.Playlists.Add(config);
|
||||
Console.WriteLine($" Added: {config.Name} (Spotify: {config.Id}, Jellyfin: {config.JellyfinId}, Position: {config.LocalTracksPosition})");
|
||||
Console.WriteLine($" Added: {config.Name} (Spotify: {config.Id}, Jellyfin: {config.JellyfinId}, Position: {config.LocalTracksPosition}, Schedule: {config.SyncSchedule})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,7 +211,7 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Failed to parse SPOTIFY_IMPORT_PLAYLISTS: {ex.Message}");
|
||||
Console.WriteLine("Expected format: [[\"Name\",\"SpotifyId\",\"JellyfinId\",\"first|last\"],[\"Name2\",\"SpotifyId2\",\"JellyfinId2\",\"first|last\"]]");
|
||||
Console.WriteLine("Expected format: [[\"Name\",\"SpotifyId\",\"JellyfinId\",\"first|last\",\"cronSchedule\"],[\"Name2\",\"SpotifyId2\",\"JellyfinId2\",\"first|last\",\"cronSchedule\"]]");
|
||||
Console.WriteLine("Will try legacy format instead");
|
||||
}
|
||||
}
|
||||
@@ -454,7 +479,8 @@ else if (musicService == MusicService.SquidWTF)
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
|
||||
sp.GetRequiredService<RedisCacheService>(),
|
||||
squidWtfApiUrls));
|
||||
squidWtfApiUrls,
|
||||
sp.GetRequiredService<GenreEnrichmentService>()));
|
||||
builder.Services.AddSingleton<IDownloadService>(sp =>
|
||||
new SquidWTFDownloadService(
|
||||
sp.GetRequiredService<IHttpClientFactory>(),
|
||||
@@ -505,6 +531,9 @@ builder.Services.AddHostedService<CacheCleanupService>();
|
||||
// Register cache warming service (loads file caches into Redis on startup)
|
||||
builder.Services.AddHostedService<CacheWarmingService>();
|
||||
|
||||
// Register Redis persistence service (snapshots Redis to files periodically)
|
||||
builder.Services.AddHostedService<RedisPersistenceService>();
|
||||
|
||||
// Register Spotify API client, lyrics service, and settings for direct API access
|
||||
// Configure from environment variables with SPOTIFY_API_ prefix
|
||||
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
|
||||
@@ -518,18 +547,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
|
||||
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
var clientId = builder.Configuration.GetValue<string>("SpotifyApi:ClientId");
|
||||
if (!string.IsNullOrEmpty(clientId))
|
||||
{
|
||||
options.ClientId = clientId;
|
||||
}
|
||||
|
||||
var clientSecret = builder.Configuration.GetValue<string>("SpotifyApi:ClientSecret");
|
||||
if (!string.IsNullOrEmpty(clientSecret))
|
||||
{
|
||||
options.ClientSecret = clientSecret;
|
||||
}
|
||||
|
||||
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
|
||||
if (!string.IsNullOrEmpty(sessionCookie))
|
||||
{
|
||||
@@ -557,7 +574,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
|
||||
// Log configuration (mask sensitive values)
|
||||
Console.WriteLine($"SpotifyApi Configuration:");
|
||||
Console.WriteLine($" Enabled: {options.Enabled}");
|
||||
Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}");
|
||||
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
|
||||
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
|
||||
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");
|
||||
@@ -568,6 +584,21 @@ builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyApiClient>();
|
||||
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
||||
|
||||
// Register LyricsPlus service (multi-source lyrics API)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
|
||||
|
||||
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
|
||||
|
||||
// Register Spotify mapping service (global Spotify ID → Local/External mappings)
|
||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyMappingService>();
|
||||
|
||||
// Register Spotify mapping validation service (validates and upgrades mappings)
|
||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyMappingValidationService>();
|
||||
|
||||
// Register Spotify mapping migration service (migrates legacy per-playlist mappings to global format)
|
||||
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMappingMigrationService>();
|
||||
|
||||
// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled)
|
||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyPlaylistFetcher>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyPlaylistFetcher>());
|
||||
@@ -626,7 +657,26 @@ builder.Services.AddCors(options =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Initialize cache settings for static access
|
||||
CacheExtensions.InitializeCacheSettings(app.Services);
|
||||
|
||||
// Migrate old .env file format on startup
|
||||
try
|
||||
{
|
||||
var migrationService = new EnvMigrationService(app.Services.GetRequiredService<ILogger<EnvMigrationService>>());
|
||||
migrationService.MigrateEnvFile();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogWarning(ex, "Failed to run .env migration");
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
|
||||
// IMPORTANT: UseForwardedHeaders must be called BEFORE other middleware
|
||||
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
app.UseExceptionHandler(_ => { }); // Global exception handler
|
||||
|
||||
// Enable response compression EARLY in the pipeline
|
||||
@@ -681,8 +731,21 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
|
||||
var isController = base.IsController(typeInfo);
|
||||
if (!isController) return false;
|
||||
|
||||
// AdminController should always be registered (for web UI)
|
||||
if (typeInfo.Name == "AdminController") return true;
|
||||
// All admin controllers should always be registered (for admin UI)
|
||||
// This includes: AdminController, ConfigController, DiagnosticsController, DownloadsController,
|
||||
// PlaylistController, JellyfinAdminController, SpotifyAdminController, LyricsController, MappingController
|
||||
if (typeInfo.Name == "AdminController" ||
|
||||
typeInfo.Name == "ConfigController" ||
|
||||
typeInfo.Name == "DiagnosticsController" ||
|
||||
typeInfo.Name == "DownloadsController" ||
|
||||
typeInfo.Name == "PlaylistController" ||
|
||||
typeInfo.Name == "JellyfinAdminController" ||
|
||||
typeInfo.Name == "SpotifyAdminController" ||
|
||||
typeInfo.Name == "LyricsController" ||
|
||||
typeInfo.Name == "MappingController")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only register the controller matching the configured backend type
|
||||
return _backendType switch
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
|
||||
namespace allstarr.Services.Admin;
|
||||
|
||||
public class AdminHelperService
|
||||
{
|
||||
private readonly ILogger<AdminHelperService> _logger;
|
||||
private readonly JellyfinSettings _jellyfinSettings;
|
||||
private readonly string _envFilePath;
|
||||
|
||||
public AdminHelperService(
|
||||
ILogger<AdminHelperService> logger,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
_logger = logger;
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_envFilePath = environment.IsDevelopment()
|
||||
? Path.Combine(environment.ContentRootPath, "..", ".env")
|
||||
: "/app/.env";
|
||||
}
|
||||
|
||||
public string GetJellyfinAuthHeader()
|
||||
{
|
||||
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.3\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
||||
}
|
||||
|
||||
public async Task<List<SpotifyPlaylistConfig>> ReadPlaylistsFromEnvFileAsync()
|
||||
{
|
||||
var playlists = new List<SpotifyPlaylistConfig>();
|
||||
|
||||
if (!File.Exists(_envFilePath))
|
||||
{
|
||||
return playlists;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(_envFilePath);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.TrimStart().StartsWith("SPOTIFY_IMPORT_PLAYLISTS="))
|
||||
{
|
||||
var value = line.Substring(line.IndexOf('=') + 1).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value) || value == "[]")
|
||||
{
|
||||
return playlists;
|
||||
}
|
||||
|
||||
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
|
||||
if (playlistArrays != null)
|
||||
{
|
||||
foreach (var arr in playlistArrays)
|
||||
{
|
||||
if (arr.Length >= 2)
|
||||
{
|
||||
playlists.Add(new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = arr[0].Trim(),
|
||||
Id = arr[1].Trim(),
|
||||
JellyfinId = arr.Length >= 3 ? arr[2].Trim() : "",
|
||||
LocalTracksPosition = arr.Length >= 4 &&
|
||||
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
||||
? LocalTracksPosition.Last
|
||||
: LocalTracksPosition.First,
|
||||
SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * *"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read playlists from .env file");
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
|
||||
public static string MaskValue(string? value, int showLast = 0)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "(not set)";
|
||||
if (value.Length <= showLast) return "***";
|
||||
return showLast > 0 ? "***" + value[^showLast..] : value[..8] + "...";
|
||||
}
|
||||
|
||||
public static string SanitizeFileName(string name)
|
||||
{
|
||||
return string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
|
||||
}
|
||||
|
||||
public static bool IsValidEnvKey(string key)
|
||||
{
|
||||
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
public static string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len = len / 1024;
|
||||
}
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
|
||||
public void InvalidatePlaylistSummaryCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheFile = "/app/cache/admin_playlists_summary.json";
|
||||
if (File.Exists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
_logger.LogDebug("🗑️ Invalidated playlist summary cache");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to invalidate playlist summary cache");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasValue(object? obj)
|
||||
{
|
||||
if (obj == null) return false;
|
||||
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
public string GetEnvFilePath() => _envFilePath;
|
||||
|
||||
public async Task<IActionResult> UpdateEnvConfigAsync(Dictionary<string, string> updates)
|
||||
{
|
||||
if (updates == null || updates.Count == 0)
|
||||
{
|
||||
return new BadRequestObjectResult(new { error = "No updates provided" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Config update requested: {Count} changes", updates.Count);
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_envFilePath))
|
||||
{
|
||||
_logger.LogWarning(".env file not found at {Path}, creating new file", _envFilePath);
|
||||
}
|
||||
|
||||
var envContent = new Dictionary<string, string>();
|
||||
|
||||
if (File.Exists(_envFilePath))
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(_envFilePath);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
|
||||
continue;
|
||||
|
||||
var eqIndex = line.IndexOf('=');
|
||||
if (eqIndex > 0)
|
||||
{
|
||||
var key = line[..eqIndex].Trim();
|
||||
var value = line[(eqIndex + 1)..].Trim();
|
||||
envContent[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var appliedUpdates = new List<string>();
|
||||
foreach (var (key, value) in updates)
|
||||
{
|
||||
if (!IsValidEnvKey(key))
|
||||
{
|
||||
_logger.LogWarning("Invalid env key rejected: {Key}", key);
|
||||
return new BadRequestObjectResult(new { error = $"Invalid environment variable key: {key}" });
|
||||
}
|
||||
|
||||
envContent[key] = value;
|
||||
appliedUpdates.Add(key);
|
||||
|
||||
if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE";
|
||||
var dateValue = DateTime.UtcNow.ToString("o");
|
||||
envContent[dateKey] = dateValue;
|
||||
appliedUpdates.Add(dateKey);
|
||||
}
|
||||
}
|
||||
|
||||
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
await File.WriteAllTextAsync(_envFilePath, newContent + "\n");
|
||||
|
||||
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
||||
|
||||
return new OkObjectResult(new
|
||||
{
|
||||
message = "Configuration updated. Restart container to apply changes.",
|
||||
updatedKeys = appliedUpdates,
|
||||
requiresRestart = true,
|
||||
envFilePath = _envFilePath
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update configuration at {Path}", _envFilePath);
|
||||
return new ObjectResult(new { error = "Failed to update configuration", details = ex.Message })
|
||||
{
|
||||
StatusCode = 500
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IActionResult> RemovePlaylistFromConfigAsync(string playlistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentPlaylists = await ReadPlaylistsFromEnvFileAsync();
|
||||
var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
return new NotFoundObjectResult(new { error = $"Playlist '{playlistName}' not found" });
|
||||
}
|
||||
|
||||
currentPlaylists.Remove(playlist);
|
||||
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] {
|
||||
p.Name,
|
||||
p.Id,
|
||||
p.JellyfinId,
|
||||
p.LocalTracksPosition.ToString().ToLower(),
|
||||
p.SyncSchedule ?? "0 8 * * *"
|
||||
}).ToArray()
|
||||
);
|
||||
|
||||
var updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
};
|
||||
|
||||
return await UpdateEnvConfigAsync(updates);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to remove playlist {Name}", playlistName);
|
||||
return new ObjectResult(new { error = "Failed to remove playlist", details = ex.Message })
|
||||
{
|
||||
StatusCode = 500
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveManualMappingToFileAsync(
|
||||
string playlistName,
|
||||
string spotifyId,
|
||||
string? jellyfinId,
|
||||
string? externalProvider,
|
||||
string? externalId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsDir = "/app/cache/mappings";
|
||||
Directory.CreateDirectory(mappingsDir);
|
||||
|
||||
var safeName = SanitizeFileName(playlistName);
|
||||
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
|
||||
|
||||
// Load existing mappings
|
||||
var mappings = new Dictionary<string, Models.Admin.ManualMappingEntry>();
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
mappings = JsonSerializer.Deserialize<Dictionary<string, Models.Admin.ManualMappingEntry>>(json)
|
||||
?? new Dictionary<string, Models.Admin.ManualMappingEntry>();
|
||||
}
|
||||
|
||||
// Add or update mapping
|
||||
mappings[spotifyId] = new Models.Admin.ManualMappingEntry
|
||||
{
|
||||
SpotifyId = spotifyId,
|
||||
JellyfinId = jellyfinId,
|
||||
ExternalProvider = externalProvider,
|
||||
ExternalId = externalId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Save back to file
|
||||
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(filePath, updatedJson);
|
||||
|
||||
_logger.LogDebug("💾 Saved manual mapping to file: {Playlist} - {SpotifyId}", playlistName, spotifyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save manual mapping to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SaveLyricsMappingToFileAsync(
|
||||
string artist,
|
||||
string title,
|
||||
string album,
|
||||
int durationSeconds,
|
||||
int lyricsId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsDir = "/app/cache/lyrics_mappings";
|
||||
Directory.CreateDirectory(mappingsDir);
|
||||
|
||||
var safeName = SanitizeFileName($"{artist}_{title}");
|
||||
var filePath = Path.Combine(mappingsDir, $"{safeName}.json");
|
||||
|
||||
var mapping = new
|
||||
{
|
||||
artist,
|
||||
title,
|
||||
album,
|
||||
durationSeconds,
|
||||
lyricsId,
|
||||
createdAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(mapping, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved lyrics mapping to file: {Artist} - {Title} → {LyricsId}", artist, title, lyricsId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save lyrics mapping to file for {Artist} - {Title}", artist, title);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an authenticated HTTP request to Jellyfin API
|
||||
/// </summary>
|
||||
public HttpRequestMessage CreateJellyfinRequest(HttpMethod method, string url)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read and deserialize a JSON file
|
||||
/// </summary>
|
||||
public async Task<T?> ReadJsonFileAsync<T>(string filePath) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
return null;
|
||||
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read JSON file: {Path}", filePath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write object to JSON file
|
||||
/// </summary>
|
||||
public async Task<bool> WriteJsonFileAsync<T>(string filePath, T data, bool createDirectory = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (createDirectory)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to write JSON file: {Path}", filePath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for handling Jellyfin/Emby authentication headers.
|
||||
/// Centralizes logic for extracting and forwarding authentication headers.
|
||||
/// </summary>
|
||||
public static class AuthHeaderHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Forwards authentication headers from HTTP request to HttpRequestMessage.
|
||||
/// Handles both X-Emby-Authorization and Authorization headers.
|
||||
/// </summary>
|
||||
/// <param name="sourceHeaders">Source headers (from HttpRequest or IHeaderDictionary)</param>
|
||||
/// <param name="targetRequest">Target HttpRequestMessage</param>
|
||||
/// <returns>True if auth header was added, false otherwise</returns>
|
||||
public static bool ForwardAuthHeaders(IHeaderDictionary sourceHeaders, HttpRequestMessage targetRequest)
|
||||
{
|
||||
// Try X-Emby-Authorization first (case-insensitive)
|
||||
foreach (var header in sourceHeaders)
|
||||
{
|
||||
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
targetRequest.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
|
||||
foreach (var header in sourceHeaders)
|
||||
{
|
||||
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
|
||||
// Check if it's a MediaBrowser/Jellyfin auth header
|
||||
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
|
||||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase) ||
|
||||
headerValue.Contains("Token=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Forward as X-Emby-Authorization (Jellyfin's expected header)
|
||||
targetRequest.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Bearer token
|
||||
targetRequest.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts device ID from X-Emby-Authorization header.
|
||||
/// </summary>
|
||||
/// <param name="headers">Request headers</param>
|
||||
/// <returns>Device ID if found, null otherwise</returns>
|
||||
public static string? ExtractDeviceId(IHeaderDictionary headers)
|
||||
{
|
||||
if (headers.TryGetValue("X-Emby-Authorization", out var authHeader))
|
||||
{
|
||||
var authValue = authHeader.ToString();
|
||||
return ExtractDeviceIdFromAuthString(authValue);
|
||||
}
|
||||
|
||||
if (headers.TryGetValue("Authorization", out var authHeader2))
|
||||
{
|
||||
var authValue = authHeader2.ToString();
|
||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ExtractDeviceIdFromAuthString(authValue);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts device ID from MediaBrowser auth string.
|
||||
/// Format: MediaBrowser Client="...", Device="...", DeviceId="...", Version="...", Token="..."
|
||||
/// </summary>
|
||||
private static string? ExtractDeviceIdFromAuthString(string authValue)
|
||||
{
|
||||
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
authValue,
|
||||
@"DeviceId=""([^""]+)""",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
if (deviceIdMatch.Success)
|
||||
{
|
||||
return deviceIdMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts client name from MediaBrowser auth string.
|
||||
/// </summary>
|
||||
public static string? ExtractClientName(IHeaderDictionary headers)
|
||||
{
|
||||
if (headers.TryGetValue("X-Emby-Authorization", out var authHeader))
|
||||
{
|
||||
var authValue = authHeader.ToString();
|
||||
return ExtractClientNameFromAuthString(authValue);
|
||||
}
|
||||
|
||||
if (headers.TryGetValue("Authorization", out var authHeader2))
|
||||
{
|
||||
var authValue = authHeader2.ToString();
|
||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ExtractClientNameFromAuthString(authValue);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts client name from MediaBrowser auth string.
|
||||
/// </summary>
|
||||
private static string? ExtractClientNameFromAuthString(string authValue)
|
||||
{
|
||||
var clientMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
authValue,
|
||||
@"Client=""([^""]+)""",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
if (clientMatch.Success)
|
||||
{
|
||||
return clientMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a MediaBrowser auth header string.
|
||||
/// </summary>
|
||||
public static string CreateAuthHeader(string token, string? client = null, string? device = null, string? deviceId = null, string? version = null)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(client))
|
||||
parts.Add($"Client=\"{client}\"");
|
||||
|
||||
if (!string.IsNullOrEmpty(device))
|
||||
parts.Add($"Device=\"{device}\"");
|
||||
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
parts.Add($"DeviceId=\"{deviceId}\"");
|
||||
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
parts.Add($"Version=\"{version}\"");
|
||||
|
||||
parts.Add($"Token=\"{token}\"");
|
||||
|
||||
return $"MediaBrowser {string.Join(", ", parts)}";
|
||||
}
|
||||
}
|
||||
@@ -104,10 +104,10 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
|
||||
|
||||
// Update access time for cache cleanup
|
||||
// Update write time for cache cleanup (extends cache lifetime)
|
||||
if (SubsonicSettings.StorageMode == StorageMode.Cache)
|
||||
{
|
||||
IOFile.SetLastAccessTime(localPath, DateTime.UtcNow);
|
||||
IOFile.SetLastWriteTime(localPath, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
// Start background Odesli conversion for lyrics (if not already cached)
|
||||
@@ -242,8 +242,18 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
/// <summary>
|
||||
/// Extracts the external album ID from the internal album ID format.
|
||||
/// Example: "ext-deezer-album-123456" -> "123456"
|
||||
/// Default implementation handles standard format: "ext-{provider}-album-{id}"
|
||||
/// Override if your provider uses a different format.
|
||||
/// </summary>
|
||||
protected abstract string? ExtractExternalIdFromAlbumId(string albumId);
|
||||
protected virtual string? ExtractExternalIdFromAlbumId(string albumId)
|
||||
{
|
||||
var prefix = $"ext-{ProviderName}-album-";
|
||||
if (albumId.StartsWith(prefix))
|
||||
{
|
||||
return albumId[prefix.Length..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -264,6 +274,7 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
|
||||
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
|
||||
await DownloadLock.WaitAsync(cancellationToken);
|
||||
var lockHeld = true;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -273,10 +284,10 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
{
|
||||
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
||||
|
||||
// For cache mode, update file access time for cache cleanup logic
|
||||
// For cache mode, update file write time to extend cache lifetime
|
||||
if (isCache)
|
||||
{
|
||||
IOFile.SetLastAccessTime(existingPath, DateTime.UtcNow);
|
||||
IOFile.SetLastWriteTime(existingPath, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
return existingPath;
|
||||
@@ -288,6 +299,7 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
|
||||
// Release lock while waiting
|
||||
DownloadLock.Release();
|
||||
lockHeld = false;
|
||||
|
||||
// Wait for download to complete, checking every 100ms (faster than 500ms)
|
||||
// Also respect cancellation token so client timeouts are handled immediately
|
||||
@@ -443,10 +455,13 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (lockHeld)
|
||||
{
|
||||
DownloadLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task DownloadRemainingAlbumTracksAsync(string albumExternalId, string excludeTrackExternalId)
|
||||
{
|
||||
|
||||
@@ -66,7 +66,9 @@ public class CacheCleanupService : BackgroundService
|
||||
|
||||
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var cachePath = PathHelper.GetCachePath();
|
||||
// Get the actual cache path used by download services
|
||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "downloads";
|
||||
var cachePath = Path.Combine(downloadPath, "cache");
|
||||
|
||||
if (!Directory.Exists(cachePath))
|
||||
{
|
||||
@@ -78,7 +80,7 @@ public class CacheCleanupService : BackgroundService
|
||||
var deletedCount = 0;
|
||||
var totalSize = 0L;
|
||||
|
||||
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime}", cutoffTime);
|
||||
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime} from {Path}", cutoffTime, cachePath);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -108,7 +110,7 @@ public class CacheCleanupService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete cached file: {Path}", filePath);
|
||||
_logger.LogError(ex, "Failed to delete cached file: {Path}", filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +125,7 @@ public class CacheCleanupService : BackgroundService
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Cache cleanup completed: no files to delete");
|
||||
_logger.LogInformation("Cache cleanup completed: no files to delete");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -154,13 +156,13 @@ public class CacheCleanupService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete empty directory: {Path}", directory);
|
||||
_logger.LogError(ex, "Failed to delete empty directory: {Path}", directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error cleaning up empty directories");
|
||||
_logger.LogError(ex, "Error cleaning up empty directories");
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for cache TTL management.
|
||||
/// Provides centralized access to configurable cache durations.
|
||||
/// </summary>
|
||||
public static class CacheExtensions
|
||||
{
|
||||
private static CacheSettings? _cacheSettings;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initialize cache settings (called once at startup).
|
||||
/// </summary>
|
||||
public static void InitializeCacheSettings(IServiceProvider serviceProvider)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_cacheSettings == null)
|
||||
{
|
||||
var options = serviceProvider.GetService<IOptions<CacheSettings>>();
|
||||
_cacheSettings = options?.Value ?? new CacheSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current cache settings.
|
||||
/// </summary>
|
||||
public static CacheSettings GetCacheSettings()
|
||||
{
|
||||
if (_cacheSettings == null)
|
||||
{
|
||||
throw new InvalidOperationException("Cache settings not initialized. Call InitializeCacheSettings first.");
|
||||
}
|
||||
return _cacheSettings;
|
||||
}
|
||||
|
||||
// Convenience methods for getting TTLs
|
||||
public static TimeSpan SearchResultsTTL => GetCacheSettings().SearchResultsTTL;
|
||||
public static TimeSpan PlaylistImagesTTL => GetCacheSettings().PlaylistImagesTTL;
|
||||
public static TimeSpan SpotifyPlaylistItemsTTL => GetCacheSettings().SpotifyPlaylistItemsTTL;
|
||||
public static TimeSpan SpotifyMatchedTracksTTL => GetCacheSettings().SpotifyMatchedTracksTTL;
|
||||
public static TimeSpan LyricsTTL => GetCacheSettings().LyricsTTL;
|
||||
public static TimeSpan GenreTTL => GetCacheSettings().GenreTTL;
|
||||
public static TimeSpan MetadataTTL => GetCacheSettings().MetadataTTL;
|
||||
public static TimeSpan OdesliLookupTTL => GetCacheSettings().OdesliLookupTTL;
|
||||
public static TimeSpan ProxyImagesTTL => GetCacheSettings().ProxyImagesTTL;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for building consistent cache keys across the application.
|
||||
/// Centralizes cache key generation to ensure consistency and prevent typos.
|
||||
/// </summary>
|
||||
public static class CacheKeyBuilder
|
||||
{
|
||||
#region Search Keys
|
||||
|
||||
public static string BuildSearchKey(string? searchTerm, string? itemTypes, int? limit, int? startIndex)
|
||||
{
|
||||
return $"search:{searchTerm?.ToLowerInvariant()}:{itemTypes}:{limit}:{startIndex}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Metadata Keys
|
||||
|
||||
public static string BuildAlbumKey(string provider, string externalId)
|
||||
{
|
||||
return $"{provider}:album:{externalId}";
|
||||
}
|
||||
|
||||
public static string BuildArtistKey(string provider, string externalId)
|
||||
{
|
||||
return $"{provider}:artist:{externalId}";
|
||||
}
|
||||
|
||||
public static string BuildSongKey(string provider, string externalId)
|
||||
{
|
||||
return $"{provider}:song:{externalId}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Spotify Keys
|
||||
|
||||
public static string BuildSpotifyPlaylistKey(string playlistName)
|
||||
{
|
||||
return $"spotify:playlist:{playlistName}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyPlaylistItemsKey(string playlistName)
|
||||
{
|
||||
return $"spotify:playlist:items:{playlistName}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyMatchedTracksKey(string playlistName)
|
||||
{
|
||||
return $"spotify:matched:ordered:{playlistName}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyMissingTracksKey(string playlistName)
|
||||
{
|
||||
return $"spotify:missing:{playlistName}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyManualMappingKey(string playlist, string spotifyId)
|
||||
{
|
||||
return $"spotify:manual-map:{playlist}:{spotifyId}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyExternalMappingKey(string playlist, string spotifyId)
|
||||
{
|
||||
return $"spotify:external-map:{playlist}:{spotifyId}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lyrics Keys
|
||||
|
||||
public static string BuildLyricsKey(string artist, string title, string? album, int? durationSeconds)
|
||||
{
|
||||
return $"lyrics:{artist}:{title}:{album}:{durationSeconds}";
|
||||
}
|
||||
|
||||
public static string BuildLyricsPlusKey(string artist, string title, string? album, int? durationSeconds)
|
||||
{
|
||||
return $"lyricsplus:{artist}:{title}:{album}:{durationSeconds}";
|
||||
}
|
||||
|
||||
public static string BuildLyricsManualMappingKey(string artist, string title)
|
||||
{
|
||||
return $"lyrics:manual-map:{artist}:{title}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Playlist Keys
|
||||
|
||||
public static string BuildPlaylistImageKey(string playlistId)
|
||||
{
|
||||
return $"playlist:image:{playlistId}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Genre Keys
|
||||
|
||||
public static string BuildGenreKey(string genre)
|
||||
{
|
||||
return $"genre:{genre.ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -105,19 +105,19 @@ public class CacheWarmingService : IHostedService
|
||||
if (cacheEntry != null && !string.IsNullOrEmpty(cacheEntry.CacheKey))
|
||||
{
|
||||
var redisKey = $"genre:{cacheEntry.CacheKey}";
|
||||
await _cache.SetAsync(redisKey, cacheEntry.Genre, TimeSpan.FromDays(30));
|
||||
await _cache.SetAsync(redisKey, cacheEntry.Genre, CacheExtensions.GenreTTL);
|
||||
warmedCount++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm genre cache from file: {File}", file);
|
||||
_logger.LogError(ex, "Failed to warm genre cache from file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
if (warmedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("🔥 Warmed {Count} genre entries from file cache", warmedCount);
|
||||
_logger.LogDebug("🔥 Warmed {Count} genre entries from file cache", warmedCount);
|
||||
}
|
||||
|
||||
return warmedCount;
|
||||
@@ -161,8 +161,8 @@ public class CacheWarmingService : IHostedService
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
var playlistName = fileName.Replace("_items", "");
|
||||
|
||||
var redisKey = $"spotify:playlist:items:{playlistName}";
|
||||
await _cache.SetAsync(redisKey, items, TimeSpan.FromHours(24));
|
||||
var redisKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
|
||||
await _cache.SetAsync(redisKey, items, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||
warmedCount++;
|
||||
|
||||
_logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)",
|
||||
@@ -171,7 +171,7 @@ public class CacheWarmingService : IHostedService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm playlist items cache from file: {File}", file);
|
||||
_logger.LogError(ex, "Failed to warm playlist items cache from file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,23 +199,23 @@ public class CacheWarmingService : IHostedService
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
var playlistName = fileName.Replace("_matched", "");
|
||||
|
||||
var redisKey = $"spotify:matched:ordered:{playlistName}";
|
||||
await _cache.SetAsync(redisKey, matchedTracks, TimeSpan.FromHours(1));
|
||||
var redisKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
|
||||
await _cache.SetAsync(redisKey, matchedTracks, CacheExtensions.SpotifyMatchedTracksTTL);
|
||||
warmedCount++;
|
||||
|
||||
_logger.LogDebug("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
|
||||
_logger.LogInformation("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
|
||||
playlistName, matchedTracks.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm matched tracks cache from file: {File}", file);
|
||||
_logger.LogError(ex, "Failed to warm matched tracks cache from file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
if (warmedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("🔥 Warmed {Count} playlist caches from file system", warmedCount);
|
||||
_logger.LogDebug("🔥 Warmed {Count} playlist caches from file system", warmedCount);
|
||||
}
|
||||
|
||||
return warmedCount;
|
||||
@@ -276,13 +276,13 @@ public class CacheWarmingService : IHostedService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm manual mappings from file: {File}", file);
|
||||
_logger.LogError(ex, "Failed to warm manual mappings from file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
if (warmedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("🔥 Warmed {Count} manual mappings from file system", warmedCount);
|
||||
_logger.LogDebug("🔥 Warmed {Count} manual mappings from file system", warmedCount);
|
||||
}
|
||||
|
||||
return warmedCount;
|
||||
@@ -318,13 +318,13 @@ public class CacheWarmingService : IHostedService
|
||||
await _cache.SetStringAsync(redisKey, mapping.LyricsId.ToString());
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔥 Warmed {Count} lyrics mappings from file system", mappings.Count);
|
||||
_logger.LogDebug("🔥 Warmed {Count} lyrics mappings from file system", mappings.Count);
|
||||
return mappings.Count;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm lyrics mappings from file: {File}", mappingsFile);
|
||||
_logger.LogError(ex, "Failed to warm lyrics mappings from file: {File}", mappingsFile);
|
||||
}
|
||||
|
||||
return 0;
|
||||
@@ -356,7 +356,7 @@ public class CacheWarmingService : IHostedService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm lyrics cache");
|
||||
_logger.LogError(ex, "Failed to warm lyrics cache");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ public class EndpointBenchmarkService
|
||||
/// <summary>
|
||||
/// Benchmarks a list of endpoints by making test requests.
|
||||
/// Returns endpoints sorted by average response time (fastest first).
|
||||
///
|
||||
/// IMPORTANT: The testFunc should implement its own timeout to prevent slow endpoints
|
||||
/// from blocking startup. Recommended: 5-10 second timeout per ping.
|
||||
/// </summary>
|
||||
public async Task<List<string>> BenchmarkEndpointsAsync(
|
||||
List<string> endpoints,
|
||||
@@ -27,7 +30,7 @@ public class EndpointBenchmarkService
|
||||
int pingCount = 3,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("🏁 Benchmarking {Count} endpoints with {Pings} pings each...", endpoints.Count, pingCount);
|
||||
_logger.LogDebug("🏁 Benchmarking {Count} endpoints with {Pings} pings each...", endpoints.Count, pingCount);
|
||||
|
||||
var tasks = endpoints.Select(async endpoint =>
|
||||
{
|
||||
@@ -51,7 +54,7 @@ public class EndpointBenchmarkService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Benchmark ping failed for {Endpoint}", endpoint);
|
||||
_logger.LogError(ex, "Benchmark ping failed for {Endpoint}", endpoint);
|
||||
}
|
||||
|
||||
// Small delay between pings
|
||||
@@ -82,7 +85,7 @@ public class EndpointBenchmarkService
|
||||
_lock.Release();
|
||||
}
|
||||
|
||||
_logger.LogInformation(" {Endpoint}: {AvgMs}ms avg, {SuccessRate:P0} success rate",
|
||||
_logger.LogDebug(" {Endpoint}: {AvgMs}ms avg, {SuccessRate:P0} success rate",
|
||||
endpoint, avgMs, metrics.SuccessRate);
|
||||
|
||||
return metrics;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Service that runs on startup to migrate old .env file format to new format
|
||||
/// </summary>
|
||||
public class EnvMigrationService
|
||||
{
|
||||
private readonly ILogger<EnvMigrationService> _logger;
|
||||
private readonly string _envFilePath;
|
||||
|
||||
public EnvMigrationService(ILogger<EnvMigrationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_envFilePath = Path.Combine(Directory.GetCurrentDirectory(), ".env");
|
||||
}
|
||||
|
||||
public void MigrateEnvFile()
|
||||
{
|
||||
if (!File.Exists(_envFilePath))
|
||||
{
|
||||
_logger.LogWarning("No .env file found, skipping migration");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var lines = File.ReadAllLines(_envFilePath);
|
||||
var modified = false;
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
|
||||
continue;
|
||||
|
||||
// Migrate DOWNLOAD_PATH to Library__DownloadPath
|
||||
if (line.StartsWith("DOWNLOAD_PATH="))
|
||||
{
|
||||
var value = line.Substring("DOWNLOAD_PATH=".Length);
|
||||
lines[i] = $"Library__DownloadPath={value}";
|
||||
modified = true;
|
||||
_logger.LogDebug("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file");
|
||||
}
|
||||
|
||||
// Migrate old SquidWTF quality values to new format
|
||||
if (line.StartsWith("SQUIDWTF_QUALITY="))
|
||||
{
|
||||
var value = line.Substring("SQUIDWTF_QUALITY=".Length).Trim();
|
||||
var newValue = value.ToUpperInvariant() switch
|
||||
{
|
||||
"FLAC" => "LOSSLESS",
|
||||
"HI_RES" => "HI_RES_LOSSLESS",
|
||||
"MP3_320" => "HIGH",
|
||||
"MP3_128" => "LOW",
|
||||
_ => null // Keep as-is if already correct
|
||||
};
|
||||
|
||||
if (newValue != null)
|
||||
{
|
||||
lines[i] = $"SQUIDWTF_QUALITY={newValue}";
|
||||
modified = true;
|
||||
_logger.LogInformation("Migrated SQUIDWTF_QUALITY from {Old} to {New} in .env file", value, newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified)
|
||||
{
|
||||
File.WriteAllLines(_envFilePath, lines);
|
||||
_logger.LogInformation("✅ .env file migration completed successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to migrate .env file - please manually update DOWNLOAD_PATH to Library__DownloadPath");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for filtering songs based on explicit content settings.
|
||||
/// Centralizes explicit content filtering logic used across metadata services.
|
||||
/// </summary>
|
||||
public static class ExplicitContentFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if a song should be included based on explicit content filter settings.
|
||||
/// </summary>
|
||||
/// <param name="song">The song to check</param>
|
||||
/// <param name="filter">The explicit content filter setting</param>
|
||||
/// <returns>True if the song should be included, false otherwise</returns>
|
||||
public static bool ShouldIncludeSong(Song song, ExplicitFilter filter)
|
||||
{
|
||||
// If no explicit content info, include the song
|
||||
if (song.ExplicitContentLyrics == null)
|
||||
return true;
|
||||
|
||||
return filter switch
|
||||
{
|
||||
// All: No filtering, include everything
|
||||
ExplicitFilter.All => true,
|
||||
|
||||
// ExplicitOnly: Exclude clean/edited versions (value 3)
|
||||
// Include: 0 (naturally clean), 1 (explicit), 2 (not applicable), 6/7 (unknown)
|
||||
ExplicitFilter.ExplicitOnly => song.ExplicitContentLyrics != 3,
|
||||
|
||||
// CleanOnly: Only show clean content
|
||||
// Include: 0 (naturally clean), 3 (clean/edited version)
|
||||
// Exclude: 1 (explicit)
|
||||
ExplicitFilter.CleanOnly => song.ExplicitContentLyrics != 1,
|
||||
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,8 @@ public static class FuzzyMatcher
|
||||
/// Calculates similarity score following OPTIMAL ORDER:
|
||||
/// 1. Strip decorators (already done by caller)
|
||||
/// 2. Substring matching (cheap, high-precision)
|
||||
/// 3. Levenshtein distance (expensive, fuzzy)
|
||||
/// 3. Token-based matching (handles word order)
|
||||
/// 4. Levenshtein distance (expensive, fuzzy)
|
||||
/// Returns score 0-100.
|
||||
/// </summary>
|
||||
public static int CalculateSimilarity(string query, string target)
|
||||
@@ -103,11 +104,71 @@ public static class FuzzyMatcher
|
||||
return 85;
|
||||
}
|
||||
|
||||
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
||||
// Only use this for candidates that survived substring checks
|
||||
// STEP 3: TOKEN-BASED MATCHING (handles word order)
|
||||
var tokens1 = queryNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var tokens2 = targetNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var distance = LevenshteinDistance(queryNorm, targetNorm);
|
||||
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
|
||||
if (tokens1.Length > 0 && tokens2.Length > 0)
|
||||
{
|
||||
// Calculate how many tokens match (order-independent)
|
||||
var matchedTokens = 0.0; // Use double for partial matches
|
||||
var usedTokens = new HashSet<int>();
|
||||
|
||||
foreach (var token1 in tokens1)
|
||||
{
|
||||
for (int i = 0; i < tokens2.Length; i++)
|
||||
{
|
||||
if (usedTokens.Contains(i)) continue;
|
||||
|
||||
var token2 = tokens2[i];
|
||||
|
||||
// Exact token match
|
||||
if (token1 == token2)
|
||||
{
|
||||
matchedTokens++;
|
||||
usedTokens.Add(i);
|
||||
break;
|
||||
}
|
||||
// Partial token match (one contains the other)
|
||||
else if (token1.Contains(token2) || token2.Contains(token1))
|
||||
{
|
||||
matchedTokens += 0.8; // Partial credit
|
||||
usedTokens.Add(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate token match percentage
|
||||
var maxTokens = Math.Max(tokens1.Length, tokens2.Length);
|
||||
var tokenMatchScore = (matchedTokens / maxTokens) * 100.0;
|
||||
|
||||
// If token match is very high (90%+), return it
|
||||
if (tokenMatchScore >= 90)
|
||||
{
|
||||
return (int)Math.Round(tokenMatchScore, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
// If token match is decent (70%+), use it as a floor for Levenshtein
|
||||
if (tokenMatchScore >= 70)
|
||||
{
|
||||
var levenshteinScore = CalculateLevenshteinScore(queryNorm, targetNorm);
|
||||
return (int)Math.Max(tokenMatchScore, levenshteinScore);
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 4: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
||||
return CalculateLevenshteinScore(queryNorm, targetNorm);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates similarity score based on Levenshtein distance.
|
||||
/// Returns score 0-75 (reserve 75-100 for substring/token matches).
|
||||
/// </summary>
|
||||
private static int CalculateLevenshteinScore(string str1, string str2)
|
||||
{
|
||||
var distance = LevenshteinDistance(str1, str2);
|
||||
var maxLength = Math.Max(str1.Length, str2.Length);
|
||||
|
||||
if (maxLength == 0)
|
||||
{
|
||||
@@ -117,8 +178,9 @@ public static class FuzzyMatcher
|
||||
// Normalize distance by length: score = 1 - (distance / max_length)
|
||||
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
||||
|
||||
// Convert to 0-80 range (reserve 80-100 for substring matches)
|
||||
var score = (int)(normalizedSimilarity * 80);
|
||||
// Convert to 0-75 range (reserve 75-100 for substring/token matches)
|
||||
// Using 75 instead of 80 to be slightly stricter
|
||||
var score = (int)(normalizedSimilarity * 75);
|
||||
|
||||
return Math.Max(0, score);
|
||||
}
|
||||
@@ -154,7 +216,9 @@ public static class FuzzyMatcher
|
||||
/// <summary>
|
||||
/// Normalizes a string for matching by:
|
||||
/// - Converting to lowercase
|
||||
/// - Normalizing apostrophes (', ', ') to standard '
|
||||
/// - Removing accents/diacritics
|
||||
/// - Converting hyphens/underscores to spaces (for word separation)
|
||||
/// - Removing other punctuation (periods, apostrophes, commas, etc.)
|
||||
/// - Removing extra whitespace
|
||||
/// </summary>
|
||||
private static string NormalizeForMatching(string text)
|
||||
@@ -166,19 +230,43 @@ public static class FuzzyMatcher
|
||||
|
||||
var normalized = text.ToLowerInvariant().Trim();
|
||||
|
||||
// Normalize different apostrophe types to standard apostrophe
|
||||
normalized = normalized
|
||||
.Replace("\u2019", "'") // Right single quotation mark (')
|
||||
.Replace("\u2018", "'") // Left single quotation mark (')
|
||||
.Replace("`", "'") // Grave accent
|
||||
.Replace("\u00B4", "'"); // Acute accent (´)
|
||||
// Remove accents/diacritics (é -> e, ñ -> n, etc.)
|
||||
normalized = RemoveDiacritics(normalized);
|
||||
|
||||
// Replace hyphens and underscores with spaces (for word separation)
|
||||
// This ensures "Dua-Lipa" becomes "Dua Lipa" not "DuaLipa"
|
||||
normalized = normalized.Replace('-', ' ').Replace('_', ' ');
|
||||
|
||||
// Remove all other punctuation: periods, apostrophes, commas, etc.
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", "");
|
||||
|
||||
// Normalize whitespace
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes diacritics (accents) from characters.
|
||||
/// Example: é -> e, ñ -> n, ü -> u
|
||||
/// </summary>
|
||||
private static string RemoveDiacritics(string text)
|
||||
{
|
||||
var normalizedString = text.Normalize(System.Text.NormalizationForm.FormD);
|
||||
var stringBuilder = new System.Text.StringBuilder();
|
||||
|
||||
foreach (var c in normalizedString)
|
||||
{
|
||||
var unicodeCategory = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (unicodeCategory != System.Globalization.UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return stringBuilder.ToString().Normalize(System.Text.NormalizationForm.FormC);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Levenshtein distance between two strings.
|
||||
/// </summary>
|
||||
|
||||
@@ -93,7 +93,7 @@ public class GenreEnrichmentService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to enrich genre for {Title} - {Artist}",
|
||||
_logger.LogError(ex, "Failed to enrich genre for {Title} - {Artist}",
|
||||
song.Title, song.Artist);
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ public class GenreEnrichmentService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read genre from file cache for {Key}", cacheKey);
|
||||
_logger.LogError(ex, "Failed to read genre from file cache for {Key}", cacheKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,7 @@ public class GenreEnrichmentService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save genre to file cache for {Key}", cacheKey);
|
||||
_logger.LogError(ex, "Failed to save genre to file cache for {Key}", cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,10 +63,10 @@ public class OdesliService
|
||||
if (match.Success)
|
||||
{
|
||||
var spotifyId = match.Groups[1].Value;
|
||||
_logger.LogInformation("✓ Converted Tidal/{TidalId} → Spotify ID {SpotifyId}", tidalTrackId, spotifyId);
|
||||
_logger.LogDebug("✓ Converted Tidal/{TidalId} → Spotify ID {SpotifyId}", tidalTrackId, spotifyId);
|
||||
|
||||
// Cache for 7 days
|
||||
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
|
||||
// Cache for configurable duration
|
||||
await _cache.SetAsync(cacheKey, spotifyId, CacheExtensions.OdesliLookupTTL);
|
||||
|
||||
return spotifyId;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public class OdesliService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to convert Tidal track to Spotify ID via Odesli");
|
||||
_logger.LogError(ex, "Failed to convert Tidal track to Spotify ID via Odesli");
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -122,10 +122,10 @@ public class OdesliService
|
||||
if (match.Success)
|
||||
{
|
||||
var spotifyId = match.Groups[1].Value;
|
||||
_logger.LogInformation("✓ Converted URL → Spotify ID {SpotifyId}", spotifyId);
|
||||
_logger.LogDebug("✓ Converted URL → Spotify ID {SpotifyId}", spotifyId);
|
||||
|
||||
// Cache for 7 days
|
||||
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
|
||||
// Cache for configurable duration
|
||||
await _cache.SetAsync(cacheKey, spotifyId, CacheExtensions.OdesliLookupTTL);
|
||||
|
||||
return spotifyId;
|
||||
}
|
||||
@@ -135,7 +135,7 @@ public class OdesliService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to convert URL to Spotify ID via Odesli");
|
||||
_logger.LogError(ex, "Failed to convert URL to Spotify ID via Odesli");
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -51,7 +51,7 @@ public class ParallelMetadataService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "❌ {Provider} search failed", providerName);
|
||||
_logger.LogError(ex, "❌ {Provider} search failed", providerName);
|
||||
return (Success: false, Result: new SearchResult(), Provider: providerName, ElapsedMs: 0L);
|
||||
}
|
||||
}).ToList();
|
||||
@@ -64,7 +64,7 @@ public class ParallelMetadataService
|
||||
|
||||
if (result.Success && (result.Result.Songs.Any() || result.Result.Albums.Any() || result.Result.Artists.Any()))
|
||||
{
|
||||
_logger.LogInformation("🏆 Using results from {Provider} ({Ms}ms) - fastest with results",
|
||||
_logger.LogDebug("🏆 Using results from {Provider} ({Ms}ms) - fastest with results",
|
||||
result.Provider, result.ElapsedMs);
|
||||
return result.Result;
|
||||
}
|
||||
@@ -110,7 +110,7 @@ public class ParallelMetadataService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "❌ {Provider} song search failed", providerName);
|
||||
_logger.LogError(ex, "❌ {Provider} song search failed", providerName);
|
||||
return (Success: false, Song: (Song?)null, Provider: providerName, ElapsedMs: 0L);
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
@@ -28,8 +28,10 @@ public static class PathHelper
|
||||
/// <param name="title">Track title (will be sanitized).</param>
|
||||
/// <param name="trackNumber">Optional track number for prefix.</param>
|
||||
/// <param name="extension">File extension (e.g., ".flac", ".mp3").</param>
|
||||
/// <param name="provider">Optional provider name (e.g., "squidwtf", "deezer").</param>
|
||||
/// <param name="externalId">Optional external ID from the provider.</param>
|
||||
/// <returns>Full path for the track file.</returns>
|
||||
public static string BuildTrackPath(string downloadPath, string artist, string album, string title, int? trackNumber, string extension)
|
||||
public static string BuildTrackPath(string downloadPath, string artist, string album, string title, int? trackNumber, string extension, string? provider = null, string? externalId = null)
|
||||
{
|
||||
var safeArtist = SanitizeFolderName(artist);
|
||||
var safeAlbum = SanitizeFolderName(album);
|
||||
@@ -39,7 +41,24 @@ public static class PathHelper
|
||||
var albumFolder = Path.Combine(artistFolder, safeAlbum);
|
||||
|
||||
var trackPrefix = trackNumber.HasValue ? $"{trackNumber:D2} - " : "";
|
||||
var fileName = $"{trackPrefix}{safeTitle}{extension}";
|
||||
// Sanitize provider and external id to avoid path traversal or invalid filename segments
|
||||
string? safeProvider = null;
|
||||
string? safeExternalId = null;
|
||||
if (!string.IsNullOrEmpty(provider))
|
||||
{
|
||||
safeProvider = SanitizeFileName(provider);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(externalId))
|
||||
{
|
||||
safeExternalId = SanitizeFileName(externalId);
|
||||
}
|
||||
|
||||
// If both provider and external id are present, append a sanitized id suffix
|
||||
var idSuffix = (!string.IsNullOrEmpty(safeProvider) && !string.IsNullOrEmpty(safeExternalId))
|
||||
? $" [{safeProvider}-{safeExternalId}]"
|
||||
: "";
|
||||
var fileName = $"{trackPrefix}{safeTitle}{idSuffix}{extension}";
|
||||
|
||||
return Path.Combine(albumFolder, fileName);
|
||||
}
|
||||
@@ -61,12 +80,24 @@ public static class PathHelper
|
||||
.Select(c => invalidChars.Contains(c) ? '_' : c)
|
||||
.ToArray());
|
||||
|
||||
// Collapse sequences of two or more dots to a single underscore to avoid
|
||||
// creating ".." which can be interpreted as parent directory tokens.
|
||||
sanitized = System.Text.RegularExpressions.Regex.Replace(sanitized, "\\.{2,}", "_");
|
||||
|
||||
// Remove any remaining path separators just in case
|
||||
sanitized = sanitized.Replace('/', '_').Replace('\\', '_');
|
||||
|
||||
// Trim whitespace and trailing/leading dots
|
||||
sanitized = sanitized.Trim().TrimEnd('.').TrimStart('.');
|
||||
|
||||
if (sanitized.Length > 100)
|
||||
{
|
||||
sanitized = sanitized[..100];
|
||||
sanitized = sanitized[..100].TrimEnd('.');
|
||||
}
|
||||
|
||||
return sanitized.Trim();
|
||||
if (string.IsNullOrWhiteSpace(sanitized)) return "Unknown";
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -114,21 +145,38 @@ public static class PathHelper
|
||||
/// <returns>Unique file path that does not exist yet.</returns>
|
||||
public static string ResolveUniquePath(string basePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(basePath))
|
||||
{
|
||||
throw new ArgumentException("basePath must be provided", nameof(basePath));
|
||||
}
|
||||
|
||||
if (!IOFile.Exists(basePath))
|
||||
{
|
||||
return basePath;
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(basePath)!;
|
||||
var directory = Path.GetDirectoryName(basePath);
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
// If no directory part is present, use current directory
|
||||
directory = Directory.GetCurrentDirectory();
|
||||
}
|
||||
var extension = Path.GetExtension(basePath);
|
||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(basePath);
|
||||
|
||||
var counter = 1;
|
||||
string uniquePath;
|
||||
// Limit attempts to avoid infinite loop in pathological cases
|
||||
const int maxAttempts = 10000;
|
||||
do
|
||||
{
|
||||
uniquePath = Path.Combine(directory, $"{fileNameWithoutExt} ({counter}){extension}");
|
||||
counter++;
|
||||
|
||||
if (counter > maxAttempts)
|
||||
{
|
||||
throw new IOException("Unable to determine unique file path after many attempts");
|
||||
}
|
||||
} while (IOFile.Exists(uniquePath));
|
||||
|
||||
return uniquePath;
|
||||
|
||||
@@ -39,7 +39,7 @@ public class RedisCacheService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Redis connection failed. Caching disabled.");
|
||||
_logger.LogError(ex, "Redis connection failed. Caching disabled.");
|
||||
_redis = null;
|
||||
_db = null;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public class RedisCacheService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Redis GET failed for key: {Key}", key);
|
||||
_logger.LogError(ex, "Redis GET failed for key: {Key}", key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ public class RedisCacheService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize cached value for key: {Key}", key);
|
||||
_logger.LogError(ex, "Failed to deserialize cached value for key: {Key}", key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ public class RedisCacheService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Redis SET failed for key: {Key}", key);
|
||||
_logger.LogError(ex, "Redis SET failed for key: {Key}", key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ public class RedisCacheService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to serialize value for key: {Key}", key);
|
||||
_logger.LogError(ex, "Failed to serialize value for key: {Key}", key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ public class RedisCacheService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Redis DELETE failed for key: {Key}", key);
|
||||
_logger.LogError(ex, "Redis DELETE failed for key: {Key}", key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ public class RedisCacheService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Redis EXISTS failed for key: {Key}", key);
|
||||
_logger.LogError(ex, "Redis EXISTS failed for key: {Key}", key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -190,12 +190,12 @@ public class RedisCacheService
|
||||
}
|
||||
|
||||
var deleted = await _db!.KeyDeleteAsync(keys);
|
||||
_logger.LogInformation("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
|
||||
_logger.LogDebug("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
|
||||
return (int)deleted;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
|
||||
_logger.LogError(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Periodically snapshots Redis cache to file system for cold start recovery.
|
||||
/// Redis is the primary cache, files are the persistence layer.
|
||||
/// </summary>
|
||||
public class RedisPersistenceService : BackgroundService
|
||||
{
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<RedisPersistenceService> _logger;
|
||||
private readonly TimeSpan _snapshotInterval = TimeSpan.FromMinutes(5);
|
||||
private const string SnapshotDirectory = "/app/cache/redis-snapshots";
|
||||
|
||||
public RedisPersistenceService(
|
||||
RedisCacheService cache,
|
||||
ILogger<RedisPersistenceService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Wait 2 minutes after startup before first snapshot (let cache warm up)
|
||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CreateSnapshotAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating Redis snapshot");
|
||||
}
|
||||
|
||||
await Task.Delay(_snapshotInterval, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CreateSnapshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_cache.IsEnabled)
|
||||
{
|
||||
_logger.LogWarning("Redis is disabled, skipping snapshot");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(SnapshotDirectory);
|
||||
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss");
|
||||
var snapshotFile = Path.Combine(SnapshotDirectory, $"snapshot_{timestamp}.json");
|
||||
|
||||
// For now, we'll rely on Redis's built-in RDB + AOF persistence
|
||||
// This service is a placeholder for future enhancements like:
|
||||
// - Exporting specific key patterns to JSON
|
||||
// - Creating human-readable backups
|
||||
// - Syncing to external storage
|
||||
|
||||
_logger.LogDebug("Redis snapshot service running (using Redis native persistence)");
|
||||
|
||||
// Clean up old snapshots (keep last 10)
|
||||
await CleanupOldSnapshotsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create Redis snapshot");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanupOldSnapshotsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(SnapshotDirectory))
|
||||
return;
|
||||
|
||||
var files = Directory.GetFiles(SnapshotDirectory, "snapshot_*.json")
|
||||
.OrderByDescending(f => f)
|
||||
.Skip(10)
|
||||
.ToArray();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
File.Delete(file);
|
||||
_logger.LogDebug("Deleted old snapshot: {File}", Path.GetFileName(file));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to cleanup old snapshots");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Utility class for handling retry logic with exponential backoff.
|
||||
/// Centralizes retry patterns used across download and metadata services.
|
||||
/// </summary>
|
||||
public static class RetryHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes an async action with exponential backoff retry logic.
|
||||
/// Retries on HTTP 503 (Service Unavailable) and 429 (Too Many Requests).
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Return type of the action</typeparam>
|
||||
/// <param name="action">The async action to execute</param>
|
||||
/// <param name="logger">Logger for retry attempts</param>
|
||||
/// <param name="maxRetries">Maximum number of retry attempts (default: 3)</param>
|
||||
/// <param name="initialDelayMs">Initial delay in milliseconds (default: 1000)</param>
|
||||
/// <returns>Result of the action</returns>
|
||||
public static async Task<T> RetryWithBackoffAsync<T>(
|
||||
Func<Task<T>> action,
|
||||
ILogger logger,
|
||||
int maxRetries = 3,
|
||||
int initialDelayMs = 1000)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
|
||||
for (int attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await action();
|
||||
}
|
||||
catch (HttpRequestException ex) when (
|
||||
ex.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable ||
|
||||
ex.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
lastException = ex;
|
||||
if (attempt < maxRetries - 1)
|
||||
{
|
||||
var delay = initialDelayMs * (int)Math.Pow(2, attempt);
|
||||
logger.LogWarning(
|
||||
"Retry attempt {Attempt}/{MaxRetries} after {Delay}ms ({Message})",
|
||||
attempt + 1, maxRetries, delay, ex.Message);
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastException!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an async action with exponential backoff retry logic (void return).
|
||||
/// </summary>
|
||||
public static async Task RetryWithBackoffAsync(
|
||||
Func<Task> action,
|
||||
ILogger logger,
|
||||
int maxRetries = 3,
|
||||
int initialDelayMs = 1000)
|
||||
{
|
||||
await RetryWithBackoffAsync(async () =>
|
||||
{
|
||||
await action();
|
||||
return true;
|
||||
}, logger, maxRetries, initialDelayMs);
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ public class RoundRobinFallbackHelper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl);
|
||||
_logger.LogError(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl);
|
||||
|
||||
// Cache as unhealthy
|
||||
lock (_healthCacheLock)
|
||||
@@ -137,7 +137,7 @@ public class RoundRobinFallbackHelper
|
||||
_apiUrls.AddRange(reordered);
|
||||
_currentUrlIndex = 0;
|
||||
|
||||
_logger.LogInformation("📊 {Service} endpoints reordered by benchmark: {Endpoints}",
|
||||
_logger.LogDebug("📊 {Service} endpoints reordered by benchmark: {Endpoints}",
|
||||
_serviceName, string.Join(", ", _apiUrls.Take(3)));
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ public class RoundRobinFallbackHelper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||
_logger.LogError(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||
_serviceName, baseUrl);
|
||||
|
||||
// Mark as unhealthy in cache
|
||||
@@ -227,7 +227,7 @@ public class RoundRobinFallbackHelper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "{Service} race failed for endpoint {Endpoint}", _serviceName, baseUrl);
|
||||
_logger.LogError(ex, "{Service} race failed for endpoint {Endpoint}", _serviceName, baseUrl);
|
||||
return (default(T)!, baseUrl, false);
|
||||
}
|
||||
}, raceCts.Token);
|
||||
@@ -243,7 +243,7 @@ public class RoundRobinFallbackHelper
|
||||
|
||||
if (success)
|
||||
{
|
||||
_logger.LogInformation("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
|
||||
_logger.LogDebug("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
|
||||
raceCts.Cancel(); // Cancel all other requests
|
||||
return result;
|
||||
}
|
||||
@@ -291,7 +291,7 @@ public class RoundRobinFallbackHelper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||
_logger.LogError(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||
_serviceName, baseUrl);
|
||||
|
||||
// Mark as unhealthy in cache
|
||||
|
||||
@@ -81,15 +81,6 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
}
|
||||
}
|
||||
|
||||
protected override string? ExtractExternalIdFromAlbumId(string albumId)
|
||||
{
|
||||
const string prefix = "ext-deezer-album-";
|
||||
if (albumId.StartsWith(prefix))
|
||||
{
|
||||
return albumId[prefix.Length..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -111,7 +102,7 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine("downloads", "cache")
|
||||
: Path.Combine("downloads", "permanent");
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "deezer", trackId);
|
||||
|
||||
// Create directories if they don't exist
|
||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||
@@ -121,14 +112,14 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
||||
|
||||
// Download the encrypted file
|
||||
var response = await RetryWithBackoffAsync(async () =>
|
||||
var response = await RetryHelper.RetryWithBackoffAsync(async () =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
|
||||
request.Headers.Add("User-Agent", "Mozilla/5.0");
|
||||
request.Headers.Add("Accept", "*/*");
|
||||
|
||||
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
});
|
||||
}, Logger);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
@@ -159,7 +150,7 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
throw new Exception("ARL token required for Deezer downloads");
|
||||
}
|
||||
|
||||
await RetryWithBackoffAsync(async () =>
|
||||
await RetryHelper.RetryWithBackoffAsync(async () =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post,
|
||||
"https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token=null");
|
||||
@@ -186,11 +177,12 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
}
|
||||
|
||||
Logger.LogInformation("Deezer token refreshed successfully");
|
||||
return true;
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
throw new Exception("Invalid ARL token");
|
||||
});
|
||||
}
|
||||
}, Logger);
|
||||
}
|
||||
|
||||
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
|
||||
@@ -457,43 +449,6 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<T> RetryWithBackoffAsync<T>(Func<Task<T>> action, int maxRetries = 3, int initialDelayMs = 1000)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
|
||||
for (int attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await action();
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable ||
|
||||
ex.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
lastException = ex;
|
||||
if (attempt < maxRetries - 1)
|
||||
{
|
||||
var delay = initialDelayMs * (int)Math.Pow(2, attempt);
|
||||
Logger.LogWarning("Retry attempt {Attempt}/{MaxRetries} after {Delay}ms ({Message})",
|
||||
attempt + 1, maxRetries, delay, ex.Message);
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastException!;
|
||||
}
|
||||
|
||||
private async Task RetryWithBackoffAsync(Func<Task<bool>> action, int maxRetries = 3, int initialDelayMs = 1000)
|
||||
{
|
||||
await RetryWithBackoffAsync<bool>(action, maxRetries, initialDelayMs);
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
private class DownloadResult
|
||||
|
||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services.Common;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -15,12 +16,17 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SubsonicSettings _settings;
|
||||
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||
private const string BaseUrl = "https://api.deezer.com";
|
||||
|
||||
public DeezerMetadataService(IHttpClientFactory httpClientFactory, IOptions<SubsonicSettings> settings)
|
||||
public DeezerMetadataService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<SubsonicSettings> settings,
|
||||
GenreEnrichmentService? genreEnrichment = null)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_settings = settings.Value;
|
||||
_genreEnrichment = genreEnrichment;
|
||||
}
|
||||
|
||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||
@@ -41,7 +47,7 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
foreach (var track in data.EnumerateArray())
|
||||
{
|
||||
var song = ParseDeezerTrack(track);
|
||||
if (ShouldIncludeSong(song))
|
||||
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
|
||||
{
|
||||
songs.Add(song);
|
||||
}
|
||||
@@ -203,6 +209,23 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich with MusicBrainz genres if missing
|
||||
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
|
||||
{
|
||||
// Silently ignore genre enrichment failures
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return song;
|
||||
}
|
||||
|
||||
@@ -237,7 +260,7 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
song.AlbumId = album.Id;
|
||||
song.AlbumArtist = album.Artist;
|
||||
|
||||
if (ShouldIncludeSong(song))
|
||||
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
|
||||
{
|
||||
album.Songs.Add(song);
|
||||
}
|
||||
@@ -384,17 +407,23 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
}
|
||||
}
|
||||
|
||||
// Contributors
|
||||
// Contributors (all artists including features)
|
||||
var contributors = new List<string>();
|
||||
var contributorIds = new List<string>();
|
||||
if (track.TryGetProperty("contributors", out var contribs))
|
||||
{
|
||||
foreach (var contrib in contribs.EnumerateArray())
|
||||
{
|
||||
if (contrib.TryGetProperty("name", out var contribName))
|
||||
if (contrib.TryGetProperty("name", out var contribName) &&
|
||||
contrib.TryGetProperty("id", out var contribId))
|
||||
{
|
||||
var name = contribName.GetString();
|
||||
var id = contribId.GetInt64();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
contributors.Add(name);
|
||||
contributorIds.Add($"ext-deezer-artist-{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,6 +466,8 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
ArtistId = track.TryGetProperty("artist", out var artistForId)
|
||||
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
|
||||
: null,
|
||||
Artists = contributors.Count > 0 ? contributors : new List<string>(),
|
||||
ArtistIds = contributorIds.Count > 0 ? contributorIds : new List<string>(),
|
||||
Album = track.TryGetProperty("album", out var album)
|
||||
? album.GetProperty("title").GetString() ?? ""
|
||||
: "",
|
||||
@@ -605,7 +636,7 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
// Override album name to be the playlist name
|
||||
song.Album = playlistName;
|
||||
|
||||
if (ShouldIncludeSong(song))
|
||||
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
|
||||
{
|
||||
songs.Add(song);
|
||||
}
|
||||
@@ -673,33 +704,4 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
CreatedDate = createdDate
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a song should be included based on the explicit content filter setting
|
||||
/// </summary>
|
||||
/// <param name="song">The song to check</param>
|
||||
/// <returns>True if the song should be included, false otherwise</returns>
|
||||
private bool ShouldIncludeSong(Song song)
|
||||
{
|
||||
// If no explicit content info, include the song
|
||||
if (song.ExplicitContentLyrics == null)
|
||||
return true;
|
||||
|
||||
return _settings.ExplicitFilter switch
|
||||
{
|
||||
// All: No filtering, include everything
|
||||
ExplicitFilter.All => true,
|
||||
|
||||
// ExplicitOnly: Exclude clean/edited versions (value 3)
|
||||
// Include: 0 (naturally clean), 1 (explicit), 2 (not applicable), 6/7 (unknown)
|
||||
ExplicitFilter.ExplicitOnly => song.ExplicitContentLyrics != 3,
|
||||
|
||||
// CleanOnly: Only show clean content
|
||||
// Include: 0 (naturally clean), 3 (clean/edited version)
|
||||
// Exclude: 1 (explicit)
|
||||
ExplicitFilter.CleanOnly => song.ExplicitContentLyrics != 1,
|
||||
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ public class JellyfinModelMapper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error parsing Jellyfin items response");
|
||||
_logger.LogError(ex, "Error parsing Jellyfin items response");
|
||||
}
|
||||
|
||||
return (songs, albums, artists);
|
||||
@@ -126,7 +126,7 @@ public class JellyfinModelMapper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error parsing Jellyfin search hints response");
|
||||
_logger.LogError(ex, "Error parsing Jellyfin search hints response");
|
||||
}
|
||||
|
||||
return (songs, albums, artists);
|
||||
|
||||
@@ -176,64 +176,11 @@ public class JellyfinProxyService
|
||||
// Forward authentication headers from client if provided
|
||||
if (clientHeaders != null && clientHeaders.Count > 0)
|
||||
{
|
||||
// Try X-Emby-Authorization first (case-insensitive)
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogTrace("Forwarded X-Emby-Authorization header");
|
||||
break;
|
||||
}
|
||||
}
|
||||
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
||||
|
||||
// Try X-Emby-Token (simpler format used by some clients)
|
||||
if (!authHeaderAdded)
|
||||
if (authHeaderAdded)
|
||||
{
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogTrace("Forwarded X-Emby-Token header");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
|
||||
// Some clients send it as "Authorization" instead of "X-Emby-Authorization"
|
||||
if (!authHeaderAdded)
|
||||
{
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
|
||||
// Check if it's MediaBrowser/Jellyfin format (contains "MediaBrowser" or "Token=")
|
||||
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
|
||||
headerValue.Contains("Token=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Forward as X-Emby-Authorization (Jellyfin's expected header)
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Bearer token - forward as-is
|
||||
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogTrace("Forwarded Authorization header");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
_logger.LogTrace("Forwarded authentication headers");
|
||||
}
|
||||
|
||||
// Check for api_key query parameter (some clients use this)
|
||||
@@ -265,11 +212,11 @@ public class JellyfinProxyService
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
// 401 means token expired or invalid - client needs to re-authenticate
|
||||
_logger.LogInformation("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
|
||||
_logger.LogDebug("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
|
||||
}
|
||||
else if (!isBrowserStaticRequest && !isPublicEndpoint)
|
||||
{
|
||||
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
|
||||
_logger.LogError("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
|
||||
}
|
||||
|
||||
// Try to parse error response to pass through to client
|
||||
@@ -329,63 +276,12 @@ public class JellyfinProxyService
|
||||
bool authHeaderAdded = false;
|
||||
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Forward authentication headers from client (case-insensitive)
|
||||
// Try X-Emby-Authorization first
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogTrace("Forwarded X-Emby-Authorization header");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Forward authentication headers from client
|
||||
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
||||
|
||||
// Try X-Emby-Token
|
||||
if (!authHeaderAdded)
|
||||
if (authHeaderAdded)
|
||||
{
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogTrace("Forwarded X-Emby-Token header");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try Authorization header
|
||||
if (!authHeaderAdded)
|
||||
{
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
|
||||
// Check if it's MediaBrowser/Jellyfin format
|
||||
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
|
||||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Forward as X-Emby-Authorization
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Bearer token
|
||||
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
||||
_logger.LogTrace("Forwarded Authorization header");
|
||||
}
|
||||
authHeaderAdded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_logger.LogTrace("Forwarded authentication headers");
|
||||
}
|
||||
|
||||
// For authentication endpoints, credentials are in the body, not headers
|
||||
@@ -418,11 +314,11 @@ public class JellyfinProxyService
|
||||
// 401 is expected when tokens expire - don't spam logs
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_logger.LogInformation("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
|
||||
_logger.LogDebug("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||
_logger.LogError("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||
response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent);
|
||||
}
|
||||
|
||||
@@ -536,55 +432,21 @@ public class JellyfinProxyService
|
||||
|
||||
bool authHeaderAdded = false;
|
||||
|
||||
// Forward authentication headers from client (case-insensitive)
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Forward authentication headers from client
|
||||
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
||||
|
||||
if (!authHeaderAdded)
|
||||
{
|
||||
foreach (var header in clientHeaders)
|
||||
{
|
||||
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var headerValue = header.Value.ToString();
|
||||
|
||||
// Check if it's MediaBrowser/Jellyfin format
|
||||
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
|
||||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Forward as X-Emby-Authorization
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
|
||||
_logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Bearer token
|
||||
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
|
||||
_logger.LogDebug("Forwarded Authorization header");
|
||||
}
|
||||
authHeaderAdded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!authHeaderAdded)
|
||||
{
|
||||
_logger.LogInformation("No client auth provided for DELETE {Url} - forwarding without auth", url);
|
||||
_logger.LogTrace("Forwarded authentication headers");
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
_logger.LogInformation("DELETE to Jellyfin: {Url}", url);
|
||||
_logger.LogDebug("DELETE to Jellyfin: {Url}", url);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
@@ -593,7 +455,7 @@ public class JellyfinProxyService
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||
_logger.LogError("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||
response.StatusCode, url, errorContent);
|
||||
return (null, statusCode);
|
||||
}
|
||||
@@ -629,7 +491,7 @@ public class JellyfinProxyService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get bytes from {Endpoint}", endpoint);
|
||||
_logger.LogError(ex, "Failed to get bytes from {Endpoint}", endpoint);
|
||||
return (null, null, false);
|
||||
}
|
||||
}
|
||||
@@ -662,7 +524,7 @@ public class JellyfinProxyService
|
||||
if (!string.IsNullOrEmpty(_settings.LibraryId))
|
||||
{
|
||||
queryParams["parentId"] = _settings.LibraryId;
|
||||
_logger.LogDebug("Searching within configured LibraryId {LibraryId}", _settings.LibraryId);
|
||||
_logger.LogInformation("Searching within configured LibraryId {LibraryId}", _settings.LibraryId);
|
||||
}
|
||||
|
||||
if (includeItemTypes != null && includeItemTypes.Length > 0)
|
||||
@@ -932,7 +794,7 @@ public class JellyfinProxyService
|
||||
if (result.Success && result.Body != null)
|
||||
{
|
||||
var cacheValue = $"{Convert.ToBase64String(result.Body)}|{result.ContentType}";
|
||||
await _cache.SetStringAsync(cacheKey, cacheValue, TimeSpan.FromDays(7));
|
||||
await _cache.SetStringAsync(cacheKey, cacheValue, CacheExtensions.ProxyImagesTTL);
|
||||
}
|
||||
|
||||
return (result.Body, result.ContentType);
|
||||
|
||||
@@ -263,9 +263,11 @@ public class JellyfinResponseBuilder
|
||||
["Name"] = songTitle,
|
||||
["ServerId"] = "allstarr",
|
||||
["Id"] = song.Id,
|
||||
["PlaylistItemId"] = song.Id, // Required for playlist items
|
||||
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
||||
["Container"] = "flac",
|
||||
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
|
||||
["DateCreated"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"),
|
||||
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
||||
["ProductionYear"] = song.Year,
|
||||
["IndexNumber"] = song.Track,
|
||||
@@ -273,6 +275,7 @@ public class JellyfinResponseBuilder
|
||||
["IsFolder"] = false,
|
||||
["Type"] = "Audio",
|
||||
["ChannelId"] = (object?)null,
|
||||
["ParentId"] = song.AlbumId,
|
||||
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
||||
? new[] { song.Genre }
|
||||
: new string[0],
|
||||
@@ -286,6 +289,9 @@ public class JellyfinResponseBuilder
|
||||
}
|
||||
}
|
||||
: new Dictionary<string, object?>[0],
|
||||
["Tags"] = new string[0],
|
||||
["People"] = new object[0],
|
||||
["SortName"] = songTitle,
|
||||
["ParentLogoItemId"] = song.AlbumId,
|
||||
["ParentBackdropItemId"] = song.AlbumId,
|
||||
["ParentBackdropImageTags"] = new string[0],
|
||||
@@ -299,13 +305,11 @@ public class JellyfinResponseBuilder
|
||||
["ItemId"] = song.Id
|
||||
},
|
||||
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
|
||||
["ArtistItems"] = artistNames.Count > 0
|
||||
["ArtistItems"] = artistNames.Count > 0 && song.ArtistIds.Count == artistNames.Count
|
||||
? artistNames.Select((name, index) => new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = name,
|
||||
["Id"] = index == 0 && song.ArtistId != null
|
||||
? song.ArtistId
|
||||
: $"{song.Id}-artist-{index}"
|
||||
["Id"] = song.ArtistIds[index]
|
||||
}).ToArray()
|
||||
: new[]
|
||||
{
|
||||
|
||||
@@ -33,7 +33,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
// Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity)
|
||||
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
|
||||
|
||||
_logger.LogDebug("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
|
||||
_logger.LogInformation("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -44,7 +44,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
{
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogWarning("Cannot create session - no device ID");
|
||||
_logger.LogError("Cannot create session - no device ID");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
||||
{
|
||||
existingSession.LastActivity = DateTime.UtcNow;
|
||||
_logger.LogTrace("Session already exists for device {DeviceId}", deviceId);
|
||||
_logger.LogInformation("Session already exists for device {DeviceId}", deviceId);
|
||||
|
||||
// Refresh capabilities to keep session alive
|
||||
// If this returns false (401), the token expired and client needs to re-auth
|
||||
@@ -60,7 +60,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (!success)
|
||||
{
|
||||
// Token expired - remove the stale session
|
||||
_logger.LogInformation("Token expired for device {DeviceId} - removing session", deviceId);
|
||||
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
|
||||
await RemoveSessionAsync(deviceId);
|
||||
return false;
|
||||
}
|
||||
@@ -78,13 +78,17 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (!success)
|
||||
{
|
||||
// Token expired or invalid - client needs to re-authenticate
|
||||
_logger.LogInformation("Failed to create session for {DeviceId} - token may be expired", deviceId);
|
||||
_logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
||||
_logger.LogInformation("Session created for {DeviceId}", deviceId);
|
||||
|
||||
// Track this session
|
||||
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
||||
?? headers["X-Real-IP"].FirstOrDefault()
|
||||
?? "Unknown";
|
||||
|
||||
_sessions[deviceId] = new SessionInfo
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
@@ -92,7 +96,8 @@ public class JellyfinSessionManager : IDisposable
|
||||
Device = device,
|
||||
Version = version,
|
||||
LastActivity = DateTime.UtcNow,
|
||||
Headers = CloneHeaders(headers)
|
||||
Headers = CloneHeaders(headers),
|
||||
ClientIp = clientIp
|
||||
};
|
||||
|
||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
||||
@@ -138,7 +143,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
else if (statusCode == 401)
|
||||
{
|
||||
// Token expired - this is expected, client needs to re-authenticate
|
||||
_logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate");
|
||||
_logger.LogWarning("Capabilities returned 401 (token expired) - client should re-authenticate");
|
||||
return false;
|
||||
}
|
||||
else
|
||||
@@ -160,7 +165,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
|
||||
_logger.LogError("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +227,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
Client = s.Client,
|
||||
Device = s.Device,
|
||||
Version = s.Version,
|
||||
ClientIp = s.ClientIp,
|
||||
LastActivity = s.LastActivity,
|
||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||
HasWebSocket = s.WebSocket != null,
|
||||
@@ -256,7 +262,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
||||
_logger.LogError(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -276,7 +282,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
};
|
||||
var stopJson = JsonSerializer.Serialize(stopPayload);
|
||||
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
||||
_logger.LogDebug("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
||||
}
|
||||
|
||||
@@ -285,7 +291,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
|
||||
_logger.LogError("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,7 +304,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
{
|
||||
if (!_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
_logger.LogDebug("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
|
||||
_logger.LogError("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -360,7 +366,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (!string.IsNullOrEmpty(_settings.ApiKey))
|
||||
{
|
||||
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
|
||||
_logger.LogDebug("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
|
||||
_logger.LogWarning("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -375,7 +381,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
|
||||
// Connect to Jellyfin
|
||||
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
|
||||
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
|
||||
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
|
||||
|
||||
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
|
||||
// This tells Jellyfin to create/show the session in the dashboard
|
||||
@@ -383,7 +389,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}";
|
||||
var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
|
||||
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
_logger.LogDebug("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
|
||||
_logger.LogInformation("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
|
||||
|
||||
// Also send SessionsStart to subscribe to session updates
|
||||
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
|
||||
@@ -516,20 +522,20 @@ public class JellyfinSessionManager : IDisposable
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogInformation("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
|
||||
_logger.LogWarning("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
|
||||
expiredSessions.Add(session.DeviceId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
|
||||
_logger.LogError(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove sessions with expired tokens
|
||||
foreach (var deviceId in expiredSessions)
|
||||
{
|
||||
_logger.LogInformation("Removing session with expired token: {DeviceId}", deviceId);
|
||||
_logger.LogWarning("Removing session with expired token: {DeviceId}", deviceId);
|
||||
await RemoveSessionAsync(deviceId);
|
||||
}
|
||||
|
||||
@@ -565,6 +571,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
public ClientWebSocket? WebSocket { get; set; }
|
||||
public string? LastPlayingItemId { get; set; }
|
||||
public long? LastPlayingPositionTicks { get; set; }
|
||||
public string? ClientIp { get; set; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -213,7 +213,7 @@ public class LocalLibraryService : ILocalLibraryService
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to trigger Subsonic scan: {StatusCode} - Server may require authentication", response.StatusCode);
|
||||
_logger.LogError("Failed to trigger Subsonic scan: {StatusCode} - Server may require authentication", response.StatusCode);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public class LrclibService
|
||||
ILogger<LrclibService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.3 (https://github.com/SoPat712/allstarr)");
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -75,7 +75,7 @@ public class LrclibService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize cached lyrics");
|
||||
_logger.LogError(ex, "Failed to deserialize cached lyrics");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ public class LrclibService
|
||||
$"track_name={Uri.EscapeDataString(trackName)}&" +
|
||||
$"artist_name={Uri.EscapeDataString(searchArtistName)}";
|
||||
|
||||
_logger.LogInformation("Searching LRCLIB: {Url} (expecting {ArtistCount} artists)", searchUrl, artistNames.Length);
|
||||
_logger.LogDebug("Searching LRCLIB: {Url} (expecting {ArtistCount} artists)", searchUrl, artistNames.Length);
|
||||
|
||||
var searchResponse = await _httpClient.GetAsync(searchUrl);
|
||||
|
||||
@@ -157,12 +157,12 @@ public class LrclibService
|
||||
SyncedLyrics = bestMatch.SyncedLyrics
|
||||
};
|
||||
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL);
|
||||
return result;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Best match score too low ({Score:F1}), trying exact match", bestScore);
|
||||
_logger.LogDebug("Best match score too low ({Score:F1}), trying exact match", bestScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,7 +206,7 @@ public class LrclibService
|
||||
SyncedLyrics = lyrics.SyncedLyrics
|
||||
};
|
||||
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(exactResult, JsonOptions), TimeSpan.FromDays(30));
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(exactResult, JsonOptions), CacheExtensions.LyricsTTL);
|
||||
|
||||
_logger.LogInformation("Retrieved lyrics via exact match for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id);
|
||||
|
||||
@@ -214,7 +214,7 @@ public class LrclibService
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch lyrics from LRCLIB for {Artist} - {Track}", artistName, trackName);
|
||||
_logger.LogError(ex, "Failed to fetch lyrics from LRCLIB for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -350,7 +350,7 @@ public class LrclibService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch cached lyrics for {Artist} - {Track}", artistName, trackName);
|
||||
_logger.LogError(ex, "Failed to fetch cached lyrics for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -368,7 +368,7 @@ public class LrclibService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize cached lyrics");
|
||||
_logger.LogError(ex, "Failed to deserialize cached lyrics");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +404,7 @@ public class LrclibService
|
||||
SyncedLyrics = lyrics.SyncedLyrics
|
||||
};
|
||||
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Models.Settings;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Services.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates lyrics fetching from multiple sources with priority-based fallback.
|
||||
/// Priority order: Spotify → LyricsPlus → LRCLib
|
||||
/// Note: Jellyfin local lyrics are handled by the controller before calling this orchestrator.
|
||||
/// </summary>
|
||||
public class LyricsOrchestrator
|
||||
{
|
||||
private readonly SpotifyLyricsService _spotifyLyrics;
|
||||
private readonly LyricsPlusService _lyricsPlus;
|
||||
private readonly LrclibService _lrclib;
|
||||
private readonly SpotifyApiSettings _spotifySettings;
|
||||
private readonly ILogger<LyricsOrchestrator> _logger;
|
||||
|
||||
public LyricsOrchestrator(
|
||||
SpotifyLyricsService spotifyLyrics,
|
||||
LyricsPlusService lyricsPlus,
|
||||
LrclibService lrclib,
|
||||
IOptions<SpotifyApiSettings> spotifySettings,
|
||||
ILogger<LyricsOrchestrator> logger)
|
||||
{
|
||||
_spotifyLyrics = spotifyLyrics;
|
||||
_lyricsPlus = lyricsPlus;
|
||||
_lrclib = lrclib;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches lyrics with automatic fallback through all available sources.
|
||||
/// Note: Jellyfin local lyrics are handled by the controller before calling this.
|
||||
/// </summary>
|
||||
/// <param name="trackName">Track title</param>
|
||||
/// <param name="artistNames">Artist names (can be multiple)</param>
|
||||
/// <param name="albumName">Album name</param>
|
||||
/// <param name="durationSeconds">Track duration in seconds</param>
|
||||
/// <param name="spotifyTrackId">Spotify track ID (if available)</param>
|
||||
/// <returns>Lyrics info or null if not found</returns>
|
||||
public async Task<LyricsInfo?> GetLyricsAsync(
|
||||
string trackName,
|
||||
string[] artistNames,
|
||||
string? albumName,
|
||||
int durationSeconds,
|
||||
string? spotifyTrackId = null)
|
||||
{
|
||||
var artistName = string.Join(", ", artistNames);
|
||||
|
||||
_logger.LogInformation("🎵 Fetching lyrics for: {Artist} - {Track}", artistName, trackName);
|
||||
|
||||
// 1. Try Spotify lyrics (if Spotify ID provided)
|
||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
|
||||
if (spotifyLyrics != null)
|
||||
{
|
||||
return spotifyLyrics;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try LyricsPlus
|
||||
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
||||
if (lyricsPlusLyrics != null)
|
||||
{
|
||||
return lyricsPlusLyrics;
|
||||
}
|
||||
|
||||
// 3. Try LRCLib
|
||||
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
||||
if (lrclibLyrics != null)
|
||||
{
|
||||
return lrclibLyrics;
|
||||
}
|
||||
|
||||
_logger.LogInformation("❌ No lyrics found for: {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefetches lyrics in the background (for cache warming).
|
||||
/// Skips Jellyfin local since we don't have an itemId.
|
||||
/// </summary>
|
||||
public async Task<bool> PrefetchLyricsAsync(
|
||||
string trackName,
|
||||
string[] artistNames,
|
||||
string? albumName,
|
||||
int durationSeconds,
|
||||
string? spotifyTrackId = null)
|
||||
{
|
||||
var artistName = string.Join(", ", artistNames);
|
||||
|
||||
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Track}", artistName, trackName);
|
||||
|
||||
// 1. Try Spotify lyrics (if Spotify ID provided)
|
||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
|
||||
if (spotifyLyrics != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try LyricsPlus
|
||||
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
||||
if (lyricsPlusLyrics != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Try LRCLib
|
||||
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
||||
if (lrclibLyrics != null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogDebug("No lyrics found for prefetch: {Artist} - {Track}", artistName, trackName);
|
||||
return false;
|
||||
}
|
||||
|
||||
#region Private Helper Methods
|
||||
|
||||
private async Task<LyricsInfo?> TrySpotifyLyrics(string spotifyTrackId, string artistName, string trackName)
|
||||
{
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
_logger.LogWarning("Spotify API not enabled, skipping Spotify lyrics");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Validate Spotify ID format
|
||||
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
||||
|
||||
if (cleanSpotifyId.Length != 22 || cleanSpotifyId.Contains(":") || cleanSpotifyId.Contains("local"))
|
||||
{
|
||||
_logger.LogWarning("Invalid Spotify ID format: {SpotifyId}, skipping", spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("→ Trying Spotify lyrics for track ID: {SpotifyId}", cleanSpotifyId);
|
||||
|
||||
var spotifyLyrics = await _spotifyLyrics.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
||||
|
||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines, type: {SyncType})",
|
||||
artistName, trackName, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
||||
|
||||
return _spotifyLyrics.ToLyricsInfo(spotifyLyrics);
|
||||
}
|
||||
|
||||
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Spotify lyrics for track ID {SpotifyId}", spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LyricsInfo?> TryLyricsPlusLyrics(
|
||||
string trackName,
|
||||
string[] artistNames,
|
||||
string? albumName,
|
||||
int durationSeconds,
|
||||
string artistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("→ Trying LyricsPlus for: {Artist} - {Track}", artistName, trackName);
|
||||
|
||||
var lyrics = await _lyricsPlus.GetLyricsAsync(trackName, artistNames, albumName, durationSeconds);
|
||||
|
||||
if (lyrics != null)
|
||||
{
|
||||
_logger.LogInformation("✓ Found LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
|
||||
return lyrics;
|
||||
}
|
||||
|
||||
_logger.LogDebug("No LyricsPlus lyrics found for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LyricsInfo?> TryLrclibLyrics(
|
||||
string trackName,
|
||||
string[] artistNames,
|
||||
string? albumName,
|
||||
int durationSeconds,
|
||||
string artistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("→ Trying LRCLib for: {Artist} - {Track}", artistName, trackName);
|
||||
|
||||
var lyrics = await _lrclib.GetLyricsAsync(trackName, artistNames, albumName ?? string.Empty, durationSeconds);
|
||||
|
||||
if (lyrics != null)
|
||||
{
|
||||
_logger.LogInformation("✓ Found LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
|
||||
return lyrics;
|
||||
}
|
||||
|
||||
_logger.LogDebug("No LRCLib lyrics found for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Services.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// Service for fetching lyrics from LyricsPlus API (https://lyricsplus.prjktla.workers.dev)
|
||||
/// Supports multiple sources: Apple Music, Spotify, Musixmatch, and more
|
||||
/// </summary>
|
||||
public class LyricsPlusService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<LyricsPlusService> _logger;
|
||||
private const string BaseUrl = "https://lyricsplus.prjktla.workers.dev/v2/lyrics/get";
|
||||
|
||||
public LyricsPlusService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
RedisCacheService cache,
|
||||
ILogger<LyricsPlusService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.3 (https://github.com/SoPat712/allstarr)");
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string artistName, string? albumName, int durationSeconds)
|
||||
{
|
||||
return await GetLyricsAsync(trackName, new[] { artistName }, albumName, durationSeconds);
|
||||
}
|
||||
|
||||
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string[] artistNames, string? albumName, int durationSeconds)
|
||||
{
|
||||
// Validate input parameters
|
||||
if (string.IsNullOrWhiteSpace(trackName) || artistNames == null || artistNames.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("Invalid parameters for LyricsPlus search: trackName={TrackName}, artistCount={ArtistCount}",
|
||||
trackName, artistNames?.Length ?? 0);
|
||||
return null;
|
||||
}
|
||||
|
||||
var artistName = string.Join(", ", artistNames);
|
||||
var cacheKey = $"lyricsplus:{artistName}:{trackName}:{albumName}:{durationSeconds}";
|
||||
|
||||
// Check cache
|
||||
var cached = await _cache.GetStringAsync(cacheKey);
|
||||
if (!string.IsNullOrEmpty(cached))
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<LyricsInfo>(cached, JsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize cached LyricsPlus lyrics");
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Build URL with query parameters
|
||||
var url = $"{BaseUrl}?title={Uri.EscapeDataString(trackName)}&artist={Uri.EscapeDataString(artistName)}";
|
||||
|
||||
if (!string.IsNullOrEmpty(albumName))
|
||||
{
|
||||
url += $"&album={Uri.EscapeDataString(albumName)}";
|
||||
}
|
||||
|
||||
if (durationSeconds > 0)
|
||||
{
|
||||
url += $"&duration={durationSeconds}";
|
||||
}
|
||||
|
||||
// Add sources: apple, lyricsplus, musixmatch, spotify, musixmatch-word
|
||||
url += "&source=apple,lyricsplus,musixmatch,spotify,musixmatch-word";
|
||||
|
||||
_logger.LogDebug("Fetching lyrics from LyricsPlus: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
_logger.LogDebug("Lyrics not found on LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var lyricsResponse = JsonSerializer.Deserialize<LyricsPlusResponse>(json, JsonOptions);
|
||||
|
||||
if (lyricsResponse == null || lyricsResponse.Lyrics == null || lyricsResponse.Lyrics.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("Empty lyrics response from LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert to LyricsInfo format
|
||||
var result = ConvertToLyricsInfo(lyricsResponse, trackName, artistName, albumName, durationSeconds);
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL);
|
||||
_logger.LogInformation("✓ Retrieved lyrics from LyricsPlus for {Artist} - {Track} (type: {Type}, source: {Source})",
|
||||
artistName, trackName, lyricsResponse.Type, lyricsResponse.Metadata?.Source);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fetch lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private LyricsInfo? ConvertToLyricsInfo(LyricsPlusResponse response, string trackName, string artistName, string? albumName, int durationSeconds)
|
||||
{
|
||||
if (response.Lyrics == null || response.Lyrics.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string? syncedLyrics = null;
|
||||
string? plainLyrics = null;
|
||||
|
||||
// Convert based on type
|
||||
if (response.Type == "Word")
|
||||
{
|
||||
// Word-level timing - convert to line-level LRC
|
||||
syncedLyrics = ConvertWordTimingToLrc(response.Lyrics);
|
||||
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
|
||||
}
|
||||
else if (response.Type == "Line")
|
||||
{
|
||||
// Line-level timing - convert to LRC
|
||||
syncedLyrics = ConvertLineTimingToLrc(response.Lyrics);
|
||||
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Static or unknown type - just plain text
|
||||
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
|
||||
}
|
||||
|
||||
return new LyricsInfo
|
||||
{
|
||||
TrackName = trackName,
|
||||
ArtistName = artistName,
|
||||
AlbumName = albumName ?? string.Empty,
|
||||
Duration = durationSeconds,
|
||||
Instrumental = false,
|
||||
PlainLyrics = plainLyrics,
|
||||
SyncedLyrics = syncedLyrics
|
||||
};
|
||||
}
|
||||
|
||||
private string ConvertLineTimingToLrc(List<LyricsPlusLine> lines)
|
||||
{
|
||||
var lrcLines = new List<string>();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.Time.HasValue)
|
||||
{
|
||||
var timestamp = TimeSpan.FromMilliseconds(line.Time.Value);
|
||||
var mm = (int)timestamp.TotalMinutes;
|
||||
var ss = timestamp.Seconds;
|
||||
var cs = timestamp.Milliseconds / 10; // Convert to centiseconds
|
||||
|
||||
lrcLines.Add($"[{mm:D2}:{ss:D2}.{cs:D2}]{line.Text}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// No timing, just add the text
|
||||
lrcLines.Add(line.Text);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join("\n", lrcLines);
|
||||
}
|
||||
|
||||
private string ConvertWordTimingToLrc(List<LyricsPlusLine> lines)
|
||||
{
|
||||
// For word-level timing, we use the line start time
|
||||
// (word-level detail is in syllabus array but we simplify to line-level for LRC)
|
||||
return ConvertLineTimingToLrc(lines);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private class LyricsPlusResponse
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = string.Empty; // "Word", "Line", or "Static"
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public LyricsPlusMetadata? Metadata { get; set; }
|
||||
|
||||
[JsonPropertyName("lyrics")]
|
||||
public List<LyricsPlusLine> Lyrics { get; set; } = new();
|
||||
}
|
||||
|
||||
private class LyricsPlusMetadata
|
||||
{
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; set; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("language")]
|
||||
public string? Language { get; set; }
|
||||
}
|
||||
|
||||
private class LyricsPlusLine
|
||||
{
|
||||
[JsonPropertyName("time")]
|
||||
public long? Time { get; set; } // Milliseconds
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public long? Duration { get; set; }
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("syllabus")]
|
||||
public List<LyricsPlusSyllable>? Syllabus { get; set; }
|
||||
}
|
||||
|
||||
private class LyricsPlusSyllable
|
||||
{
|
||||
[JsonPropertyName("time")]
|
||||
public long Time { get; set; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public long Duration { get; set; }
|
||||
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
// Run initial prefetch
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Running initial lyrics prefetch on startup");
|
||||
_logger.LogDebug("Running initial lyrics prefetch on startup");
|
||||
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -115,7 +115,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
string playlistName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Prefetching lyrics for playlist: {Playlist}", playlistName);
|
||||
_logger.LogDebug("Prefetching lyrics for playlist: {Playlist}", playlistName);
|
||||
|
||||
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
if (tracks.Count == 0)
|
||||
@@ -125,7 +125,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
|
||||
// Get the pre-built playlist items cache which includes Jellyfin item IDs for local tracks
|
||||
var playlistItemsKey = $"spotify:playlist:items:{playlistName}";
|
||||
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
|
||||
var playlistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
|
||||
|
||||
// Build a map of Spotify ID -> Jellyfin Item ID for quick lookup
|
||||
@@ -156,7 +156,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}",
|
||||
_logger.LogInformation("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}",
|
||||
spotifyToJellyfinId.Count, playlistName);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
if (!string.IsNullOrEmpty(existingLyrics))
|
||||
{
|
||||
cached++;
|
||||
_logger.LogDebug("✓ Lyrics already cached for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
_logger.LogInformation("✓ Lyrics already cached for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
if (hasLocalLyrics)
|
||||
{
|
||||
cached++;
|
||||
_logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch",
|
||||
_logger.LogWarning("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch",
|
||||
track.PrimaryArtist, track.Title);
|
||||
|
||||
// Remove any previously cached LRCLib lyrics for this track
|
||||
@@ -239,12 +239,12 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to prefetch lyrics for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
_logger.LogError(ex, "Failed to prefetch lyrics for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
missing++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
|
||||
_logger.LogDebug("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
|
||||
playlistName, fetched, cached, missing);
|
||||
|
||||
return (fetched, cached, missing);
|
||||
@@ -264,7 +264,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save lyrics to file for {Artist} - {Track}", artist, title);
|
||||
_logger.LogError(ex, "Failed to save lyrics to file for {Artist} - {Track}", artist, title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
{
|
||||
if (!Directory.Exists(_lyricsCacheDir))
|
||||
{
|
||||
_logger.LogInformation("Lyrics cache directory does not exist, skipping cache warming");
|
||||
_logger.LogWarning("Lyrics cache directory does not exist, skipping cache warming");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔥 Warming lyrics cache from {Count} files...", files.Length);
|
||||
_logger.LogDebug("🔥 Warming lyrics cache from {Count} files...", files.Length);
|
||||
|
||||
var loaded = 0;
|
||||
foreach (var file in files)
|
||||
@@ -301,17 +301,17 @@ public class LyricsPrefetchService : BackgroundService
|
||||
if (lyrics != null)
|
||||
{
|
||||
var cacheKey = $"lyrics:{lyrics.ArtistName}:{lyrics.TrackName}:{lyrics.AlbumName}:{lyrics.Duration}";
|
||||
await _cache.SetStringAsync(cacheKey, json, TimeSpan.FromDays(30));
|
||||
await _cache.SetStringAsync(cacheKey, json, CacheExtensions.LyricsTTL);
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load lyrics from file {File}", Path.GetFileName(file));
|
||||
_logger.LogError(ex, "Failed to load lyrics from file {File}", Path.GetFileName(file));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Warmed {Count} lyrics from file cache", loaded);
|
||||
_logger.LogDebug("✅ Warmed {Count} lyrics from file cache", loaded);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -351,7 +351,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to remove cached lyrics for {Artist} - {Track}", artist, title);
|
||||
_logger.LogError(ex, "Failed to remove cached lyrics for {Artist} - {Track}", artist, title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +375,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
|
||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)",
|
||||
_logger.LogDebug("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)",
|
||||
artistName, trackTitle, spotifyLyrics.Lines.Count);
|
||||
return spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||
}
|
||||
@@ -384,7 +384,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track {SpotifyId}", spotifyTrackId);
|
||||
_logger.LogError(ex, "Error fetching Spotify lyrics for track {SpotifyId}", spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -423,7 +423,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error checking Jellyfin lyrics for item {ItemId}", jellyfinItemId);
|
||||
_logger.LogError(ex, "Error checking Jellyfin lyrics for item {ItemId}", jellyfinItemId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -528,7 +528,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error checking for local Jellyfin lyrics for Spotify track {SpotifyId}", spotifyTrackId);
|
||||
_logger.LogError(ex, "Error checking for local Jellyfin lyrics for Spotify track {SpotifyId}", spotifyTrackId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,16 +167,14 @@ public class LyricsStartupValidator : BaseStartupValidator
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_spotifySettings.ClientId))
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
WriteStatus("Spotify API", "NOT CONFIGURED", ConsoleColor.Yellow);
|
||||
WriteDetail("Set SpotifyApi__ClientId to enable");
|
||||
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Gray);
|
||||
return true;
|
||||
}
|
||||
|
||||
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
|
||||
WriteDetail($"Client ID: {_spotifySettings.ClientId.Substring(0, Math.Min(8, _spotifySettings.ClientId.Length))}...");
|
||||
WriteDetail("Note: Spotify API is used for track matching, not lyrics");
|
||||
WriteDetail("Note: Spotify API is used for track matching and lyrics");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -50,13 +50,13 @@ public class SpotifyLyricsService
|
||||
{
|
||||
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
_logger.LogDebug("Spotify API not enabled or no session cookie configured");
|
||||
_logger.LogInformation("Spotify API not enabled or no session cookie configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_settings.LyricsApiUrl))
|
||||
{
|
||||
_logger.LogWarning("Spotify lyrics API URL not configured");
|
||||
_logger.LogInformation("Spotify lyrics API URL not configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ public class SpotifyLyricsService
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
_logger.LogInformation("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
|
||||
_logger.LogDebug("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
|
||||
spotifyTrackId, result.Lines.Count);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ public class SpotifyLyricsService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error fetching lyrics from sidecar API for track {TrackId}", spotifyTrackId);
|
||||
_logger.LogError(ex, "Error fetching lyrics from sidecar API for track {TrackId}", spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -110,14 +110,14 @@ public class SpotifyLyricsService
|
||||
{
|
||||
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
_logger.LogDebug("Spotify lyrics search skipped: API not enabled or no session cookie");
|
||||
_logger.LogInformation("Spotify lyrics search skipped: API not enabled or no session cookie");
|
||||
return null;
|
||||
}
|
||||
|
||||
// The sidecar API only supports track ID, not search
|
||||
// So we skip Spotify lyrics for search-based requests
|
||||
// LRCLib will be used as fallback
|
||||
_logger.LogDebug("Spotify lyrics search by metadata not supported with sidecar API, skipping");
|
||||
_logger.LogWarning("Spotify lyrics search by metadata not supported with sidecar API, skipping");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ public class SpotifyLyricsService
|
||||
// Check for error
|
||||
if (root.TryGetProperty("error", out var error) && error.GetBoolean())
|
||||
{
|
||||
_logger.LogDebug("Sidecar API returned error for track {TrackId}", trackId);
|
||||
_logger.LogError("Sidecar API returned error for track {TrackId}", trackId);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ public class MusicBrainzService
|
||||
ILogger<MusicBrainzService> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.3 (https://github.com/SoPat712/allstarr)");
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
_settings = settings.Value;
|
||||
@@ -92,6 +92,7 @@ public class MusicBrainzService
|
||||
|
||||
/// <summary>
|
||||
/// Searches for recordings by title and artist.
|
||||
/// Note: Search API doesn't return genres, only MBIDs. Use LookupByMbidAsync to get genres.
|
||||
/// </summary>
|
||||
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
|
||||
{
|
||||
@@ -107,7 +108,8 @@ public class MusicBrainzService
|
||||
// Build Lucene query
|
||||
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
|
||||
var encodedQuery = Uri.EscapeDataString(query);
|
||||
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}&inc=genres+tags";
|
||||
// Note: Search API doesn't support inc=genres, only returns basic info + MBIDs
|
||||
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}";
|
||||
|
||||
_logger.LogDebug("MusicBrainz search: {Url}", url);
|
||||
|
||||
@@ -128,7 +130,7 @@ public class MusicBrainzService
|
||||
return new List<MusicBrainzRecording>();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} MusicBrainz recordings for: {Title} - {Artist}",
|
||||
_logger.LogDebug("Found {Count} MusicBrainz recordings for: {Title} - {Artist}",
|
||||
result.Recordings.Count, title, artist);
|
||||
|
||||
return result.Recordings;
|
||||
@@ -140,9 +142,56 @@ public class MusicBrainzService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a recording by MBID to get full details including genres.
|
||||
/// </summary>
|
||||
public async Task<MusicBrainzRecording?> LookupByMbidAsync(string mbid)
|
||||
{
|
||||
if (!_settings.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await RateLimitAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_settings.BaseUrl}/recording/{mbid}?fmt=json&inc=artists+releases+release-groups+genres+tags";
|
||||
_logger.LogDebug("MusicBrainz MBID lookup: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("MusicBrainz MBID lookup failed: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var recording = JsonSerializer.Deserialize<MusicBrainzRecording>(json, JsonOptions);
|
||||
|
||||
if (recording == null)
|
||||
{
|
||||
_logger.LogDebug("No MusicBrainz recording found for MBID: {Mbid}", mbid);
|
||||
return null;
|
||||
}
|
||||
|
||||
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string?>();
|
||||
_logger.LogInformation("✓ Found MusicBrainz recording for MBID {Mbid}: {Title} by {Artist} (Genres: {Genres})",
|
||||
mbid, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
|
||||
|
||||
return recording;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error looking up MBID {Mbid} in MusicBrainz", mbid);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches a song with genre information from MusicBrainz.
|
||||
/// First tries ISRC lookup, then falls back to title/artist search.
|
||||
/// First tries ISRC lookup, then falls back to title/artist search + MBID lookup.
|
||||
/// </summary>
|
||||
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
|
||||
{
|
||||
@@ -153,17 +202,23 @@ public class MusicBrainzService
|
||||
|
||||
MusicBrainzRecording? recording = null;
|
||||
|
||||
// Try ISRC lookup first (most accurate)
|
||||
// Try ISRC lookup first (most accurate and includes genres)
|
||||
if (!string.IsNullOrEmpty(isrc))
|
||||
{
|
||||
recording = await LookupByIsrcAsync(isrc);
|
||||
}
|
||||
|
||||
// Fall back to search if ISRC lookup failed or no ISRC provided
|
||||
// Fall back to search + MBID lookup if ISRC lookup failed or no ISRC provided
|
||||
if (recording == null)
|
||||
{
|
||||
var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
|
||||
recording = recordings.FirstOrDefault();
|
||||
var searchResult = recordings.FirstOrDefault();
|
||||
|
||||
// If we found a recording from search, do a full lookup by MBID to get genres
|
||||
if (searchResult != null && !string.IsNullOrEmpty(searchResult.Id))
|
||||
{
|
||||
recording = await LookupByMbidAsync(searchResult.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (recording == null)
|
||||
@@ -186,7 +241,7 @@ public class MusicBrainzService
|
||||
.ToList());
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} genres for {Title} - {Artist}: {Genres}",
|
||||
_logger.LogDebug("Found {Count} genres for {Title} - {Artist}: {Genres}",
|
||||
genres.Count, title, artist, string.Join(", ", genres));
|
||||
|
||||
return genres;
|
||||
|
||||
@@ -92,18 +92,18 @@ public class QobuzBundleService
|
||||
|
||||
// Step 1: Get the bundle URL from login page
|
||||
var bundleUrl = await GetBundleUrlAsync();
|
||||
_logger.LogInformation("Found bundle URL: {BundleUrl}", bundleUrl);
|
||||
_logger.LogDebug("Found bundle URL: {BundleUrl}", bundleUrl);
|
||||
|
||||
// Step 2: Download the bundle JavaScript
|
||||
var bundleJs = await DownloadBundleAsync(bundleUrl);
|
||||
|
||||
// Step 3: Extract App ID
|
||||
_cachedAppId = ExtractAppId(bundleJs);
|
||||
_logger.LogInformation("Extracted App ID: {AppId}", _cachedAppId);
|
||||
_logger.LogDebug("Extracted App ID: {AppId}", _cachedAppId);
|
||||
|
||||
// Step 4: Extract secrets (they are base64 encoded in the bundle)
|
||||
_cachedSecrets = ExtractSecrets(bundleJs);
|
||||
_logger.LogInformation("Extracted {Count} secrets", _cachedSecrets.Count);
|
||||
_logger.LogDebug("Extracted {Count} secrets", _cachedSecrets.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -253,7 +253,7 @@ public class QobuzBundleService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to decode secret for timezone {Timezone}", kvp.Key);
|
||||
_logger.LogError(ex, "Failed to decode secret for timezone {Timezone}", kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,15 +80,6 @@ public class QobuzDownloadService : BaseDownloadService
|
||||
}
|
||||
}
|
||||
|
||||
protected override string? ExtractExternalIdFromAlbumId(string albumId)
|
||||
{
|
||||
const string prefix = "ext-qobuz-album-";
|
||||
if (albumId.StartsWith(prefix))
|
||||
{
|
||||
return albumId[prefix.Length..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -114,7 +105,7 @@ public class QobuzDownloadService : BaseDownloadService
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine(DownloadPath, "cache")
|
||||
: Path.Combine(DownloadPath, "permanent");
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "qobuz", trackId);
|
||||
|
||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||
EnsureDirectoryExists(albumFolder);
|
||||
|
||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services.Common;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -18,6 +19,7 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
private readonly SubsonicSettings _settings;
|
||||
private readonly QobuzBundleService _bundleService;
|
||||
private readonly ILogger<QobuzMetadataService> _logger;
|
||||
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||
private readonly string? _userAuthToken;
|
||||
private readonly string? _userId;
|
||||
|
||||
@@ -28,12 +30,14 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
IOptions<SubsonicSettings> settings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
QobuzBundleService bundleService,
|
||||
ILogger<QobuzMetadataService> logger)
|
||||
ILogger<QobuzMetadataService> logger,
|
||||
GenreEnrichmentService? genreEnrichment = null)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_settings = settings.Value;
|
||||
_bundleService = bundleService;
|
||||
_logger = logger;
|
||||
_genreEnrichment = genreEnrichment;
|
||||
|
||||
var qobuzConfig = qobuzSettings.Value;
|
||||
_userAuthToken = qobuzConfig.UserAuthToken;
|
||||
@@ -64,12 +68,9 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
foreach (var track in items.EnumerateArray())
|
||||
{
|
||||
var song = ParseQobuzTrack(track);
|
||||
if (ShouldIncludeSong(song))
|
||||
{
|
||||
songs.Add(song);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return songs;
|
||||
}
|
||||
@@ -177,7 +178,26 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
|
||||
if (track.TryGetProperty("error", out _)) return null;
|
||||
|
||||
return ParseQobuzTrackFull(track);
|
||||
var song = ParseQobuzTrackFull(track);
|
||||
|
||||
// Enrich with MusicBrainz genres if missing
|
||||
if (_genreEnrichment != null && song != 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return song;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -218,12 +238,9 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
song.AlbumId = album.Id;
|
||||
song.AlbumArtist = album.Artist;
|
||||
|
||||
if (ShouldIncludeSong(song))
|
||||
{
|
||||
album.Songs.Add(song);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return album;
|
||||
}
|
||||
@@ -406,10 +423,7 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
song.Album = playlistName;
|
||||
song.Track = trackIndex;
|
||||
|
||||
if (ShouldIncludeSong(song))
|
||||
{
|
||||
songs.Add(song);
|
||||
}
|
||||
trackIndex++;
|
||||
}
|
||||
}
|
||||
@@ -812,14 +826,4 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
.Replace("(C)", "©");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a song should be included based on the explicit content filter setting
|
||||
/// Note: Qobuz doesn't have the same explicit content tagging as Deezer, so this is a no-op for now
|
||||
/// </summary>
|
||||
private bool ShouldIncludeSong(Song song)
|
||||
{
|
||||
// Qobuz API doesn't expose explicit content flags in the same way as Deezer
|
||||
// We could implement this in the future if needed
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ public class SpotifyApiClient : IDisposable
|
||||
{
|
||||
if (string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
_logger.LogWarning("No Spotify session cookie configured");
|
||||
_logger.LogInformation("No Spotify session cookie configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -349,6 +349,17 @@ public class SpotifyApiClient : IDisposable
|
||||
|
||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||
|
||||
// Handle 429 rate limiting with exponential backoff
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds);
|
||||
await Task.Delay(retryAfter, cancellationToken);
|
||||
|
||||
// Retry the request
|
||||
response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
||||
@@ -519,7 +530,7 @@ public class SpotifyApiClient : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse GraphQL track");
|
||||
_logger.LogError(ex, "Failed to parse GraphQL track");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -735,6 +746,18 @@ public class SpotifyApiClient : IDisposable
|
||||
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
||||
string searchName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetUserPlaylistsAsync(searchName, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all playlists from the user's library, optionally filtered by name.
|
||||
/// Uses GraphQL API which is less rate-limited than REST API.
|
||||
/// </summary>
|
||||
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
|
||||
public async Task<List<SpotifyPlaylist>> GetUserPlaylistsAsync(
|
||||
string? searchName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
@@ -744,61 +767,204 @@ public class SpotifyApiClient : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
// Use GraphQL endpoint instead of REST API to avoid rate limiting
|
||||
// GraphQL is less aggressive with rate limits
|
||||
var playlists = new List<SpotifyPlaylist>();
|
||||
var offset = 0;
|
||||
const int limit = 50;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}";
|
||||
// GraphQL query to fetch user playlists - using libraryV3 operation
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
{ "operationName", "libraryV3" },
|
||||
{ "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
|
||||
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
|
||||
};
|
||||
|
||||
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
||||
var url = $"{WebApiBase}/query?{queryString}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) break;
|
||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||
|
||||
// Handle 429 rate limiting with exponential backoff
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds);
|
||||
await Task.Delay(retryAfter, cancellationToken);
|
||||
|
||||
// Retry the request
|
||||
response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
|
||||
break;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
|
||||
if (!root.TryGetProperty("data", out var data) ||
|
||||
!data.TryGetProperty("me", out var me) ||
|
||||
!me.TryGetProperty("libraryV3", out var library) ||
|
||||
!library.TryGetProperty("items", out var items))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Get total count
|
||||
if (library.TryGetProperty("totalCount", out var totalCount))
|
||||
{
|
||||
var total = totalCount.GetInt32();
|
||||
if (total == 0) break;
|
||||
}
|
||||
|
||||
var itemCount = 0;
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
itemCount++;
|
||||
|
||||
// Check if name matches (case-insensitive)
|
||||
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
if (!item.TryGetProperty("item", out var playlistItem) ||
|
||||
!playlistItem.TryGetProperty("data", out var playlist))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check __typename to filter out folders and only include playlists
|
||||
if (playlistItem.TryGetProperty("__typename", out var typename))
|
||||
{
|
||||
var typeStr = typename.GetString();
|
||||
// Skip folders - only process Playlist types
|
||||
if (typeStr != null && typeStr.Contains("Folder", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get playlist URI/ID
|
||||
string? uri = null;
|
||||
if (playlistItem.TryGetProperty("uri", out var uriProp))
|
||||
{
|
||||
uri = uriProp.GetString();
|
||||
}
|
||||
else if (playlistItem.TryGetProperty("_uri", out var uriProp2))
|
||||
{
|
||||
uri = uriProp2.GetString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(uri)) continue;
|
||||
|
||||
// Skip if not a playlist URI (e.g., folders have different URI format)
|
||||
if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
|
||||
// Check if name matches (case-insensitive) - if searchName is provided
|
||||
if (!string.IsNullOrEmpty(searchName) &&
|
||||
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
// Log if we couldn't find track count for debugging
|
||||
if (trackCount == 0)
|
||||
{
|
||||
_logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}",
|
||||
itemName, spotifyId, playlist.GetRawText());
|
||||
}
|
||||
|
||||
// Get owner name
|
||||
string? ownerName = null;
|
||||
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
||||
ownerV2.TryGetProperty("data", out var ownerData) &&
|
||||
ownerData.TryGetProperty("username", out var ownerNameProp))
|
||||
{
|
||||
ownerName = ownerNameProp.GetString();
|
||||
}
|
||||
|
||||
// Get image URL
|
||||
string? imageUrl = null;
|
||||
if (playlist.TryGetProperty("images", out var images) &&
|
||||
images.TryGetProperty("items", out var imageItems) &&
|
||||
imageItems.GetArrayLength() > 0)
|
||||
{
|
||||
var firstImage = imageItems[0];
|
||||
if (firstImage.TryGetProperty("sources", out var sources) &&
|
||||
sources.GetArrayLength() > 0)
|
||||
{
|
||||
var firstSource = sources[0];
|
||||
if (firstSource.TryGetProperty("url", out var urlProp))
|
||||
{
|
||||
imageUrl = urlProp.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playlists.Add(new SpotifyPlaylist
|
||||
{
|
||||
SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "",
|
||||
SpotifyId = spotifyId,
|
||||
Name = itemName,
|
||||
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
TotalTracks = item.TryGetProperty("tracks", out var tracks) &&
|
||||
tracks.TryGetProperty("total", out var total)
|
||||
? total.GetInt32() : 0,
|
||||
SnapshotId = item.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null
|
||||
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
TotalTracks = trackCount,
|
||||
OwnerName = ownerName,
|
||||
ImageUrl = imageUrl,
|
||||
SnapshotId = null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (items.GetArrayLength() < limit) break;
|
||||
if (itemCount < limit) break;
|
||||
offset += limit;
|
||||
|
||||
if (_settings.RateLimitDelayMs > 0)
|
||||
{
|
||||
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
||||
}
|
||||
// Add delay between pages to avoid rate limiting
|
||||
// Library fetching can be aggressive, so use a longer delay
|
||||
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
|
||||
_logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs);
|
||||
await Task.Delay(delayMs, cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Found {Count} playlists{Filter} via GraphQL",
|
||||
playlists.Count,
|
||||
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
||||
return playlists;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching user playlists for '{SearchName}'", searchName);
|
||||
_logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL",
|
||||
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
||||
return new List<SpotifyPlaylist>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Migrates legacy per-playlist manual mappings to global mappings.
|
||||
/// Runs once on startup to convert old format to new format.
|
||||
/// </summary>
|
||||
public class SpotifyMappingMigrationService : IHostedService
|
||||
{
|
||||
private readonly SpotifyMappingService _mappingService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<SpotifyMappingMigrationService> _logger;
|
||||
private const string MappingsCacheDirectory = "/app/cache/mappings";
|
||||
private const string MigrationFlagKey = "spotify:mappings:migrated";
|
||||
|
||||
public SpotifyMappingMigrationService(
|
||||
SpotifyMappingService mappingService,
|
||||
RedisCacheService cache,
|
||||
ILogger<SpotifyMappingMigrationService> logger)
|
||||
{
|
||||
_mappingService = mappingService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if migration already completed
|
||||
var migrated = await _cache.GetStringAsync(MigrationFlagKey);
|
||||
if (migrated == "true")
|
||||
{
|
||||
_logger.LogDebug("Mapping migration already completed, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔄 Starting migration of legacy per-playlist mappings to global mappings...");
|
||||
|
||||
try
|
||||
{
|
||||
var migratedCount = await MigrateLegacyMappingsAsync(cancellationToken);
|
||||
|
||||
if (migratedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("✅ Migrated {Count} legacy mappings to global format", migratedCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("✅ No legacy mappings found to migrate");
|
||||
}
|
||||
|
||||
// Set migration flag (permanent)
|
||||
await _cache.SetStringAsync(MigrationFlagKey, "true", expiry: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to migrate legacy mappings");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<int> MigrateLegacyMappingsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(MappingsCacheDirectory))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(MappingsCacheDirectory, "*_mappings.json");
|
||||
var migratedCount = 0;
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(file, cancellationToken);
|
||||
var legacyMappings = JsonSerializer.Deserialize<Dictionary<string, LegacyMappingEntry>>(json);
|
||||
|
||||
if (legacyMappings == null || legacyMappings.Count == 0)
|
||||
continue;
|
||||
|
||||
var playlistName = Path.GetFileNameWithoutExtension(file).Replace("_mappings", "");
|
||||
_logger.LogInformation("Migrating {Count} mappings from playlist: {Playlist}",
|
||||
legacyMappings.Count, playlistName);
|
||||
|
||||
foreach (var (spotifyId, legacyMapping) in legacyMappings)
|
||||
{
|
||||
// Check if global mapping already exists
|
||||
var existingMapping = await _mappingService.GetMappingAsync(spotifyId);
|
||||
if (existingMapping != null)
|
||||
{
|
||||
_logger.LogDebug("Skipping {SpotifyId} - global mapping already exists", spotifyId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert legacy mapping to global mapping
|
||||
var metadata = new TrackMetadata
|
||||
{
|
||||
Title = legacyMapping.Title,
|
||||
Artist = legacyMapping.Artist,
|
||||
Album = legacyMapping.Album
|
||||
};
|
||||
|
||||
bool success;
|
||||
if (!string.IsNullOrEmpty(legacyMapping.JellyfinId))
|
||||
{
|
||||
// Local mapping
|
||||
success = await _mappingService.SaveManualMappingAsync(
|
||||
spotifyId,
|
||||
"local",
|
||||
localId: legacyMapping.JellyfinId,
|
||||
metadata: metadata);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(legacyMapping.ExternalProvider) &&
|
||||
!string.IsNullOrEmpty(legacyMapping.ExternalId))
|
||||
{
|
||||
// External mapping
|
||||
success = await _mappingService.SaveManualMappingAsync(
|
||||
spotifyId,
|
||||
"external",
|
||||
externalProvider: legacyMapping.ExternalProvider,
|
||||
externalId: legacyMapping.ExternalId,
|
||||
metadata: metadata);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Invalid legacy mapping for {SpotifyId}, skipping", spotifyId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (success)
|
||||
{
|
||||
migratedCount++;
|
||||
_logger.LogDebug("Migrated {SpotifyId} → {TargetType}",
|
||||
spotifyId,
|
||||
!string.IsNullOrEmpty(legacyMapping.JellyfinId) ? "local" : "external");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to migrate mappings from file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
return migratedCount;
|
||||
}
|
||||
|
||||
private class LegacyMappingEntry
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public string? Artist { get; set; }
|
||||
public string? Album { get; set; }
|
||||
public string? JellyfinId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Manages global Spotify ID → Local/External track mappings.
|
||||
/// Provides fast lookups and persistence via Redis.
|
||||
/// </summary>
|
||||
public class SpotifyMappingService
|
||||
{
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<SpotifyMappingService> _logger;
|
||||
private const string MappingKeyPrefix = "spotify:global-map:";
|
||||
private const string AllMappingsKey = "spotify:global-map:all-ids";
|
||||
|
||||
public SpotifyMappingService(
|
||||
RedisCacheService cache,
|
||||
ILogger<SpotifyMappingService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a mapping for a Spotify track ID.
|
||||
/// </summary>
|
||||
public async Task<SpotifyTrackMapping?> GetMappingAsync(string spotifyId)
|
||||
{
|
||||
var key = $"{MappingKeyPrefix}{spotifyId}";
|
||||
var mapping = await _cache.GetAsync<SpotifyTrackMapping>(key);
|
||||
|
||||
if (mapping != null)
|
||||
{
|
||||
_logger.LogDebug("Found mapping for Spotify ID {SpotifyId}: {TargetType}", spotifyId, mapping.TargetType);
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a mapping for a Spotify track ID.
|
||||
/// Local mappings are always preferred over external.
|
||||
/// Manual mappings are preserved unless explicitly overwritten.
|
||||
/// </summary>
|
||||
public async Task<bool> SaveMappingAsync(SpotifyTrackMapping mapping)
|
||||
{
|
||||
// Validate mapping
|
||||
if (string.IsNullOrEmpty(mapping.SpotifyId))
|
||||
{
|
||||
_logger.LogWarning("Cannot save mapping: SpotifyId is required");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mapping.TargetType == "local" && string.IsNullOrEmpty(mapping.LocalId))
|
||||
{
|
||||
_logger.LogWarning("Cannot save local mapping: LocalId is required");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mapping.TargetType == "external" &&
|
||||
(string.IsNullOrEmpty(mapping.ExternalProvider) || string.IsNullOrEmpty(mapping.ExternalId)))
|
||||
{
|
||||
_logger.LogWarning("Cannot save external mapping: ExternalProvider and ExternalId are required");
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = $"{MappingKeyPrefix}{mapping.SpotifyId}";
|
||||
|
||||
// Check if mapping already exists
|
||||
var existingMapping = await GetMappingAsync(mapping.SpotifyId);
|
||||
|
||||
// RULE 1: Never overwrite manual mappings with auto mappings
|
||||
if (existingMapping != null &&
|
||||
existingMapping.Source == "manual" &&
|
||||
mapping.Source == "auto")
|
||||
{
|
||||
_logger.LogDebug("Skipping auto mapping for {SpotifyId} - manual mapping exists", mapping.SpotifyId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// RULE 2: Local always wins over external (even if existing is manual external)
|
||||
if (existingMapping != null &&
|
||||
existingMapping.TargetType == "external" &&
|
||||
mapping.TargetType == "local")
|
||||
{
|
||||
_logger.LogInformation("🎉 UPGRADING: External → Local for {SpotifyId}", mapping.SpotifyId);
|
||||
// Allow the upgrade to proceed
|
||||
}
|
||||
|
||||
// RULE 3: Don't downgrade local to external
|
||||
if (existingMapping != null &&
|
||||
existingMapping.TargetType == "local" &&
|
||||
mapping.TargetType == "external")
|
||||
{
|
||||
_logger.LogDebug("Skipping external mapping for {SpotifyId} - local mapping exists", mapping.SpotifyId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set timestamps
|
||||
if (mapping.CreatedAt == default)
|
||||
{
|
||||
mapping.CreatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Preserve CreatedAt from existing mapping
|
||||
if (existingMapping != null)
|
||||
{
|
||||
mapping.CreatedAt = existingMapping.CreatedAt;
|
||||
}
|
||||
|
||||
// Save mapping (permanent - no TTL)
|
||||
var success = await _cache.SetAsync(key, mapping, expiry: null);
|
||||
|
||||
if (success)
|
||||
{
|
||||
// Add to set of all mapping IDs for enumeration
|
||||
await AddToAllMappingsSetAsync(mapping.SpotifyId);
|
||||
|
||||
// Invalidate ALL playlist stats caches since this mapping could affect any playlist
|
||||
// This ensures the stats are recalculated on next request
|
||||
await InvalidateAllPlaylistStatsCachesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Saved {Source} mapping: Spotify {SpotifyId} → {TargetType} {TargetId}",
|
||||
mapping.Source,
|
||||
mapping.SpotifyId,
|
||||
mapping.TargetType,
|
||||
mapping.TargetType == "local" ? mapping.LocalId : $"{mapping.ExternalProvider}:{mapping.ExternalId}"
|
||||
);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a local mapping (auto-populated during matching).
|
||||
/// </summary>
|
||||
public async Task<bool> SaveLocalMappingAsync(
|
||||
string spotifyId,
|
||||
string localId,
|
||||
TrackMetadata? metadata = null)
|
||||
{
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = spotifyId,
|
||||
TargetType = "local",
|
||||
LocalId = localId,
|
||||
Metadata = metadata,
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await SaveMappingAsync(mapping);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves an external mapping (auto-populated during matching).
|
||||
/// </summary>
|
||||
public async Task<bool> SaveExternalMappingAsync(
|
||||
string spotifyId,
|
||||
string externalProvider,
|
||||
string externalId,
|
||||
TrackMetadata? metadata = null)
|
||||
{
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = spotifyId,
|
||||
TargetType = "external",
|
||||
ExternalProvider = externalProvider,
|
||||
ExternalId = externalId,
|
||||
Metadata = metadata,
|
||||
Source = "auto",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await SaveMappingAsync(mapping);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a manual mapping (user override via Admin UI).
|
||||
/// </summary>
|
||||
public async Task<bool> SaveManualMappingAsync(
|
||||
string spotifyId,
|
||||
string targetType,
|
||||
string? localId = null,
|
||||
string? externalProvider = null,
|
||||
string? externalId = null,
|
||||
TrackMetadata? metadata = null)
|
||||
{
|
||||
var mapping = new SpotifyTrackMapping
|
||||
{
|
||||
SpotifyId = spotifyId,
|
||||
TargetType = targetType,
|
||||
LocalId = localId,
|
||||
ExternalProvider = externalProvider,
|
||||
ExternalId = externalId,
|
||||
Metadata = metadata,
|
||||
Source = "manual",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await SaveMappingAsync(mapping);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a mapping for a Spotify track ID.
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteMappingAsync(string spotifyId)
|
||||
{
|
||||
var key = $"{MappingKeyPrefix}{spotifyId}";
|
||||
var success = await _cache.DeleteAsync(key);
|
||||
|
||||
if (success)
|
||||
{
|
||||
await RemoveFromAllMappingsSetAsync(spotifyId);
|
||||
_logger.LogInformation("Deleted mapping for Spotify ID {SpotifyId}", spotifyId);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all Spotify IDs that have mappings.
|
||||
/// </summary>
|
||||
public async Task<List<string>> GetAllMappingIdsAsync()
|
||||
{
|
||||
var json = await _cache.GetStringAsync(AllMappingsKey);
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize all mapping IDs");
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all mappings (paginated).
|
||||
/// </summary>
|
||||
public async Task<List<SpotifyTrackMapping>> GetAllMappingsAsync(int skip = 0, int take = 100)
|
||||
{
|
||||
var allIds = await GetAllMappingIdsAsync();
|
||||
var pagedIds = allIds.Skip(skip).Take(take).ToList();
|
||||
|
||||
var mappings = new List<SpotifyTrackMapping>();
|
||||
|
||||
foreach (var spotifyId in pagedIds)
|
||||
{
|
||||
var mapping = await GetMappingAsync(spotifyId);
|
||||
if (mapping != null)
|
||||
{
|
||||
mappings.Add(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets count of all mappings.
|
||||
/// </summary>
|
||||
public async Task<int> GetMappingCountAsync()
|
||||
{
|
||||
var allIds = await GetAllMappingIdsAsync();
|
||||
return allIds.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics about mappings.
|
||||
/// </summary>
|
||||
public async Task<MappingStats> GetStatsAsync()
|
||||
{
|
||||
var allIds = await GetAllMappingIdsAsync();
|
||||
var stats = new MappingStats
|
||||
{
|
||||
TotalMappings = allIds.Count
|
||||
};
|
||||
|
||||
// Sample first 1000 to get stats (avoid loading all mappings)
|
||||
var sampleIds = allIds.Take(1000).ToList();
|
||||
|
||||
foreach (var spotifyId in sampleIds)
|
||||
{
|
||||
var mapping = await GetMappingAsync(spotifyId);
|
||||
if (mapping != null)
|
||||
{
|
||||
if (mapping.TargetType == "local")
|
||||
{
|
||||
stats.LocalMappings++;
|
||||
}
|
||||
else if (mapping.TargetType == "external")
|
||||
{
|
||||
stats.ExternalMappings++;
|
||||
}
|
||||
|
||||
if (mapping.Source == "manual")
|
||||
{
|
||||
stats.ManualMappings++;
|
||||
}
|
||||
else if (mapping.Source == "auto")
|
||||
{
|
||||
stats.AutoMappings++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extrapolate if we sampled
|
||||
if (allIds.Count > 1000)
|
||||
{
|
||||
var ratio = (double)allIds.Count / sampleIds.Count;
|
||||
stats.LocalMappings = (int)(stats.LocalMappings * ratio);
|
||||
stats.ExternalMappings = (int)(stats.ExternalMappings * ratio);
|
||||
stats.ManualMappings = (int)(stats.ManualMappings * ratio);
|
||||
stats.AutoMappings = (int)(stats.AutoMappings * ratio);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
private async Task AddToAllMappingsSetAsync(string spotifyId)
|
||||
{
|
||||
var allIds = await GetAllMappingIdsAsync();
|
||||
|
||||
if (!allIds.Contains(spotifyId))
|
||||
{
|
||||
allIds.Add(spotifyId);
|
||||
var json = JsonSerializer.Serialize(allIds);
|
||||
await _cache.SetStringAsync(AllMappingsKey, json, expiry: null);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveFromAllMappingsSetAsync(string spotifyId)
|
||||
{
|
||||
var allIds = await GetAllMappingIdsAsync();
|
||||
|
||||
if (allIds.Remove(spotifyId))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(allIds);
|
||||
await _cache.SetStringAsync(AllMappingsKey, json, expiry: null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates all playlist stats caches.
|
||||
/// Called when a mapping is saved/deleted to ensure stats are recalculated.
|
||||
/// </summary>
|
||||
private async Task InvalidateAllPlaylistStatsCachesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Delete all keys matching the pattern "spotify:playlist:stats:*"
|
||||
// Note: This is a simple implementation that deletes known patterns
|
||||
// In production, you might want to track playlist names or use Redis SCAN
|
||||
|
||||
// For now, we'll just log that stats should be recalculated
|
||||
// The stats will be recalculated on next request since they check global mappings
|
||||
_logger.LogDebug("Mapping changed - playlist stats will be recalculated on next request");
|
||||
|
||||
// Optionally: Delete the admin playlist summary cache to force immediate refresh
|
||||
var summaryFile = "/app/cache/admin_playlists_summary.json";
|
||||
if (File.Exists(summaryFile))
|
||||
{
|
||||
File.Delete(summaryFile);
|
||||
_logger.LogDebug("Deleted admin playlist summary cache");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to invalidate playlist stats caches");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about Spotify track mappings.
|
||||
/// </summary>
|
||||
public class MappingStats
|
||||
{
|
||||
public int TotalMappings { get; set; }
|
||||
public int LocalMappings { get; set; }
|
||||
public int ExternalMappings { get; set; }
|
||||
public int ManualMappings { get; set; }
|
||||
public int AutoMappings { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Validates Spotify track mappings to ensure they're still accurate.
|
||||
/// - Local mappings: Checks if Jellyfin track still exists (every 7 days)
|
||||
/// - External mappings: Searches for local match to upgrade (every playlist sync)
|
||||
/// </summary>
|
||||
public class SpotifyMappingValidationService
|
||||
{
|
||||
private readonly SpotifyMappingService _mappingService;
|
||||
private readonly JellyfinProxyService _jellyfinProxy;
|
||||
private readonly JellyfinSettings _jellyfinSettings;
|
||||
private readonly ILogger<SpotifyMappingValidationService> _logger;
|
||||
|
||||
public SpotifyMappingValidationService(
|
||||
SpotifyMappingService mappingService,
|
||||
JellyfinProxyService jellyfinProxy,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
ILogger<SpotifyMappingValidationService> logger)
|
||||
{
|
||||
_mappingService = mappingService;
|
||||
_jellyfinProxy = jellyfinProxy;
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a single mapping. Returns updated mapping or null if should be deleted.
|
||||
/// </summary>
|
||||
public async Task<SpotifyTrackMapping?> ValidateMappingAsync(SpotifyTrackMapping mapping, bool isPlaylistSync = false)
|
||||
{
|
||||
if (!mapping.NeedsValidation(isPlaylistSync))
|
||||
{
|
||||
_logger.LogDebug("Mapping {SpotifyId} doesn't need validation yet", mapping.SpotifyId);
|
||||
return mapping;
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔍 Validating mapping: {SpotifyId} → {TargetType} {TargetId}",
|
||||
mapping.SpotifyId,
|
||||
mapping.TargetType,
|
||||
mapping.TargetType == "local" ? mapping.LocalId : $"{mapping.ExternalProvider}:{mapping.ExternalId}");
|
||||
|
||||
if (mapping.TargetType == "local")
|
||||
{
|
||||
return await ValidateLocalMappingAsync(mapping);
|
||||
}
|
||||
else if (mapping.TargetType == "external")
|
||||
{
|
||||
return await ValidateExternalMappingAsync(mapping);
|
||||
}
|
||||
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a local mapping - checks if Jellyfin track still exists.
|
||||
/// If deleted: clear mapping and search for new local match, fallback to external.
|
||||
/// </summary>
|
||||
private async Task<SpotifyTrackMapping?> ValidateLocalMappingAsync(SpotifyTrackMapping mapping)
|
||||
{
|
||||
if (string.IsNullOrEmpty(mapping.LocalId))
|
||||
{
|
||||
_logger.LogWarning("Local mapping has no LocalId, deleting: {SpotifyId}", mapping.SpotifyId);
|
||||
await _mappingService.DeleteMappingAsync(mapping.SpotifyId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if Jellyfin track still exists
|
||||
try
|
||||
{
|
||||
var (response, _) = await _jellyfinProxy.GetJsonAsyncInternal(
|
||||
$"Items/{mapping.LocalId}",
|
||||
new Dictionary<string, string>());
|
||||
|
||||
if (response != null && response.RootElement.TryGetProperty("Name", out _))
|
||||
{
|
||||
// Track still exists, update validation timestamp
|
||||
mapping.LastValidatedAt = DateTime.UtcNow;
|
||||
await _mappingService.SaveMappingAsync(mapping);
|
||||
|
||||
_logger.LogInformation("✓ Local track still exists: {LocalId}", mapping.LocalId);
|
||||
return mapping;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Local track not found or error checking: {LocalId}", mapping.LocalId);
|
||||
}
|
||||
|
||||
// Track doesn't exist anymore - clear mapping and re-search
|
||||
_logger.LogWarning("❌ Local track deleted: {LocalId}, re-searching for {Title} - {Artist}",
|
||||
mapping.LocalId,
|
||||
mapping.Metadata?.Title ?? "Unknown",
|
||||
mapping.Metadata?.Artist ?? "Unknown");
|
||||
|
||||
await _mappingService.DeleteMappingAsync(mapping.SpotifyId);
|
||||
|
||||
// Try to find new local match
|
||||
if (mapping.Metadata != null)
|
||||
{
|
||||
var newLocalMatch = await SearchJellyfinForTrackAsync(
|
||||
mapping.Metadata.Title ?? "",
|
||||
mapping.Metadata.Artist ?? "");
|
||||
|
||||
if (newLocalMatch != null)
|
||||
{
|
||||
_logger.LogInformation("✓ Found new local match: {Title} → {NewLocalId}",
|
||||
mapping.Metadata.Title,
|
||||
newLocalMatch.Id);
|
||||
|
||||
// Create new local mapping
|
||||
await _mappingService.SaveLocalMappingAsync(
|
||||
mapping.SpotifyId,
|
||||
newLocalMatch.Id,
|
||||
mapping.Metadata);
|
||||
|
||||
mapping.LocalId = newLocalMatch.Id;
|
||||
mapping.LastValidatedAt = DateTime.UtcNow;
|
||||
return mapping;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("❌ No local match found, will fallback to external on next match");
|
||||
}
|
||||
}
|
||||
|
||||
return null; // Mapping deleted, will re-match from scratch
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an external mapping - searches Jellyfin to see if track is now local.
|
||||
/// If found locally: upgrade mapping from external → local.
|
||||
/// </summary>
|
||||
private async Task<SpotifyTrackMapping?> ValidateExternalMappingAsync(SpotifyTrackMapping mapping)
|
||||
{
|
||||
if (mapping.Metadata == null)
|
||||
{
|
||||
_logger.LogWarning("⚠️ External mapping has NO METADATA, cannot search for local match: {SpotifyId}", mapping.SpotifyId);
|
||||
mapping.LastValidatedAt = DateTime.UtcNow;
|
||||
await _mappingService.SaveMappingAsync(mapping);
|
||||
return mapping;
|
||||
}
|
||||
|
||||
// Search Jellyfin for local match
|
||||
var localMatch = await SearchJellyfinForTrackAsync(
|
||||
mapping.Metadata.Title ?? "",
|
||||
mapping.Metadata.Artist ?? "");
|
||||
|
||||
if (localMatch != null)
|
||||
{
|
||||
// Found in local library! Upgrade mapping
|
||||
_logger.LogInformation("🎉 UPGRADE: External → Local for {Title} - {Artist}",
|
||||
mapping.Metadata.Title,
|
||||
mapping.Metadata.Artist);
|
||||
_logger.LogInformation(" Old: {Provider}:{ExternalId}",
|
||||
mapping.ExternalProvider,
|
||||
mapping.ExternalId);
|
||||
_logger.LogInformation(" New: Jellyfin:{LocalId}",
|
||||
localMatch.Id);
|
||||
|
||||
// Update mapping to local
|
||||
mapping.TargetType = "local";
|
||||
mapping.LocalId = localMatch.Id;
|
||||
mapping.ExternalProvider = null;
|
||||
mapping.ExternalId = null;
|
||||
mapping.LastValidatedAt = DateTime.UtcNow;
|
||||
mapping.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _mappingService.SaveMappingAsync(mapping);
|
||||
return mapping;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Still not in local library, keep external mapping
|
||||
_logger.LogDebug("External track not yet in local library: {Title} - {Artist}",
|
||||
mapping.Metadata.Title,
|
||||
mapping.Metadata.Artist);
|
||||
|
||||
mapping.LastValidatedAt = DateTime.UtcNow;
|
||||
await _mappingService.SaveMappingAsync(mapping);
|
||||
return mapping;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches Jellyfin for a track using fuzzy matching (same algorithm as playlist matching).
|
||||
/// Uses greedy algorithm + Levenshtein distance.
|
||||
/// </summary>
|
||||
private async Task<Song?> SearchJellyfinForTrackAsync(string title, string artist)
|
||||
{
|
||||
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Search Jellyfin using same query format as playlist matching
|
||||
var query = $"{title} {artist}";
|
||||
var searchParams = new Dictionary<string, string>
|
||||
{
|
||||
["searchTerm"] = query,
|
||||
["includeItemTypes"] = "Audio",
|
||||
["recursive"] = "true",
|
||||
["limit"] = "10"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_jellyfinSettings.LibraryId))
|
||||
{
|
||||
searchParams["parentId"] = _jellyfinSettings.LibraryId;
|
||||
}
|
||||
|
||||
var (response, _) = await _jellyfinProxy.GetJsonAsyncInternal("Items", searchParams);
|
||||
|
||||
if (response == null || !response.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Score all results using fuzzy matching (same as SpotifyTrackMatchingService)
|
||||
var candidates = new List<(Song Song, double Score)>();
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var itemTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var itemArtist = item.TryGetProperty("AlbumArtist", out var artistEl) ? artistEl.GetString() ?? "" : "";
|
||||
var itemId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrEmpty(itemId)) continue;
|
||||
|
||||
// Calculate similarity using aggressive matching (same as playlist matching)
|
||||
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, itemTitle);
|
||||
var artistScore = FuzzyMatcher.CalculateSimilarity(artist, itemArtist);
|
||||
|
||||
// Weight: 70% title, 30% artist (same as playlist matching)
|
||||
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
||||
|
||||
// Same thresholds as playlist matching
|
||||
if (totalScore >= 40 || (artistScore >= 70 && titleScore >= 30) || titleScore >= 85)
|
||||
{
|
||||
var song = new Song
|
||||
{
|
||||
Id = itemId,
|
||||
Title = itemTitle,
|
||||
Artist = itemArtist,
|
||||
Album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() ?? "" : "",
|
||||
IsLocal = true
|
||||
};
|
||||
|
||||
candidates.Add((song, totalScore));
|
||||
}
|
||||
}
|
||||
|
||||
// Return best match (highest score)
|
||||
var bestMatch = candidates.OrderByDescending(c => c.Score).FirstOrDefault();
|
||||
|
||||
if (bestMatch.Song != null)
|
||||
{
|
||||
_logger.LogDebug("Found local match: {Title} - {Artist} (score: {Score:F1})",
|
||||
bestMatch.Song.Title,
|
||||
bestMatch.Song.Artist,
|
||||
bestMatch.Score);
|
||||
return bestMatch.Song;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching Jellyfin for track: {Title} - {Artist}", title, artist);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates all mappings for tracks in active playlists.
|
||||
/// Processes in batches, oldest-first.
|
||||
/// </summary>
|
||||
public async Task ValidateMappingsForPlaylistAsync(
|
||||
List<SpotifyPlaylistTrack> tracks,
|
||||
bool isPlaylistSync = false,
|
||||
int batchSize = 50)
|
||||
{
|
||||
var spotifyIds = tracks.Select(t => t.SpotifyId).Distinct().ToList();
|
||||
|
||||
_logger.LogInformation("Validating mappings for {Count} tracks from playlist (isPlaylistSync: {IsSync})",
|
||||
spotifyIds.Count,
|
||||
isPlaylistSync);
|
||||
|
||||
var validatedCount = 0;
|
||||
var upgradedCount = 0;
|
||||
var deletedCount = 0;
|
||||
|
||||
foreach (var spotifyId in spotifyIds)
|
||||
{
|
||||
var mapping = await _mappingService.GetMappingAsync(spotifyId);
|
||||
if (mapping == null) continue;
|
||||
|
||||
var originalType = mapping.TargetType;
|
||||
var validatedMapping = await ValidateMappingAsync(mapping, isPlaylistSync);
|
||||
|
||||
if (validatedMapping == null)
|
||||
{
|
||||
deletedCount++;
|
||||
}
|
||||
else if (validatedMapping.TargetType != originalType)
|
||||
{
|
||||
upgradedCount++;
|
||||
}
|
||||
|
||||
validatedCount++;
|
||||
|
||||
// Rate limiting to avoid overwhelming Jellyfin
|
||||
if (validatedCount % batchSize == 0)
|
||||
{
|
||||
await Task.Delay(100); // 100ms pause every 50 validations
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ Validation complete: {Validated} checked, {Upgraded} upgraded to local, {Deleted} deleted",
|
||||
validatedCount,
|
||||
upgradedCount,
|
||||
deletedCount);
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
if (_spotifyApiSettings.Value.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.Value.SessionCookie))
|
||||
{
|
||||
_logger.LogInformation("SpotifyApi is enabled with session cookie - using direct Spotify API instead of Jellyfin scraping");
|
||||
_logger.LogInformation("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists.");
|
||||
_logger.LogDebug("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists.");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
@@ -77,7 +77,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
_logger.LogWarning("Jellyfin URL or API key not configured, Spotify playlist injection disabled");
|
||||
_logger.LogInformation("Jellyfin URL or API key not configured, Spotify playlist injection disabled");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
@@ -115,7 +115,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Skipping startup fetch - already have cached files");
|
||||
_logger.LogWarning("Skipping startup fetch - already have cached files");
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
}
|
||||
@@ -188,13 +188,13 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
foreach (var playlistName in _playlistIdToName.Values)
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
|
||||
|
||||
// Check file cache
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
|
||||
_logger.LogInformation(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
|
||||
_logger.LogDebug(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
|
||||
|
||||
// Load into Redis if not already there
|
||||
if (!await _cache.ExistsAsync(cacheKey))
|
||||
@@ -207,7 +207,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
// Check Redis cache
|
||||
if (await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
_logger.LogInformation(" {Playlist}: Found in Redis cache", playlistName);
|
||||
_logger.LogDebug(" {Playlist}: Found in Redis cache", playlistName);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
if (allPlaylistsHaveCache)
|
||||
{
|
||||
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
|
||||
_logger.LogWarning("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -245,18 +245,18 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
if (tracks != null && tracks.Count > 0)
|
||||
{
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
|
||||
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
|
||||
|
||||
// No expiration - cache persists until next Jellyfin job generates new file
|
||||
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365));
|
||||
_logger.LogInformation("Loaded {Count} tracks from file cache for {Playlist} (age: {Age:F1}h, no expiration)",
|
||||
_logger.LogDebug("Loaded {Count} tracks from file cache for {Playlist} (age: {Age:F1}h, no expiration)",
|
||||
tracks.Count, playlistName, fileAge.TotalHours);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load file cache for {Playlist}", playlistName);
|
||||
_logger.LogError(ex, "Failed to load file cache for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
_logger.LogInformation("Saved {Count} tracks to file cache for {Playlist}",
|
||||
_logger.LogDebug("Saved {Count} tracks to file cache for {Playlist}",
|
||||
tracks.Count, playlistName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -279,7 +279,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("=== FETCHING MISSING TRACKS ===");
|
||||
_logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count);
|
||||
_logger.LogDebug("Processing {Count} playlists", _playlistIdToName.Count);
|
||||
|
||||
// Track when we find files to optimize search for other playlists
|
||||
DateTime? firstFoundTime = null;
|
||||
@@ -310,7 +310,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
CancellationToken cancellationToken,
|
||||
DateTime? hintTime = null)
|
||||
{
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
|
||||
|
||||
// Check if we have existing cache
|
||||
var existingTracks = await _cache.GetAsync<List<MissingTrack>>(cacheKey);
|
||||
@@ -324,11 +324,11 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
if (existingTracks != null && existingTracks.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(" Current cache has {Count} tracks, will search for newer file", existingTracks.Count);
|
||||
_logger.LogDebug(" Current cache has {Count} tracks, will search for newer file", existingTracks.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(" No existing cache, will search for missing tracks file");
|
||||
_logger.LogDebug(" No existing cache, will search for missing tracks file");
|
||||
}
|
||||
|
||||
var settings = _spotifySettings.Value;
|
||||
@@ -428,7 +428,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
// Keep the existing cache - don't let it expire
|
||||
if (existingTracks != null && existingTracks.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(" ✓ Keeping existing cache with {Count} tracks (no expiration)", existingTracks.Count);
|
||||
_logger.LogDebug(" ✓ Keeping existing cache with {Count} tracks (no expiration)", existingTracks.Count);
|
||||
// Re-save with no expiration to ensure it persists
|
||||
await _cache.SetAsync(cacheKey, existingTracks, TimeSpan.FromDays(365)); // Effectively no expiration
|
||||
}
|
||||
@@ -444,7 +444,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
if (tracks != null && tracks.Count > 0)
|
||||
{
|
||||
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365)); // No expiration
|
||||
_logger.LogInformation(" ✓ Loaded {Count} tracks from file cache (no expiration)", tracks.Count);
|
||||
_logger.LogDebug(" ✓ Loaded {Count} tracks from file cache (no expiration)", tracks.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -476,7 +476,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
try
|
||||
{
|
||||
// Log every request with the actual filename
|
||||
_logger.LogInformation("Checking: {Playlist} at {DateTime}", playlistName, time.ToString("yyyy-MM-dd HH:mm"));
|
||||
_logger.LogDebug("Checking: {Playlist} at {DateTime}", playlistName, time.ToString("yyyy-MM-dd HH:mm"));
|
||||
|
||||
var response = await httpClient.GetAsync(url, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
@@ -486,7 +486,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
|
||||
|
||||
// Save to both Redis and file with extended TTL until next job runs
|
||||
// Set to 365 days (effectively no expiration) - will be replaced when Jellyfin generates new file
|
||||
@@ -502,7 +502,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
|
||||
_logger.LogError(ex, "Failed to fetch {Filename}", filename);
|
||||
}
|
||||
|
||||
return (false, null);
|
||||
|
||||
@@ -3,6 +3,7 @@ using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Cronos;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
@@ -14,6 +15,9 @@ namespace allstarr.Services.Spotify;
|
||||
/// - ISRC codes available for exact matching
|
||||
/// - Real-time data without waiting for plugin sync schedules
|
||||
/// - Full track metadata (duration, release date, etc.)
|
||||
///
|
||||
/// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval.
|
||||
/// Cache persists until next cron run to prevent excess Spotify API calls.
|
||||
/// </summary>
|
||||
public class SpotifyPlaylistFetcher : BackgroundService
|
||||
{
|
||||
@@ -23,7 +27,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
private readonly SpotifyApiClient _spotifyClient;
|
||||
private readonly RedisCacheService _cache;
|
||||
|
||||
private const string CacheDirectory = "/app/cache/spotify";
|
||||
private const string CacheKeyPrefix = "spotify:playlist:";
|
||||
|
||||
// Track Spotify playlist IDs after discovery
|
||||
@@ -45,6 +48,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Spotify playlist tracks in order, using cache if available.
|
||||
/// Cache persists until next cron run to prevent excess API calls.
|
||||
/// </summary>
|
||||
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
||||
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
||||
@@ -57,7 +61,38 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
if (cached != null && cached.Tracks.Count > 0)
|
||||
{
|
||||
var age = DateTime.UtcNow - cached.FetchedAt;
|
||||
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
||||
|
||||
// Calculate if cache should still be valid based on cron schedule
|
||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
var shouldRefresh = false;
|
||||
|
||||
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(playlistConfig.SyncSchedule);
|
||||
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue && DateTime.UtcNow >= nextRun.Value)
|
||||
{
|
||||
shouldRefresh = true;
|
||||
_logger.LogWarning("Cache expired for '{Name}' - next cron run was at {NextRun} UTC",
|
||||
playlistName, nextRun.Value);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not parse cron schedule for '{Name}', falling back to cache duration", playlistName);
|
||||
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No cron schedule, use cache duration from settings
|
||||
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
|
||||
}
|
||||
|
||||
if (!shouldRefresh)
|
||||
{
|
||||
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
|
||||
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
||||
@@ -65,47 +100,23 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
// Try file cache
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var filePlaylist = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
|
||||
if (filePlaylist != null && filePlaylist.Tracks.Count > 0)
|
||||
{
|
||||
var age = DateTime.UtcNow - filePlaylist.FetchedAt;
|
||||
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
||||
{
|
||||
_logger.LogDebug("Using file-cached playlist '{Name}' ({Count} tracks)",
|
||||
playlistName, filePlaylist.Tracks.Count);
|
||||
return filePlaylist.Tracks;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read file cache for '{Name}'", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Need to fetch fresh - try to use cached or configured Spotify playlist ID
|
||||
// Cache miss or expired - need to fetch fresh from Spotify
|
||||
// Try to use cached or configured Spotify playlist ID
|
||||
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
||||
{
|
||||
// Check if we have a configured Spotify ID for this playlist
|
||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
|
||||
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
if (config != null && !string.IsNullOrEmpty(config.Id))
|
||||
{
|
||||
// Use the configured Spotify playlist ID directly
|
||||
spotifyId = playlistConfig.Id;
|
||||
spotifyId = config.Id;
|
||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No configured ID, try searching by name (works for public/followed playlists)
|
||||
_logger.LogDebug("No configured Spotify ID for '{Name}', searching...", playlistName);
|
||||
_logger.LogInformation("No configured Spotify ID for '{Name}', searching...", playlistName);
|
||||
var playlists = await _spotifyClient.SearchUserPlaylistsAsync(playlistName);
|
||||
|
||||
var exactMatch = playlists.FirstOrDefault(p =>
|
||||
@@ -113,21 +124,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
if (exactMatch == null)
|
||||
{
|
||||
_logger.LogWarning("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
|
||||
|
||||
// Return file cache even if expired, as a fallback
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var fallback = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
|
||||
if (fallback != null)
|
||||
{
|
||||
_logger.LogWarning("Using expired file cache as fallback for '{Name}'", playlistName);
|
||||
return fallback.Tracks;
|
||||
}
|
||||
}
|
||||
|
||||
return new List<SpotifyPlaylistTrack>();
|
||||
_logger.LogInformation("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
|
||||
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
||||
}
|
||||
|
||||
spotifyId = exactMatch.SpotifyId;
|
||||
@@ -140,16 +138,42 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId);
|
||||
if (playlist == null || playlist.Tracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch playlist '{Name}' from Spotify", playlistName);
|
||||
_logger.LogError("Failed to fetch playlist '{Name}' from Spotify", playlistName);
|
||||
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
||||
}
|
||||
|
||||
// Update cache
|
||||
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
|
||||
await SaveToFileCacheAsync(playlistName, playlist);
|
||||
// Calculate cache expiration based on cron schedule
|
||||
var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
|
||||
|
||||
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
|
||||
playlistName, playlist.Tracks.Count);
|
||||
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(playlistCfg.SyncSchedule);
|
||||
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
|
||||
// Add 5 minutes buffer
|
||||
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
|
||||
|
||||
_logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)",
|
||||
playlistName, nextRun.Value, timeUntilNextRun.TotalHours);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Update Redis cache with cron-based expiration
|
||||
await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
|
||||
|
||||
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
|
||||
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
|
||||
|
||||
return playlist.Tracks;
|
||||
}
|
||||
@@ -206,9 +230,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("SpotifyPlaylistFetcher: Starting up...");
|
||||
|
||||
// Ensure cache directory exists
|
||||
Directory.CreateDirectory(CacheDirectory);
|
||||
|
||||
if (!_spotifyApiSettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify API integration is DISABLED");
|
||||
@@ -218,13 +239,13 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
_logger.LogWarning("Spotify session cookie not configured - cannot access editorial playlists");
|
||||
_logger.LogError("Spotify session cookie not configured - cannot access editorial playlists");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify we can get an access token (the most reliable auth check)
|
||||
_logger.LogInformation("Attempting Spotify authentication...");
|
||||
_logger.LogDebug("Attempting Spotify authentication...");
|
||||
var token = await _spotifyClient.GetWebAccessTokenAsync(stoppingToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
@@ -235,32 +256,99 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
_logger.LogInformation("Spotify API ENABLED");
|
||||
_logger.LogInformation("Authenticated via sp_dc session cookie");
|
||||
_logger.LogInformation("Cache duration: {Minutes} minutes", _spotifyApiSettings.CacheDurationMinutes);
|
||||
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
||||
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
||||
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
_logger.LogInformation(" - {Name}", playlist.Name);
|
||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * *" : playlist.SyncSchedule;
|
||||
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
||||
}
|
||||
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Initial fetch of all playlists
|
||||
await FetchAllPlaylistsAsync(stoppingToken);
|
||||
|
||||
// Periodic refresh loop
|
||||
// Cron-based refresh loop - only fetch when cron schedule triggers
|
||||
// This prevents excess Spotify API calls
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
|
||||
try
|
||||
{
|
||||
// Check each playlist to see if it needs refreshing based on cron schedule
|
||||
var now = DateTime.UtcNow;
|
||||
var needsRefresh = new List<string>();
|
||||
|
||||
foreach (var config in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * *" : config.SyncSchedule;
|
||||
|
||||
try
|
||||
{
|
||||
await FetchAllPlaylistsAsync(stoppingToken);
|
||||
var cron = CronExpression.Parse(schedule);
|
||||
|
||||
// Check if we have cached data
|
||||
var cacheKey = $"{CacheKeyPrefix}{config.Name}";
|
||||
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||
|
||||
if (cached != null)
|
||||
{
|
||||
// Calculate when the next run should be after the last fetch
|
||||
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue && now >= nextRun.Value)
|
||||
{
|
||||
needsRefresh.Add(config.Name);
|
||||
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
|
||||
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No cache, fetch it
|
||||
needsRefresh.Add(config.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during periodic playlist refresh");
|
||||
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}", config.Name, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch playlists that need refreshing
|
||||
if (needsRefresh.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
|
||||
|
||||
foreach (var playlistName in needsRefresh)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
await GetPlaylistTracksAsync(playlistName);
|
||||
|
||||
// Rate limiting between playlists
|
||||
if (playlistName != needsRefresh.Last())
|
||||
{
|
||||
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", playlistName);
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED FETCHING PLAYLISTS ===");
|
||||
}
|
||||
|
||||
// Sleep for 1 hour before checking again
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in playlist fetcher loop");
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,7 +364,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
try
|
||||
{
|
||||
var tracks = await GetPlaylistTracksAsync(config.Name);
|
||||
_logger.LogInformation(" {Name}: {Count} tracks", config.Name, tracks.Count);
|
||||
_logger.LogDebug(" {Name}: {Count} tracks", config.Name, tracks.Count);
|
||||
|
||||
// Log sample of track order for debugging
|
||||
if (tracks.Count > 0)
|
||||
@@ -301,36 +389,11 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
// Wait 3 seconds between each playlist to avoid 429 TooManyRequests errors
|
||||
if (config != _spotifyImportSettings.Playlists.Last())
|
||||
{
|
||||
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
|
||||
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", config.Name);
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED FETCHING SPOTIFY PLAYLISTS ===");
|
||||
}
|
||||
|
||||
private string GetCacheFilePath(string playlistName)
|
||||
{
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
return Path.Combine(CacheDirectory, $"{safeName}_spotify.json");
|
||||
}
|
||||
|
||||
private async Task SaveToFileCacheAsync(string playlistName, SpotifyPlaylist playlist)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var json = JsonSerializer.Serialize(playlist, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
_logger.LogDebug("Saved playlist '{Name}' to file cache", playlistName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save file cache for '{Name}'", playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using allstarr.Services.Jellyfin;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Cronos;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
@@ -17,29 +18,40 @@ namespace allstarr.Services.Spotify;
|
||||
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
||||
///
|
||||
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
||||
///
|
||||
/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers.
|
||||
/// Manual refresh is always allowed. Cache persists until next cron run.
|
||||
/// </summary>
|
||||
public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly SpotifyMappingService _mappingService;
|
||||
private readonly SpotifyMappingValidationService _validationService;
|
||||
private readonly ILogger<SpotifyTrackMatchingService> _logger;
|
||||
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 DateTime _lastMatchingRun = DateTime.MinValue;
|
||||
private readonly TimeSpan _minimumMatchingInterval = TimeSpan.FromMinutes(5); // Don't run more than once per 5 minutes
|
||||
|
||||
// Track last run time per playlist to prevent duplicate runs
|
||||
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
|
||||
private readonly TimeSpan _minimumRunInterval = TimeSpan.FromMinutes(5); // Cooldown between runs
|
||||
|
||||
public SpotifyTrackMatchingService(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
RedisCacheService cache,
|
||||
SpotifyMappingService mappingService,
|
||||
SpotifyMappingValidationService validationService,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SpotifyTrackMatchingService> logger)
|
||||
{
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_cache = cache;
|
||||
_mappingService = mappingService;
|
||||
_validationService = validationService;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -57,17 +69,29 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||
? "ISRC-preferred" : "fuzzy";
|
||||
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
||||
_logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule");
|
||||
|
||||
// Log all playlist schedules
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
||||
}
|
||||
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Wait a bit for the fetcher to run first
|
||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||
@@ -75,7 +99,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Run once on startup to match any existing missing tracks
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Running initial track matching on startup");
|
||||
_logger.LogInformation("Running initial track matching on startup (one-time)");
|
||||
await MatchAllPlaylistsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -83,52 +107,106 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
_logger.LogError(ex, "Error during startup track matching");
|
||||
}
|
||||
|
||||
// Now start the periodic matching loop
|
||||
// Now start the cron-based scheduling loop
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Wait for configured interval before next run (default 24 hours)
|
||||
var intervalHours = _spotifySettings.MatchingIntervalHours;
|
||||
if (intervalHours <= 0)
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Periodic matching disabled (MatchingIntervalHours = {Hours}), only startup run will execute", intervalHours);
|
||||
break; // Exit loop - only run once on startup
|
||||
}
|
||||
// Calculate next run time for each playlist
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
|
||||
|
||||
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * *" : playlist.SyncSchedule;
|
||||
|
||||
try
|
||||
{
|
||||
await MatchAllPlaylistsAsync(stoppingToken);
|
||||
var cron = CronExpression.Parse(schedule);
|
||||
var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
nextRuns.Add((playlist.Name, nextRun.Value, cron));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Could not calculate next run for playlist {Name} with schedule {Schedule}",
|
||||
playlist.Name, schedule);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in track matching service");
|
||||
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}",
|
||||
playlist.Name, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextRuns.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No valid cron schedules found, sleeping for 1 hour");
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the next playlist that needs to run
|
||||
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
|
||||
var waitTime = nextPlaylist.NextRun - now;
|
||||
|
||||
if (waitTime.TotalSeconds > 0)
|
||||
{
|
||||
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
|
||||
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
|
||||
|
||||
// Wait until next run (or max 1 hour to re-check schedules)
|
||||
var maxWait = TimeSpan.FromHours(1);
|
||||
var actualWait = waitTime > maxWait ? maxWait : waitTime;
|
||||
await Task.Delay(actualWait, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Time to run this playlist
|
||||
_logger.LogInformation("=== CRON TRIGGER: Running scheduled match for {Playlist} ===", nextPlaylist.PlaylistName);
|
||||
|
||||
// Check cooldown to prevent duplicate runs
|
||||
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
|
||||
{
|
||||
var timeSinceLastRun = now - lastRun;
|
||||
if (timeSinceLastRun < _minimumRunInterval)
|
||||
{
|
||||
_logger.LogWarning("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||
nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Run matching for this playlist
|
||||
await MatchSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
|
||||
_lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
|
||||
|
||||
_logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
|
||||
nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in cron scheduling loop");
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger matching manually for all playlists (called from controller).
|
||||
/// Matches tracks for a single playlist (called by cron scheduler or manual trigger).
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingAsync()
|
||||
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for all playlists");
|
||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger matching for a specific playlist (called from controller).
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
|
||||
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogWarning("Playlist {Playlist} not found in configuration", playlistName);
|
||||
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,63 +220,6 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (playlistFetcher != null)
|
||||
{
|
||||
// Use new direct API mode with ISRC support
|
||||
await MatchPlaylistTracksWithIsrcAsync(
|
||||
playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to legacy mode
|
||||
await MatchPlaylistTracksLegacyAsync(
|
||||
playlist.Name, metadataService, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if we've run too recently (cooldown period)
|
||||
var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
|
||||
if (timeSinceLastRun < _minimumMatchingInterval)
|
||||
{
|
||||
_logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)",
|
||||
(int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
||||
_lastMatchingRun = DateTime.UtcNow;
|
||||
|
||||
var playlists = _spotifySettings.Playlists;
|
||||
if (playlists.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No playlists configured for matching");
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||
|
||||
// Check if we should use the new SpotifyPlaylistFetcher
|
||||
SpotifyPlaylistFetcher? playlistFetcher = null;
|
||||
if (_spotifyApiSettings.Enabled)
|
||||
{
|
||||
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
||||
}
|
||||
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
if (playlistFetcher != null)
|
||||
@@ -217,10 +238,70 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
||||
/// <summary>
|
||||
/// Public method to trigger matching manually for all playlists (called from controller).
|
||||
/// This bypasses cron schedules and runs immediately.
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingAsync()
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
|
||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger matching for a specific playlist (called from controller).
|
||||
/// This bypasses cron schedules and runs immediately.
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (bypassing cron schedule)", playlistName);
|
||||
|
||||
// Check cooldown to prevent abuse
|
||||
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
||||
{
|
||||
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||
if (timeSinceLastRun < _minimumRunInterval)
|
||||
{
|
||||
_logger.LogWarning("Skipping manual refresh for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
||||
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before refreshing again");
|
||||
}
|
||||
}
|
||||
|
||||
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
|
||||
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||
|
||||
var playlists = _spotifySettings.Playlists;
|
||||
if (playlists.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No playlists configured for matching");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -235,13 +316,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
|
||||
|
||||
// Get playlist tracks with full metadata including ISRC and position
|
||||
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
if (spotifyTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No tracks found for {Playlist}, skipping matching", playlistName);
|
||||
_logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -272,7 +353,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
|
||||
_logger.LogInformation("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
|
||||
}
|
||||
|
||||
var (existingTracksResponse, _) = await proxyService.GetJsonAsyncInternal(
|
||||
@@ -304,7 +385,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not fetch existing Jellyfin tracks for {Playlist}, will match all tracks", playlistName);
|
||||
_logger.LogError(ex, "Could not fetch existing Jellyfin tracks for {Playlist}, will match all tracks", playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,12 +397,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
if (tracksToMatch.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching",
|
||||
_logger.LogWarning("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching",
|
||||
spotifyTracks.Count, playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)",
|
||||
_logger.LogWarning("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)",
|
||||
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
|
||||
|
||||
// Check cache - use snapshot/timestamp to detect changes
|
||||
@@ -355,7 +436,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
if (!hasNewManualMappings)
|
||||
{
|
||||
_logger.LogInformation("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
|
||||
_logger.LogWarning("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
|
||||
playlistName, existingMatched.Count, tracksToMatch.Count);
|
||||
return;
|
||||
}
|
||||
@@ -363,32 +444,155 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
_logger.LogInformation("New manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
|
||||
}
|
||||
|
||||
// PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin)
|
||||
var jellyfinTracks = new List<Song>();
|
||||
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
|
||||
|
||||
if (proxyService != null && jellyfinSettings != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = jellyfinSettings.UserId;
|
||||
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
||||
var queryParams = new Dictionary<string, string> { ["Fields"] = "ProviderIds" };
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
queryParams["UserId"] = userId;
|
||||
}
|
||||
|
||||
var (response, _) = await proxyService.GetJsonAsyncInternal(playlistItemsUrl, queryParams);
|
||||
|
||||
if (response != null && response.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var song = new Song
|
||||
{
|
||||
Id = item.GetProperty("Id").GetString() ?? "",
|
||||
Title = item.GetProperty("Name").GetString() ?? "",
|
||||
Artist = item.TryGetProperty("AlbumArtist", out var artist) ? artist.GetString() ?? "" : "",
|
||||
Album = item.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
|
||||
IsLocal = true
|
||||
};
|
||||
jellyfinTracks.Add(song);
|
||||
}
|
||||
_logger.LogInformation("📚 Loaded {Count} tracks from Jellyfin playlist {Playlist}",
|
||||
jellyfinTracks.Count, playlistName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load Jellyfin tracks for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 2: Match Jellyfin tracks → Spotify tracks using fuzzy matching
|
||||
_logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify tracks",
|
||||
jellyfinTracks.Count, spotifyTracks.Count);
|
||||
|
||||
var localMatches = new Dictionary<string, (Song JellyfinTrack, SpotifyPlaylistTrack SpotifyTrack, double Score)>();
|
||||
var usedJellyfinIds = new HashSet<string>();
|
||||
var usedSpotifyIds = new HashSet<string>();
|
||||
|
||||
// Build all possible matches with scores
|
||||
var allLocalCandidates = new List<(Song JellyfinTrack, SpotifyPlaylistTrack SpotifyTrack, double Score)>();
|
||||
|
||||
foreach (var jellyfinTrack in jellyfinTracks)
|
||||
{
|
||||
foreach (var spotifyTrack in spotifyTracks)
|
||||
{
|
||||
var score = CalculateMatchScore(jellyfinTrack.Title, jellyfinTrack.Artist,
|
||||
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
|
||||
if (score >= 70) // Only consider good matches
|
||||
{
|
||||
allLocalCandidates.Add((jellyfinTrack, spotifyTrack, score));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Greedy assignment: best matches first
|
||||
foreach (var (jellyfinTrack, spotifyTrack, score) in allLocalCandidates.OrderByDescending(c => c.Score))
|
||||
{
|
||||
if (usedJellyfinIds.Contains(jellyfinTrack.Id)) continue;
|
||||
if (usedSpotifyIds.Contains(spotifyTrack.SpotifyId)) continue;
|
||||
|
||||
localMatches[spotifyTrack.SpotifyId] = (jellyfinTrack, spotifyTrack, score);
|
||||
usedJellyfinIds.Add(jellyfinTrack.Id);
|
||||
usedSpotifyIds.Add(spotifyTrack.SpotifyId);
|
||||
|
||||
// Save local mapping
|
||||
var metadata = new TrackMetadata
|
||||
{
|
||||
Title = spotifyTrack.Title,
|
||||
Artist = spotifyTrack.PrimaryArtist,
|
||||
Album = spotifyTrack.Album,
|
||||
ArtworkUrl = spotifyTrack.AlbumArtUrl,
|
||||
DurationMs = spotifyTrack.DurationMs
|
||||
};
|
||||
|
||||
await _mappingService.SaveLocalMappingAsync(spotifyTrack.SpotifyId, jellyfinTrack.Id, metadata);
|
||||
|
||||
_logger.LogInformation(" ✓ Local: {SpotifyTitle} → {JellyfinTitle} (score: {Score:F1})",
|
||||
spotifyTrack.Title, jellyfinTrack.Title, score);
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Matched {LocalCount}/{SpotifyCount} Spotify tracks to local Jellyfin tracks",
|
||||
localMatches.Count, spotifyTracks.Count);
|
||||
|
||||
// PHASE 3: For remaining unmatched Spotify tracks, search external providers
|
||||
var unmatchedSpotifyTracks = spotifyTracks
|
||||
.Where(t => !usedSpotifyIds.Contains(t.SpotifyId))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("🔍 Searching external providers for {Count} unmatched tracks",
|
||||
unmatchedSpotifyTracks.Count);
|
||||
|
||||
var matchedTracks = new List<MatchedTrack>();
|
||||
var isrcMatches = 0;
|
||||
var fuzzyMatches = 0;
|
||||
var noMatch = 0;
|
||||
|
||||
// GREEDY ASSIGNMENT: Collect all possible matches first, then assign optimally
|
||||
var allCandidates = new List<(SpotifyPlaylistTrack SpotifyTrack, Song MatchedSong, double Score, string MatchType)>();
|
||||
|
||||
// Process tracks in batches for parallel searching
|
||||
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
|
||||
for (int i = 0; i < orderedTracks.Count; i += BatchSize)
|
||||
// Process unmatched tracks in batches
|
||||
for (int i = 0; i < unmatchedSpotifyTracks.Count; i += BatchSize)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
var batch = orderedTracks.Skip(i).Take(BatchSize).ToList();
|
||||
_logger.LogDebug("Processing batch {Start}-{End} of {Total}",
|
||||
i + 1, Math.Min(i + BatchSize, orderedTracks.Count), orderedTracks.Count);
|
||||
var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList();
|
||||
|
||||
// Process all tracks in this batch in parallel
|
||||
var batchTasks = batch.Select(async spotifyTrack =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var candidates = new List<(Song Song, double Score, string MatchType)>();
|
||||
|
||||
// Try ISRC match first if available and enabled
|
||||
// Check global external mapping first
|
||||
var globalMapping = await _mappingService.GetMappingAsync(spotifyTrack.SpotifyId);
|
||||
if (globalMapping != null && globalMapping.TargetType == "external")
|
||||
{
|
||||
Song? mappedSong = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) &&
|
||||
!string.IsNullOrEmpty(globalMapping.ExternalId))
|
||||
{
|
||||
mappedSong = await metadataService.GetSongAsync(globalMapping.ExternalProvider, globalMapping.ExternalId);
|
||||
}
|
||||
|
||||
if (mappedSong != null)
|
||||
{
|
||||
candidates.Add((mappedSong, 100.0, "global-mapping-external"));
|
||||
return (spotifyTrack, candidates);
|
||||
}
|
||||
}
|
||||
|
||||
// Try ISRC match
|
||||
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||
{
|
||||
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||
@@ -398,7 +602,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
// Always try fuzzy matching to get more candidates
|
||||
// Fuzzy search external providers
|
||||
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
|
||||
spotifyTrack.Title,
|
||||
spotifyTrack.Artists,
|
||||
@@ -406,99 +610,168 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
foreach (var (song, score) in fuzzySongs)
|
||||
{
|
||||
candidates.Add((song, score, "fuzzy"));
|
||||
if (!song.IsLocal) // Only external tracks
|
||||
{
|
||||
candidates.Add((song, score, "fuzzy-external"));
|
||||
}
|
||||
}
|
||||
|
||||
return (spotifyTrack, candidates);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
_logger.LogError(ex, "Failed to match track: {Title}", spotifyTrack.Title);
|
||||
return (spotifyTrack, new List<(Song, double, string)>());
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
// Wait for all tracks in this batch to complete
|
||||
var batchResults = await Task.WhenAll(batchTasks);
|
||||
|
||||
// Collect all candidates
|
||||
foreach (var (spotifyTrack, candidates) in batchResults)
|
||||
foreach (var result in batchResults)
|
||||
{
|
||||
foreach (var (song, score, matchType) in candidates)
|
||||
foreach (var candidate in result.Item2)
|
||||
{
|
||||
allCandidates.Add((spotifyTrack, song, score, matchType));
|
||||
allCandidates.Add((result.Item1, candidate.Item1, candidate.Item2, candidate.Item3));
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting between batches
|
||||
if (i + BatchSize < orderedTracks.Count)
|
||||
if (i + BatchSize < unmatchedSpotifyTracks.Count)
|
||||
{
|
||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// GREEDY ASSIGNMENT: Assign each Spotify track to its best unique match
|
||||
// PHASE 4: Greedy assignment for external matches
|
||||
var usedSongIds = new HashSet<string>();
|
||||
var assignments = new Dictionary<string, (Song Song, double Score, string MatchType)>();
|
||||
var externalAssignments = new Dictionary<string, (Song Song, double Score, string MatchType)>();
|
||||
|
||||
// Sort candidates by score (highest first)
|
||||
var sortedCandidates = allCandidates
|
||||
.OrderByDescending(c => c.Score)
|
||||
.ToList();
|
||||
|
||||
foreach (var (spotifyTrack, song, score, matchType) in sortedCandidates)
|
||||
foreach (var (spotifyTrack, song, score, matchType) in allCandidates.OrderByDescending(c => c.Score))
|
||||
{
|
||||
// Skip if this Spotify track already has a match
|
||||
if (assignments.ContainsKey(spotifyTrack.SpotifyId))
|
||||
continue;
|
||||
if (externalAssignments.ContainsKey(spotifyTrack.SpotifyId)) continue;
|
||||
if (usedSongIds.Contains(song.Id)) continue;
|
||||
|
||||
// Skip if this song is already used
|
||||
if (usedSongIds.Contains(song.Id))
|
||||
continue;
|
||||
|
||||
// Assign this match
|
||||
assignments[spotifyTrack.SpotifyId] = (song, score, matchType);
|
||||
externalAssignments[spotifyTrack.SpotifyId] = (song, score, matchType);
|
||||
usedSongIds.Add(song.Id);
|
||||
|
||||
// Save external mapping
|
||||
var metadata = new TrackMetadata
|
||||
{
|
||||
Title = spotifyTrack.Title,
|
||||
Artist = spotifyTrack.PrimaryArtist,
|
||||
Album = spotifyTrack.Album,
|
||||
ArtworkUrl = spotifyTrack.AlbumArtUrl,
|
||||
DurationMs = spotifyTrack.DurationMs
|
||||
};
|
||||
|
||||
await _mappingService.SaveExternalMappingAsync(
|
||||
spotifyTrack.SpotifyId,
|
||||
song.ExternalProvider ?? "Unknown",
|
||||
song.ExternalId ?? song.Id,
|
||||
metadata);
|
||||
|
||||
if (matchType == "isrc") isrcMatches++;
|
||||
else fuzzyMatches++;
|
||||
|
||||
_logger.LogInformation(" ✓ External: {Title} → {Provider}:{ExternalId} (score: {Score:F1})",
|
||||
spotifyTrack.Title, song.ExternalProvider, song.ExternalId, score);
|
||||
}
|
||||
|
||||
// Build final matched tracks list
|
||||
foreach (var spotifyTrack in orderedTracks)
|
||||
// PHASE 5: Build final matched tracks list (local + external)
|
||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||
{
|
||||
if (assignments.TryGetValue(spotifyTrack.SpotifyId, out var match))
|
||||
MatchedTrack? matched = null;
|
||||
|
||||
// Check local matches first
|
||||
if (localMatches.TryGetValue(spotifyTrack.SpotifyId, out var localMatch))
|
||||
{
|
||||
var matched = new MatchedTrack
|
||||
matched = new MatchedTrack
|
||||
{
|
||||
Position = spotifyTrack.Position,
|
||||
SpotifyId = spotifyTrack.SpotifyId,
|
||||
SpotifyTitle = spotifyTrack.Title,
|
||||
SpotifyArtist = spotifyTrack.PrimaryArtist,
|
||||
Isrc = spotifyTrack.Isrc,
|
||||
MatchType = match.MatchType,
|
||||
MatchedSong = match.Song
|
||||
MatchType = "fuzzy-local",
|
||||
MatchedSong = localMatch.JellyfinTrack
|
||||
};
|
||||
}
|
||||
// Check external matches
|
||||
else if (externalAssignments.TryGetValue(spotifyTrack.SpotifyId, out var externalMatch))
|
||||
{
|
||||
matched = new MatchedTrack
|
||||
{
|
||||
Position = spotifyTrack.Position,
|
||||
SpotifyId = spotifyTrack.SpotifyId,
|
||||
SpotifyTitle = spotifyTrack.Title,
|
||||
SpotifyArtist = spotifyTrack.PrimaryArtist,
|
||||
Isrc = spotifyTrack.Isrc,
|
||||
MatchType = externalMatch.MatchType,
|
||||
MatchedSong = externalMatch.Song
|
||||
};
|
||||
|
||||
matchedTracks.Add(matched);
|
||||
|
||||
if (match.MatchType == "isrc") isrcMatches++;
|
||||
else if (match.MatchType == "fuzzy") fuzzyMatches++;
|
||||
|
||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match (score: {Score:F1}): {MatchedTitle}",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
||||
match.MatchType, match.Score, match.Song.Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
noMatch++;
|
||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
_logger.LogDebug(" #{Position} {Title} → no match", spotifyTrack.Position, spotifyTrack.Title);
|
||||
}
|
||||
|
||||
if (matched != null)
|
||||
{
|
||||
matchedTracks.Add(matched);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedTracks.Count > 0)
|
||||
{
|
||||
// Cache matched tracks with position data
|
||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
||||
// UPDATE STATS CACHE: Calculate and cache stats immediately after matching
|
||||
var statsLocalCount = localMatches.Count;
|
||||
var statsExternalCount = externalAssignments.Count;
|
||||
var statsMissingCount = spotifyTracks.Count - statsLocalCount - statsExternalCount;
|
||||
|
||||
var stats = new Dictionary<string, int>
|
||||
{
|
||||
["local"] = statsLocalCount,
|
||||
["external"] = statsExternalCount,
|
||||
["missing"] = statsMissingCount
|
||||
};
|
||||
|
||||
var statsCacheKey = $"spotify:playlist:stats:{playlistName}";
|
||||
await _cache.SetAsync(statsCacheKey, stats, TimeSpan.FromMinutes(30));
|
||||
|
||||
_logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing",
|
||||
playlistName, statsLocalCount, statsExternalCount, statsMissingCount);
|
||||
|
||||
// Calculate cache expiration: until next cron run (not just cache duration from settings)
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
|
||||
|
||||
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(playlist.SyncSchedule);
|
||||
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
|
||||
// Add 5 minutes buffer to ensure cache doesn't expire before next run
|
||||
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
|
||||
|
||||
_logger.LogInformation("Cache will persist until next cron run: {NextRun} UTC (in {Hours:F1} hours)",
|
||||
nextRun.Value, timeUntilNextRun.TotalHours);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not calculate next cron run for {Playlist}, using default cache duration", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache matched tracks with position data until next cron run
|
||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
|
||||
|
||||
// Save matched tracks to file for persistence across restarts
|
||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||
@@ -506,15 +779,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Also update legacy cache for backward compatibility
|
||||
var legacyKey = $"spotify:matched:{playlistName}";
|
||||
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
||||
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
|
||||
|
||||
_logger.LogInformation(
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - cache expires in {Hours:F1}h",
|
||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours);
|
||||
|
||||
// Pre-build playlist items cache for instant serving
|
||||
// This is what makes the UI show all matched tracks at once
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -526,9 +799,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Returns multiple candidate matches with scores for greedy assignment.
|
||||
/// FOLLOWS OPTIMAL ORDER:
|
||||
/// 1. Strip decorators (done in FuzzyMatcher)
|
||||
/// 2. Substring matching (done in FuzzyMatcher)
|
||||
/// 3. Levenshtein distance (done in FuzzyMatcher)
|
||||
/// This method just collects candidates; greedy assignment happens later.
|
||||
/// <summary>
|
||||
/// Attempts to match a track by title and artist using fuzzy matching.
|
||||
/// SEARCHES LOCAL FIRST, then external if no local match found.
|
||||
/// Returns multiple candidates for greedy assignment.
|
||||
/// </summary>
|
||||
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
||||
string title,
|
||||
@@ -538,21 +812,63 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
try
|
||||
{
|
||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||
|
||||
// STEP 1: Strip decorators FIRST (before searching)
|
||||
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||
var query = $"{titleStripped} {primaryArtist}";
|
||||
|
||||
var results = await metadataService.SearchSongsAsync(query, limit: 10);
|
||||
var allCandidates = new List<(Song Song, double Score)>();
|
||||
|
||||
if (results.Count == 0) return new List<(Song, double)>();
|
||||
// STEP 1: Search LOCAL Jellyfin library FIRST
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
if (proxyService != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Search Jellyfin for local tracks
|
||||
var searchParams = new Dictionary<string, string>
|
||||
{
|
||||
["searchTerm"] = query,
|
||||
["includeItemTypes"] = "Audio",
|
||||
["recursive"] = "true",
|
||||
["limit"] = "10"
|
||||
};
|
||||
|
||||
// STEP 2-3: Score all results (substring + Levenshtein already in CalculateSimilarityAggressive)
|
||||
var scoredResults = results
|
||||
var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
|
||||
|
||||
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
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)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
localResults.Add(new Song
|
||||
{
|
||||
Id = id,
|
||||
Title = songTitle,
|
||||
Artist = artist,
|
||||
IsLocal = true
|
||||
});
|
||||
}
|
||||
|
||||
if (localResults.Count > 0)
|
||||
{
|
||||
// Score local results
|
||||
var scoredLocal = localResults
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
// Use aggressive matching which follows optimal order internally
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
@@ -561,7 +877,6 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.Where(x =>
|
||||
@@ -572,7 +887,55 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
.Select(x => (x.Song, x.TotalScore))
|
||||
.ToList();
|
||||
|
||||
return scoredResults;
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -580,14 +943,26 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist)
|
||||
{
|
||||
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(spotifyTitle, jellyfinTitle);
|
||||
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyArtist, jellyfinArtist);
|
||||
return (titleScore * 0.7) + (artistScore * 0.3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to match a track by ISRC using provider search.
|
||||
/// 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)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Search by ISRC directly - most providers support this
|
||||
// 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
|
||||
var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1);
|
||||
if (results.Count > 0 && results[0].Isrc == isrc)
|
||||
{
|
||||
@@ -699,14 +1074,14 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var missingTracksKey = $"spotify:missing:{playlistName}";
|
||||
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
|
||||
var matchedTracksKey = $"spotify:matched:{playlistName}";
|
||||
|
||||
// Check if we already have matched tracks cached
|
||||
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
|
||||
if (existingMatched != null && existingMatched.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
||||
_logger.LogWarning("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
||||
playlistName, existingMatched.Count);
|
||||
return;
|
||||
}
|
||||
@@ -715,11 +1090,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No missing tracks found for {Playlist}, skipping matching", playlistName);
|
||||
_logger.LogWarning("No missing tracks found for {Playlist}, skipping matching", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {Count} tracks for {Playlist} (with rate limiting)",
|
||||
_logger.LogWarning("Matching {Count} tracks for {Playlist} (with rate limiting)",
|
||||
missingTracks.Count, playlistName);
|
||||
|
||||
var matchedSongs = new List<Song>();
|
||||
@@ -774,15 +1149,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
_logger.LogError(ex, "Failed to match track: {Title} - {Artist}",
|
||||
track.Title, track.PrimaryArtist);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedSongs.Count > 0)
|
||||
{
|
||||
// Cache matched tracks for 1 hour
|
||||
await _cache.SetAsync(matchedTracksKey, matchedSongs, TimeSpan.FromHours(1));
|
||||
// Cache matched tracks for configurable duration
|
||||
await _cache.SetAsync(matchedTracksKey, matchedSongs, CacheExtensions.SpotifyMatchedTracksTTL);
|
||||
_logger.LogInformation("✓ Cached {Matched}/{Total} matched tracks for {Playlist}",
|
||||
matchedSongs.Count, missingTracks.Count, playlistName);
|
||||
}
|
||||
@@ -792,72 +1167,26 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates artist match score ensuring ALL artists are present.
|
||||
/// Penalizes if artist counts don't match or if any artist is missing.
|
||||
/// </summary>
|
||||
private static double CalculateArtistMatchScore(List<string> spotifyArtists, string songMainArtist, List<string> songContributors)
|
||||
{
|
||||
if (spotifyArtists.Count == 0 || string.IsNullOrEmpty(songMainArtist))
|
||||
return 0;
|
||||
|
||||
// Build list of all song artists (main + contributors)
|
||||
var allSongArtists = new List<string> { songMainArtist };
|
||||
allSongArtists.AddRange(songContributors);
|
||||
|
||||
// If artist counts differ significantly, penalize
|
||||
var countDiff = Math.Abs(spotifyArtists.Count - allSongArtists.Count);
|
||||
if (countDiff > 1) // Allow 1 artist difference (sometimes features are listed differently)
|
||||
return 0;
|
||||
|
||||
// Check that each Spotify artist has a good match in song artists
|
||||
var spotifyScores = new List<double>();
|
||||
foreach (var spotifyArtist in spotifyArtists)
|
||||
{
|
||||
var bestMatch = allSongArtists.Max(songArtist =>
|
||||
FuzzyMatcher.CalculateSimilarity(spotifyArtist, songArtist));
|
||||
spotifyScores.Add(bestMatch);
|
||||
}
|
||||
|
||||
// Check that each song artist has a good match in Spotify artists
|
||||
var songScores = new List<double>();
|
||||
foreach (var songArtist in allSongArtists)
|
||||
{
|
||||
var bestMatch = spotifyArtists.Max(spotifyArtist =>
|
||||
FuzzyMatcher.CalculateSimilarity(songArtist, spotifyArtist));
|
||||
songScores.Add(bestMatch);
|
||||
}
|
||||
|
||||
// Average all scores - this ensures ALL artists must match well
|
||||
var allScores = spotifyScores.Concat(songScores);
|
||||
var avgScore = allScores.Average();
|
||||
|
||||
// Penalize if any individual artist match is poor (< 70)
|
||||
var minScore = allScores.Min();
|
||||
if (minScore < 70)
|
||||
avgScore *= 0.7; // 30% penalty for poor individual match
|
||||
|
||||
return avgScore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-builds the playlist items cache for instant serving.
|
||||
/// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order.
|
||||
/// PRIORITY: Local Jellyfin tracks FIRST, then external providers for unmatched tracks only.
|
||||
/// </summary>
|
||||
private async Task PreBuildPlaylistItemsCacheAsync(
|
||||
string playlistName,
|
||||
string? jellyfinPlaylistId,
|
||||
List<SpotifyPlaylistTrack> spotifyTracks,
|
||||
List<MatchedTrack> matchedTracks,
|
||||
List<MatchedTrack> externalMatchedTracks,
|
||||
TimeSpan cacheExpiration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
|
||||
_logger.LogDebug("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
|
||||
|
||||
if (string.IsNullOrEmpty(jellyfinPlaylistId))
|
||||
{
|
||||
_logger.LogWarning("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
|
||||
_logger.LogError("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -876,7 +1205,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var userId = jellyfinSettings.UserId;
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
|
||||
_logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -887,12 +1216,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
||||
}
|
||||
|
||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||
// Request all fields that clients typically need (not just MediaSources)
|
||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=Genres,DateCreated,MediaSources,ParentId,People,Tags,SortName,ProviderIds";
|
||||
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
||||
|
||||
if (statusCode != 200 || existingTracksResponse == null)
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
|
||||
_logger.LogError("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -923,8 +1253,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
// Build the final track list in correct Spotify order
|
||||
// PRIORITY: Local Jellyfin tracks FIRST, then external for unmatched only
|
||||
var finalItems = new List<Dictionary<string, object?>>();
|
||||
var usedJellyfinItems = new HashSet<string>();
|
||||
var matchedSpotifyIds = new HashSet<string>(); // Track which Spotify tracks got local matches
|
||||
var localUsedCount = 0;
|
||||
var externalUsedCount = 0;
|
||||
var manualExternalCount = 0;
|
||||
@@ -962,19 +1294,42 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||
if (itemDict != null)
|
||||
{
|
||||
// Add Spotify ID to ProviderIds so lyrics can work for local tracks too
|
||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
// Add Jellyfin ID to ProviderIds for easy identification
|
||||
if (itemDict.TryGetValue("Id", out var jellyfinIdObj) && jellyfinIdObj != null)
|
||||
{
|
||||
var jellyfinId = jellyfinIdObj.ToString();
|
||||
if (!string.IsNullOrEmpty(jellyfinId))
|
||||
{
|
||||
if (!itemDict.ContainsKey("ProviderIds"))
|
||||
{
|
||||
itemDict["ProviderIds"] = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
|
||||
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||
// Handle ProviderIds which might be a JsonElement or Dictionary
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (itemDict["ProviderIds"] is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||
_logger.LogDebug("Added Spotify ID {SpotifyId} to local track for lyrics support", spotifyTrack.SpotifyId);
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (itemDict["ProviderIds"] is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Convert JsonElement to Dictionary
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
// Replace the JsonElement with the Dictionary
|
||||
itemDict["ProviderIds"] = providerIds;
|
||||
}
|
||||
|
||||
if (providerIds != null && !providerIds.ContainsKey("Jellyfin"))
|
||||
{
|
||||
providerIds["Jellyfin"] = jellyfinId;
|
||||
_logger.LogDebug("Added Jellyfin ID {JellyfinId} to manual mapped local track {Title}",
|
||||
jellyfinId, spotifyTrack.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -983,6 +1338,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
usedJellyfinItems.Add(matchedKey);
|
||||
}
|
||||
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as locally matched
|
||||
localUsedCount++;
|
||||
}
|
||||
continue; // Skip to next track
|
||||
@@ -1031,7 +1387,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
|
||||
_logger.LogError("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
|
||||
provider, externalId);
|
||||
}
|
||||
}
|
||||
@@ -1058,15 +1414,6 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
};
|
||||
}
|
||||
|
||||
var matchedTrack = new MatchedTrack
|
||||
{
|
||||
Position = spotifyTrack.Position,
|
||||
SpotifyId = spotifyTrack.SpotifyId,
|
||||
MatchedSong = externalSong
|
||||
};
|
||||
|
||||
matchedTracks.Add(matchedTrack);
|
||||
|
||||
// Convert external song to Jellyfin item format and add to finalItems
|
||||
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
|
||||
|
||||
@@ -1088,6 +1435,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
finalItems.Add(externalItem);
|
||||
externalUsedCount++;
|
||||
manualExternalCount++;
|
||||
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as matched (external)
|
||||
|
||||
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
|
||||
spotifyTrack.Title, provider, externalId);
|
||||
@@ -1096,11 +1444,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
|
||||
_logger.LogError(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
|
||||
}
|
||||
}
|
||||
|
||||
// If no manual external mapping, try AGGRESSIVE fuzzy matching with local Jellyfin tracks
|
||||
// THIRD: Try AGGRESSIVE fuzzy matching with local Jellyfin tracks (PRIORITY!)
|
||||
double bestScore = 0;
|
||||
|
||||
foreach (var kvp in jellyfinItemsByName)
|
||||
@@ -1140,19 +1488,52 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||
if (itemDict != null)
|
||||
{
|
||||
// Add Spotify ID to ProviderIds so lyrics can work for fuzzy-matched local tracks too
|
||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
// Add Jellyfin ID to ProviderIds for easy identification
|
||||
if (itemDict.TryGetValue("Id", out var jellyfinIdObj) && jellyfinIdObj != null)
|
||||
{
|
||||
var jellyfinId = jellyfinIdObj.ToString();
|
||||
if (!string.IsNullOrEmpty(jellyfinId))
|
||||
{
|
||||
if (!itemDict.ContainsKey("ProviderIds"))
|
||||
{
|
||||
itemDict["ProviderIds"] = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
|
||||
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||
// Handle ProviderIds which might be a JsonElement or Dictionary
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (itemDict["ProviderIds"] is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (itemDict["ProviderIds"] is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// Convert JsonElement to Dictionary
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
// Replace the JsonElement with the Dictionary
|
||||
itemDict["ProviderIds"] = providerIds;
|
||||
}
|
||||
|
||||
if (providerIds != null)
|
||||
{
|
||||
if (!providerIds.ContainsKey("Jellyfin"))
|
||||
{
|
||||
providerIds["Jellyfin"] = jellyfinId;
|
||||
}
|
||||
|
||||
// Add Spotify ID for matching in track details endpoint
|
||||
if (!providerIds.ContainsKey("Spotify") && !string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
{
|
||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||
_logger.LogDebug("Added Spotify ID {SpotifyId} to fuzzy-matched local track for lyrics support", spotifyTrack.SpotifyId);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Fuzzy matched local track {Title} with Jellyfin ID {Id} (score: {Score:F1})",
|
||||
spotifyTrack.Title, jellyfinId, bestScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1161,19 +1542,20 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
usedJellyfinItems.Add(matchedKey);
|
||||
}
|
||||
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as locally matched
|
||||
localUsedCount++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No local match - try to find external track
|
||||
var matched = matchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||
// FOURTH: No local match - try to find external track (ONLY for unmatched tracks)
|
||||
var matched = externalMatchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||
if (matched != null && matched.MatchedSong != null)
|
||||
{
|
||||
// Convert external song to Jellyfin item format
|
||||
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
|
||||
|
||||
// Add Spotify ID to ProviderIds so lyrics can work
|
||||
// Add Spotify ID to ProviderIds for matching in track details endpoint
|
||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
{
|
||||
if (!externalItem.ContainsKey("ProviderIds"))
|
||||
@@ -1189,16 +1571,94 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
finalItems.Add(externalItem);
|
||||
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as matched (external)
|
||||
externalUsedCount++;
|
||||
|
||||
_logger.LogDebug("Using external match for {Title}: {Provider}",
|
||||
spotifyTrack.Title, matched.MatchedSong.ExternalProvider);
|
||||
}
|
||||
// else: Track remains unmatched (not added to finalItems)
|
||||
}
|
||||
}
|
||||
|
||||
if (finalItems.Count > 0)
|
||||
{
|
||||
// Save to Redis cache
|
||||
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
||||
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
||||
// Enrich external tracks with genres from MusicBrainz
|
||||
if (externalUsedCount > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var genreEnrichment = _serviceProvider.GetService<GenreEnrichmentService>();
|
||||
if (genreEnrichment != null)
|
||||
{
|
||||
_logger.LogDebug("🎨 Enriching {Count} external tracks with genres from MusicBrainz...", externalUsedCount);
|
||||
|
||||
// Extract external songs from externalMatchedTracks that were actually used
|
||||
var usedExternalSpotifyIds = finalItems
|
||||
.Where(item => item.TryGetValue("Id", out var idObj) &&
|
||||
idObj is string id && id.StartsWith("ext-"))
|
||||
.Select(item =>
|
||||
{
|
||||
// Try to get Spotify ID from ProviderIds
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj is Dictionary<string, string> providerIds)
|
||||
{
|
||||
providerIds.TryGetValue("Spotify", out var spotifyId);
|
||||
return spotifyId;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.ToHashSet();
|
||||
|
||||
var externalSongs = externalMatchedTracks
|
||||
.Where(t => t.MatchedSong != null &&
|
||||
!t.MatchedSong.IsLocal &&
|
||||
usedExternalSpotifyIds.Contains(t.SpotifyId))
|
||||
.Select(t => t.MatchedSong!)
|
||||
.ToList();
|
||||
|
||||
// Enrich genres in parallel
|
||||
await genreEnrichment.EnrichSongsGenresAsync(externalSongs);
|
||||
|
||||
// Update the genres in finalItems
|
||||
foreach (var item in finalItems)
|
||||
{
|
||||
if (item.TryGetValue("Id", out var idObj) && idObj is string id && id.StartsWith("ext-"))
|
||||
{
|
||||
// Find the corresponding song
|
||||
var song = externalSongs.FirstOrDefault(s => s.Id == id);
|
||||
if (song != null && !string.IsNullOrEmpty(song.Genre))
|
||||
{
|
||||
// Update Genres array
|
||||
item["Genres"] = new[] { song.Genre };
|
||||
|
||||
// Update GenreItems array
|
||||
item["GenreItems"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = song.Genre,
|
||||
["Id"] = $"genre-{song.Genre.ToLowerInvariant()}"
|
||||
}
|
||||
};
|
||||
|
||||
_logger.LogDebug("✓ Enriched {Title} with genre: {Genre}", song.Title, song.Genre);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Genre enrichment complete for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to enrich genres for {Playlist}, continuing without genres", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Save to Redis cache with same expiration as matched tracks (until next cron run)
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
|
||||
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
||||
|
||||
// Save to file cache for persistence
|
||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||
@@ -1209,9 +1669,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
manualMappingInfo = $" [Manual external: {manualExternalCount}]";
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
|
||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
|
||||
_logger.LogDebug("✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
|
||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1264,7 +1723,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
|
||||
_logger.LogInformation("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -91,21 +91,10 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var response = await _httpClient.GetAsync(baseUrl);
|
||||
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
|
||||
return response.IsSuccessStatusCode;
|
||||
});
|
||||
}
|
||||
|
||||
protected override string? ExtractExternalIdFromAlbumId(string albumId)
|
||||
{
|
||||
const string prefix = "ext-squidwtf-album-";
|
||||
if (albumId.StartsWith(prefix))
|
||||
{
|
||||
Console.WriteLine(albumId[prefix.Length..]);
|
||||
return albumId[prefix.Length..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -129,7 +118,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine("downloads", "cache")
|
||||
: Path.Combine("downloads", "permanent");
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId);
|
||||
|
||||
// Create directories if they don't exist
|
||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||
|
||||
@@ -56,6 +56,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||
|
||||
public SquidWTFMetadataService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
@@ -63,13 +64,15 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
IOptions<SquidWTFSettings> squidwtfSettings,
|
||||
ILogger<SquidWTFMetadataService> logger,
|
||||
RedisCacheService cache,
|
||||
List<string> apiUrls)
|
||||
List<string> apiUrls,
|
||||
GenreEnrichmentService? genreEnrichment = null)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||
_genreEnrichment = genreEnrichment;
|
||||
|
||||
// Set up default headers
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||
@@ -83,19 +86,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||
{
|
||||
// Race all endpoints for fastest search results
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
// Use round-robin to distribute load across endpoints (allows parallel processing of multiple tracks)
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Use 's' parameter for track search as per hifi-api spec
|
||||
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Check for error in response body
|
||||
var result = JsonDocument.Parse(json);
|
||||
@@ -116,7 +119,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
if (count >= limit) break;
|
||||
|
||||
var song = ParseTidalTrack(track);
|
||||
if (ShouldIncludeSong(song))
|
||||
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
|
||||
{
|
||||
songs.Add(song);
|
||||
}
|
||||
@@ -129,19 +132,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
||||
{
|
||||
// Race all endpoints for fastest search results
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
||||
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var albums = new List<Album>();
|
||||
@@ -166,14 +169,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
||||
{
|
||||
// Race all endpoints for fastest search results
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Per hifi-api spec: use 'a' parameter for artist search
|
||||
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||
_logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
@@ -181,7 +184,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var artists = new List<Artist>();
|
||||
@@ -237,7 +240,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse playlist, skipping");
|
||||
_logger.LogWarning(ex, "Failed to parse playlist, skipping");
|
||||
// Skip this playlist and continue with others
|
||||
}
|
||||
}
|
||||
@@ -286,6 +289,23 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
var song = ParseTidalTrackFull(track);
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
||||
// This avoids redundant conversions and ensures it's done in parallel with the download
|
||||
|
||||
@@ -328,7 +348,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
if (trackWrapper.TryGetProperty("item", out var track))
|
||||
{
|
||||
var song = ParseTidalTrack(track);
|
||||
if (ShouldIncludeSong(song))
|
||||
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
|
||||
{
|
||||
album.Songs.Add(song);
|
||||
}
|
||||
@@ -336,8 +356,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for 24 hours
|
||||
await _cache.SetAsync(cacheKey, album, TimeSpan.FromHours(24));
|
||||
// Cache for configurable duration
|
||||
await _cache.SetAsync(cacheKey, album, CacheExtensions.MetadataTTL);
|
||||
|
||||
return album;
|
||||
}, (Album?)null);
|
||||
@@ -347,14 +367,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
if (externalProvider != "squidwtf") return null;
|
||||
|
||||
_logger.LogInformation("GetArtistAsync called for SquidWTF artist {ExternalId}", externalId);
|
||||
_logger.LogDebug("GetArtistAsync called for SquidWTF artist {ExternalId}", externalId);
|
||||
|
||||
// Try cache first
|
||||
var cacheKey = $"squidwtf:artist:{externalId}";
|
||||
var cached = await _cache.GetAsync<Artist>(cacheKey);
|
||||
if (cached != null)
|
||||
{
|
||||
_logger.LogInformation("Returning cached artist {ArtistName}", cached.Name);
|
||||
_logger.LogDebug("Returning cached artist {ArtistName}", cached.Name);
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -362,12 +382,12 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
|
||||
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||
_logger.LogInformation("Fetching artist from {Url}", url);
|
||||
_logger.LogDebug("Fetching artist from {Url}", url);
|
||||
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("SquidWTF artist request failed with status {StatusCode}", response.StatusCode);
|
||||
_logger.LogError("SquidWTF artist request failed with status {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -388,7 +408,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
if (albumItems[0].TryGetProperty("artist", out var artistEl))
|
||||
{
|
||||
artistSource = artistEl;
|
||||
_logger.LogInformation("Found artist from albums, albumCount={AlbumCount}", albumCount);
|
||||
_logger.LogDebug("Found artist from albums, albumCount={AlbumCount}", albumCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,10 +443,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
using var doc = JsonDocument.Parse(normalizedArtist.ToJsonString());
|
||||
var artist = ParseTidalArtist(doc.RootElement);
|
||||
|
||||
_logger.LogInformation("Successfully parsed artist {ArtistName} with {AlbumCount} albums", artist.Name, albumCount);
|
||||
_logger.LogDebug("Successfully parsed artist {ArtistName} with {AlbumCount} albums", artist.Name, albumCount);
|
||||
|
||||
// Cache for 24 hours
|
||||
await _cache.SetAsync(cacheKey, artist, TimeSpan.FromHours(24));
|
||||
// Cache for configurable duration
|
||||
await _cache.SetAsync(cacheKey, artist, CacheExtensions.MetadataTTL);
|
||||
|
||||
return artist;
|
||||
}, (Artist?)null);
|
||||
@@ -438,16 +458,16 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
|
||||
_logger.LogDebug("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
|
||||
|
||||
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
|
||||
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||
_logger.LogInformation("Fetching artist albums from URL: {Url}", url);
|
||||
_logger.LogDebug("Fetching artist albums from URL: {Url}", url);
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("SquidWTF artist albums request failed with status {StatusCode}", response.StatusCode);
|
||||
_logger.LogError("SquidWTF artist albums request failed with status {StatusCode}", response.StatusCode);
|
||||
return new List<Album>();
|
||||
}
|
||||
|
||||
@@ -468,7 +488,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
parsedAlbum.Title, parsedAlbum.Artist, parsedAlbum.ArtistId);
|
||||
albums.Add(parsedAlbum);
|
||||
}
|
||||
_logger.LogInformation("Found {AlbumCount} albums for artist {ExternalId}", albums.Count, externalId);
|
||||
_logger.LogDebug("Found {AlbumCount} albums for artist {ExternalId}", albums.Count, externalId);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -554,7 +574,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
// Override album name to be the playlist name
|
||||
song.Album = playlistName;
|
||||
|
||||
if (ShouldIncludeSong(song))
|
||||
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
|
||||
{
|
||||
songs.Add(song);
|
||||
}
|
||||
@@ -595,6 +615,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
|
||||
var allArtists = new List<string>();
|
||||
var allArtistIds = new List<string>();
|
||||
string artistName = "";
|
||||
string? artistId = null;
|
||||
|
||||
@@ -604,9 +625,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
foreach (var artistEl in artists.EnumerateArray())
|
||||
{
|
||||
var name = artistEl.GetProperty("name").GetString();
|
||||
var id = artistEl.GetProperty("id").GetInt64();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
allArtists.Add(name);
|
||||
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,7 +637,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
if (allArtists.Count > 0)
|
||||
{
|
||||
artistName = allArtists[0];
|
||||
artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}";
|
||||
artistId = allArtistIds[0];
|
||||
}
|
||||
}
|
||||
// Fallback to singular "artist" field
|
||||
@@ -623,6 +646,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
||||
allArtists.Add(artistName);
|
||||
allArtistIds.Add(artistId);
|
||||
}
|
||||
|
||||
// Get album info
|
||||
@@ -649,6 +673,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
Artist = artistName,
|
||||
ArtistId = artistId,
|
||||
Artists = allArtists,
|
||||
ArtistIds = allArtistIds,
|
||||
Album = albumTitle,
|
||||
AlbumId = albumId,
|
||||
Duration = track.TryGetProperty("duration", out var duration)
|
||||
@@ -711,6 +736,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
// Get all artists - prefer "artists" array for collaborations
|
||||
var allArtists = new List<string>();
|
||||
var allArtistIds = new List<string>();
|
||||
string artistName = "";
|
||||
long artistIdNum = 0;
|
||||
|
||||
@@ -719,9 +745,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
foreach (var artistEl in artists.EnumerateArray())
|
||||
{
|
||||
var name = artistEl.GetProperty("name").GetString();
|
||||
var id = artistEl.GetProperty("id").GetInt64();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
allArtists.Add(name);
|
||||
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,6 +764,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||
artistIdNum = artist.GetProperty("id").GetInt64();
|
||||
allArtists.Add(artistName);
|
||||
allArtistIds.Add($"ext-squidwtf-artist-{artistIdNum}");
|
||||
}
|
||||
|
||||
// Album artist - same as main artist for Tidal tracks
|
||||
@@ -771,6 +800,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
Artist = artistName,
|
||||
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
|
||||
Artists = allArtists,
|
||||
ArtistIds = allArtistIds,
|
||||
Album = albumTitle,
|
||||
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
|
||||
AlbumArtist = albumArtist,
|
||||
|
||||
@@ -73,7 +73,11 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(endpoint, ct);
|
||||
// 5 second timeout per ping - mark slow endpoints as failed
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
|
||||
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -187,7 +187,7 @@ public class PlaylistSyncService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to download track '{Artist} - {Title}'", track.Artist, track.Title);
|
||||
_logger.LogError(ex, "Failed to download track '{Artist} - {Title}'", track.Artist, track.Title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ public class PlaylistSyncService
|
||||
}
|
||||
|
||||
await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString());
|
||||
_logger.LogInformation("Created M3U playlist: {Path}", playlistPath);
|
||||
_logger.LogDebug("Created M3U playlist: {Path}", playlistPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -259,7 +259,7 @@ public class PlaylistSyncService
|
||||
// Skip real-time updates during full playlist download (M3U will be created once at the end)
|
||||
if (isFullPlaylistDownload)
|
||||
{
|
||||
_logger.LogDebug("Skipping M3U update for track {TrackId} (full playlist download in progress)", track.Id);
|
||||
_logger.LogWarning("Skipping M3U update for track {TrackId} (full playlist download in progress)", track.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ public class PlaylistSyncService
|
||||
|
||||
// Write the M3U file (overwrites existing)
|
||||
await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString());
|
||||
_logger.LogInformation("Updated M3U playlist '{PlaylistName}' with {Count} tracks (in correct order)",
|
||||
_logger.LogDebug("Updated M3U playlist '{PlaylistName}' with {Count} tracks (in correct order)",
|
||||
playlist.Name, addedCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -382,7 +382,7 @@ public class PlaylistSyncService
|
||||
|
||||
if (expiredKeys.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Cleaned up {Count} expired playlist cache entries", expiredKeys.Count);
|
||||
_logger.LogWarning("Cleaned up {Count} expired playlist cache entries", expiredKeys.Count);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -392,7 +392,7 @@ public class PlaylistSyncService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error during playlist cache cleanup");
|
||||
_logger.LogError(ex, "Error during playlist cache cleanup");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ public class SubsonicModelMapper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error parsing Subsonic search response");
|
||||
_logger.LogError(ex, "Error parsing Subsonic search response");
|
||||
}
|
||||
|
||||
return (songs, albums, artists);
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>allstarr</RootNamespace>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<Version>1.0.3</Version>
|
||||
<AssemblyVersion>1.0.3.0</AssemblyVersion>
|
||||
<FileVersion>1.0.3.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Cronos" Version="0.11.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||
|
||||
@@ -32,8 +32,7 @@
|
||||
"EnableExternalPlaylists": true
|
||||
},
|
||||
"Library": {
|
||||
"DownloadPath": "./downloads",
|
||||
"KeptPath": "/app/kept"
|
||||
"DownloadPath": "./downloads"
|
||||
},
|
||||
"Qobuz": {
|
||||
"UserAuthToken": "your-qobuz-token",
|
||||
@@ -52,6 +51,17 @@
|
||||
"Enabled": true,
|
||||
"ConnectionString": "localhost:6379"
|
||||
},
|
||||
"Cache": {
|
||||
"SearchResultsMinutes": 120,
|
||||
"PlaylistImagesHours": 168,
|
||||
"SpotifyPlaylistItemsHours": 168,
|
||||
"SpotifyMatchedTracksDays": 30,
|
||||
"LyricsDays": 14,
|
||||
"GenreDays": 30,
|
||||
"MetadataDays": 7,
|
||||
"OdesliLookupDays": 60,
|
||||
"ProxyImagesDays": 14
|
||||
},
|
||||
"SpotifyImport": {
|
||||
"Enabled": false,
|
||||
"SyncStartHour": 16,
|
||||
@@ -62,8 +72,6 @@
|
||||
},
|
||||
"SpotifyApi": {
|
||||
"Enabled": false,
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"SessionCookie": "",
|
||||
"CacheDurationMinutes": 60,
|
||||
"RateLimitDelayMs": 100,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// ============================================================================
|
||||
// DEPRECATED: This file has been replaced by main.js
|
||||
// ============================================================================
|
||||
// All functionality has been moved to modular ES6 files:
|
||||
// - main.js (entry point with all initialization and window functions)
|
||||
// - utils.js (utility functions like showToast, escapeHtml, formatCookieAge)
|
||||
// - api.js (all API calls)
|
||||
// - ui.js (all UI update functions)
|
||||
// - modals.js (modal management)
|
||||
// - helpers.js (helper functions for mapping, searching, etc.)
|
||||
// ============================================================================
|
||||
// This file is kept for backwards compatibility only.
|
||||
// All code has been successfully migrated to the modular structure.
|
||||
// ============================================================================
|
||||
|
||||
console.warn('⚠️ app.js is deprecated. All functionality is now in main.js and other modules.');
|
||||
+342
-2398
@@ -1,530 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Allstarr Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--text-primary: #f0f6fc;
|
||||
--text-secondary: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--accent-hover: #79c0ff;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--error: #f85149;
|
||||
--border: #30363d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h1 .version {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.success { background: rgba(63, 185, 80, 0.2); color: var(--success); }
|
||||
.status-badge.warning { background: rgba(210, 153, 34, 0.2); color: var(--warning); }
|
||||
.status-badge.error { background: rgba(248, 81, 73, 0.2); color: var(--error); }
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card h2 .actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value.success { color: var(--success); }
|
||||
.stat-value.warning { color: var(--warning); }
|
||||
.stat-value.error { color: var(--error); }
|
||||
|
||||
button {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: rgba(248, 81, 73, 0.3);
|
||||
}
|
||||
|
||||
.playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.playlist-table th,
|
||||
.playlist-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.playlist-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.playlist-table tr:hover td {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.playlist-table .track-count {
|
||||
font-family: monospace;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.playlist-table .cache-age {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
input, select {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 120px auto;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.config-section h3 {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.config-item .label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.config-item .value {
|
||||
font-family: monospace;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.success { border-color: var(--success); }
|
||||
.toast.error { border-color: var(--error); }
|
||||
.toast.warning { border-color: var(--warning); }
|
||||
.toast.info { border-color: var(--accent); }
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.restart-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
z-index: 9999;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.restart-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.restart-overlay .spinner-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.restart-overlay h2 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.restart-overlay p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.restart-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--warning);
|
||||
color: var(--bg-primary);
|
||||
padding: 12px 20px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
z-index: 9998;
|
||||
display: none;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.restart-banner.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.restart-banner button {
|
||||
margin-left: 16px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.restart-banner button:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 75%;
|
||||
width: 75%;
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-content .form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-content .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-content .form-group input,
|
||||
.modal-content .form-group select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tracks-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.track-item {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.track-position {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.track-info h4 {
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.track-info .artists {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.track-meta {
|
||||
text-align: right;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Restart Required Banner -->
|
||||
<div class="restart-banner" id="restart-banner">
|
||||
⚠️ Configuration changed. Restart required to apply changes.
|
||||
<button onclick="restartContainer()">Restart Now</button>
|
||||
<button onclick="dismissRestartBanner()" style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
||||
<button onclick="dismissRestartBanner()"
|
||||
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>
|
||||
Allstarr <span class="version" id="version">v1.0.0</span>
|
||||
Allstarr <span class="version" id="version">v1.0.3</span>
|
||||
</h1>
|
||||
<div id="status-indicator">
|
||||
<span class="status-badge" id="spotify-status">
|
||||
@@ -537,7 +33,7 @@
|
||||
<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">Active Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
@@ -594,6 +90,7 @@
|
||||
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
|
||||
<button onclick="clearCache()">Clear Cache</button>
|
||||
<button onclick="openAddPlaylist()">Add Playlist</button>
|
||||
<button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -608,14 +105,18 @@
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
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 reliable.
|
||||
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
|
||||
reliable.
|
||||
</p>
|
||||
|
||||
<div 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>
|
||||
<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);">
|
||||
<label
|
||||
style="display: block; margin-bottom: 4px; color: var(--text-secondary); font-size: 0.85rem;">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>
|
||||
</select>
|
||||
</div>
|
||||
@@ -646,27 +147,37 @@
|
||||
<!-- Active Playlists Tab -->
|
||||
<div class="tab-content" id="tab-playlists">
|
||||
<!-- Warning Banner (hidden by default) -->
|
||||
<div id="matching-warning-banner" style="display:none;background:#f59e0b;color:#000;padding:16px;border-radius:8px;margin-bottom:16px;font-weight:600;text-align:center;box-shadow:0 4px 6px rgba(0,0,0,0.1);">
|
||||
⚠️ TRACK MATCHING IN PROGRESS - Please wait for matching to complete before making changes to playlists or mappings!
|
||||
<div id="matching-warning-banner"
|
||||
style="display:none;background:#f59e0b;color:#000;padding:16px;border-radius:8px;margin-bottom:16px;font-weight:600;text-align:center;box-shadow:0 4px 6px rgba(0,0,0,0.1);">
|
||||
⚠️ TRACK MATCHING IN PROGRESS - Please wait for matching to complete before making changes to playlists
|
||||
or mappings!
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>
|
||||
Active Spotify Playlists
|
||||
Injected Spotify Playlists
|
||||
<div class="actions">
|
||||
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
|
||||
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
|
||||
<button onclick="refreshAndMatchAll()" title="Clear caches, fetch fresh data from Spotify, and match all tracks. This is a full rebuild and may take several minutes." style="background:var(--accent);border-color:var(--accent);">Refresh & Match All</button>
|
||||
<button onclick="matchAllPlaylists()"
|
||||
title="Re-match tracks when local library changed (uses cached Spotify data)">Re-match All
|
||||
Local</button>
|
||||
<button onclick="refreshPlaylists()"
|
||||
title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh
|
||||
All</button>
|
||||
<button onclick="refreshAndMatchAll()"
|
||||
title="Rebuild all playlists when Spotify playlists changed (fetches fresh data and re-matches)"
|
||||
style="background:var(--accent);border-color:var(--accent);">Rebuild All Remote</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
These are the Spotify playlists currently being monitored and filled with tracks from your music service.
|
||||
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music
|
||||
service.
|
||||
</p>
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Spotify ID</th>
|
||||
<th>Sync Schedule</th>
|
||||
<th>Tracks</th>
|
||||
<th>Completion</th>
|
||||
<th>Cache Age</th>
|
||||
@@ -675,7 +186,7 @@
|
||||
</thead>
|
||||
<tbody id="playlist-table-body">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">
|
||||
<td colspan="7" class="loading">
|
||||
<span class="spinner"></span> Loading playlists...
|
||||
</td>
|
||||
</tr>
|
||||
@@ -692,16 +203,19 @@
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||
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"
|
||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px;" 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);" id="mappings-external">0</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--success);"
|
||||
id="mappings-external">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="playlist-table">
|
||||
@@ -734,12 +248,15 @@
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
Tracks that couldn't be matched locally or externally. Map them manually to add them to your playlists.
|
||||
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"
|
||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Missing:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);" id="missing-total">0</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);"
|
||||
id="missing-total">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="playlist-table">
|
||||
@@ -773,14 +290,17 @@
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
Downloaded files stored permanently. Download or delete individual tracks.
|
||||
</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"
|
||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Files:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-count">0</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--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 B</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0
|
||||
B</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="playlist-table">
|
||||
@@ -806,18 +326,83 @@
|
||||
|
||||
<!-- Configuration Tab -->
|
||||
<div class="tab-content" id="tab-config">
|
||||
<div class="card">
|
||||
<h2>Core Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Backend Type <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-backend-type">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('BACKEND_TYPE', 'Backend Type', 'select', 'Choose your media server backend', ['Jellyfin', 'Subsonic'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Music Service <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-music-service">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('MUSIC_SERVICE', 'Music Service', 'select', 'Choose your music download provider', ['SquidWTF', 'Deezer', 'Qobuz'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Storage Mode</span>
|
||||
<span class="value" id="config-storage-mode">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('STORAGE_MODE', 'Storage Mode', 'select', 'Permanent keeps files forever, Cache auto-deletes after duration', ['Permanent', 'Cache'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item" id="cache-duration-row" style="display: none;">
|
||||
<span class="label">Cache Duration (hours)</span>
|
||||
<span class="value" id="config-cache-duration-hours">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('CACHE_DURATION_HOURS', 'Cache Duration (hours)', 'number', 'How long to keep cached files before deletion')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Download Mode</span>
|
||||
<span class="value" id="config-download-mode">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('DOWNLOAD_MODE', 'Download Mode', 'select', 'Download individual tracks or full albums', ['Track', 'Album'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Explicit Filter</span>
|
||||
<span class="value" id="config-explicit-filter">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('EXPLICIT_FILTER', 'Explicit Filter', 'select', 'Filter explicit content', ['All', 'Explicit', 'Clean'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Enable External Playlists</span>
|
||||
<span class="value" id="config-enable-external-playlists">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('ENABLE_EXTERNAL_PLAYLISTS', 'Enable External Playlists', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Playlists Directory</span>
|
||||
<span class="value" id="config-playlists-directory">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('PLAYLISTS_DIRECTORY', 'Playlists Directory', 'text', 'Directory path for external playlists')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Redis Enabled</span>
|
||||
<span class="value" id="config-redis-enabled">-</span>
|
||||
<button onclick="openEditSetting('REDIS_ENABLED', 'Redis Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Spotify API Settings</h2>
|
||||
<div
|
||||
style="background: rgba(248, 81, 73, 0.15); border: 1px solid var(--error); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
|
||||
⚠️ For active playlists and link functionality to work, sp_dc session cookie must be set!
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">API Enabled</span>
|
||||
<span class="value" id="config-spotify-enabled">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Session Cookie (sp_dc)</span>
|
||||
<span class="label">Session Cookie (sp_dc) <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-spotify-cookie">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
|
||||
</div>
|
||||
<div class="config-item" style="grid-template-columns: 200px 1fr;">
|
||||
<span class="label">Cookie Age</span>
|
||||
@@ -826,12 +411,14 @@
|
||||
<div class="config-item">
|
||||
<span class="label">Cache Duration</span>
|
||||
<span class="value" id="config-cache-duration">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_API_CACHE_DURATION_MINUTES', 'Cache Duration (minutes)', 'number')">Edit</button>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_API_CACHE_DURATION_MINUTES', 'Cache Duration (minutes)', 'number')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">ISRC Matching</span>
|
||||
<span class="value" id="config-isrc-matching">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_API_PREFER_ISRC_MATCHING', 'Prefer ISRC Matching', 'toggle')">Edit</button>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_API_PREFER_ISRC_MATCHING', 'Prefer ISRC Matching', 'toggle')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -842,12 +429,14 @@
|
||||
<div class="config-item">
|
||||
<span class="label">ARL Token</span>
|
||||
<span class="value" id="config-deezer-arl">-</span>
|
||||
<button onclick="openEditSetting('DEEZER_ARL', 'Deezer ARL Token', 'password', 'Get from browser cookies while logged into Deezer')">Update</button>
|
||||
<button
|
||||
onclick="openEditSetting('DEEZER_ARL', 'Deezer ARL Token', 'password', 'Get from browser cookies while logged into Deezer')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Quality</span>
|
||||
<span class="value" id="config-deezer-quality">-</span>
|
||||
<button onclick="openEditSetting('DEEZER_QUALITY', 'Deezer Quality', 'select', '', ['FLAC', 'MP3_320', 'MP3_128'])">Edit</button>
|
||||
<button
|
||||
onclick="openEditSetting('DEEZER_QUALITY', 'Deezer Quality', 'select', '', ['FLAC', 'MP3_320', 'MP3_128'])">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -858,7 +447,8 @@
|
||||
<div class="config-item">
|
||||
<span class="label">Quality</span>
|
||||
<span class="value" id="config-squid-quality">-</span>
|
||||
<button onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', '', ['LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
|
||||
<button
|
||||
onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', 'HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest)\\nLOSSLESS: 16-bit/44.1kHz FLAC (default)\\nHIGH: 320kbps AAC\\nLOW: 96kbps AAC', ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -869,17 +459,20 @@
|
||||
<div class="config-item">
|
||||
<span class="label">Enabled</span>
|
||||
<span class="value" id="config-musicbrainz-enabled">-</span>
|
||||
<button onclick="openEditSetting('MUSICBRAINZ_ENABLED', 'MusicBrainz Enabled', 'select', '', ['true', 'false'])">Edit</button>
|
||||
<button
|
||||
onclick="openEditSetting('MUSICBRAINZ_ENABLED', 'MusicBrainz Enabled', 'select', '', ['true', 'false'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Username</span>
|
||||
<span class="value" id="config-musicbrainz-username">-</span>
|
||||
<button onclick="openEditSetting('MUSICBRAINZ_USERNAME', 'MusicBrainz Username', 'text', 'Your MusicBrainz username')">Update</button>
|
||||
<button
|
||||
onclick="openEditSetting('MUSICBRAINZ_USERNAME', 'MusicBrainz Username', 'text', 'Your MusicBrainz username')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Password</span>
|
||||
<span class="value" id="config-musicbrainz-password">-</span>
|
||||
<button onclick="openEditSetting('MUSICBRAINZ_PASSWORD', 'MusicBrainz Password', 'password', 'Your MusicBrainz password')">Update</button>
|
||||
<button
|
||||
onclick="openEditSetting('MUSICBRAINZ_PASSWORD', 'MusicBrainz Password', 'password', 'Your MusicBrainz password')">Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -890,12 +483,14 @@
|
||||
<div class="config-item">
|
||||
<span class="label">User Auth Token</span>
|
||||
<span class="value" id="config-qobuz-token">-</span>
|
||||
<button onclick="openEditSetting('QOBUZ_USER_AUTH_TOKEN', 'Qobuz User Auth Token', 'password', 'Get from browser while logged into Qobuz')">Update</button>
|
||||
<button
|
||||
onclick="openEditSetting('QOBUZ_USER_AUTH_TOKEN', 'Qobuz User Auth Token', 'password', 'Get from browser while logged into Qobuz')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Quality</span>
|
||||
<span class="value" id="config-qobuz-quality">-</span>
|
||||
<button onclick="openEditSetting('QOBUZ_QUALITY', 'Qobuz Quality', 'select', '', ['FLAC_24_192', 'FLAC_24_96', 'FLAC_16_44', 'MP3_320'])">Edit</button>
|
||||
<button
|
||||
onclick="openEditSetting('QOBUZ_QUALITY', 'Qobuz Quality', 'select', '', ['FLAC_24_192', 'FLAC_24_96', 'FLAC_16_44', 'MP3_320'])">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -904,24 +499,27 @@
|
||||
<h2>Jellyfin Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">URL</span>
|
||||
<span class="label">URL <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-jellyfin-url">-</span>
|
||||
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">API Key</span>
|
||||
<span class="label">API Key <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-jellyfin-api-key">-</span>
|
||||
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
|
||||
<button
|
||||
onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">User ID</span>
|
||||
<span class="label">User ID <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-jellyfin-user-id">-</span>
|
||||
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
|
||||
<button
|
||||
onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Library ID</span>
|
||||
<span class="value" id="config-jellyfin-library-id">-</span>
|
||||
<button onclick="openEditSetting('JELLYFIN_LIBRARY_ID', 'Jellyfin Library ID', 'text')">Edit</button>
|
||||
<button
|
||||
onclick="openEditSetting('JELLYFIN_LIBRARY_ID', 'Jellyfin Library ID', 'text')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -932,7 +530,8 @@
|
||||
<div class="config-item">
|
||||
<span class="label">Download Path (Cache)</span>
|
||||
<span class="value" id="config-download-path">-</span>
|
||||
<button onclick="openEditSetting('LIBRARY_DOWNLOAD_PATH', 'Download Path', 'text')">Edit</button>
|
||||
<button
|
||||
onclick="openEditSetting('LIBRARY_DOWNLOAD_PATH', 'Download Path', 'text')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Kept Path (Favorited)</span>
|
||||
@@ -943,17 +542,83 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sync Schedule</h2>
|
||||
<h2>Spotify Import Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Sync Start Time</span>
|
||||
<span class="value" id="config-sync-time">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_START_HOUR', 'Sync Start Hour (0-23)', 'number')">Edit</button>
|
||||
<span class="label">Spotify Import Enabled</span>
|
||||
<span class="value" id="config-spotify-import-enabled">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_IMPORT_ENABLED', 'Spotify Import Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Sync Window</span>
|
||||
<span class="value" id="config-sync-window">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_WINDOW_HOURS', 'Sync Window (hours)', 'number')">Edit</button>
|
||||
<span class="label">Matching Interval (hours)</span>
|
||||
<span class="value" id="config-matching-interval">-</span>
|
||||
<button
|
||||
onclick="openEditSetting('SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS', 'Matching Interval (hours)', 'number', 'How often to check for playlist updates')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Cache Settings</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Configure how long different types of data are cached. Longer durations reduce API calls but may
|
||||
show stale data.
|
||||
</p>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Search Results (minutes)</span>
|
||||
<span class="value" id="config-cache-search">-</span>
|
||||
<button
|
||||
onclick="openEditCacheSetting('SearchResultsMinutes', 'Search Results Cache (minutes)', 'How long to cache search results')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Playlist Images (hours)</span>
|
||||
<span class="value" id="config-cache-playlist-images">-</span>
|
||||
<button
|
||||
onclick="openEditCacheSetting('PlaylistImagesHours', 'Playlist Images Cache (hours)', 'How long to cache playlist cover images')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Spotify Playlist Items (hours)</span>
|
||||
<span class="value" id="config-cache-spotify-items">-</span>
|
||||
<button
|
||||
onclick="openEditCacheSetting('SpotifyPlaylistItemsHours', 'Spotify Playlist Items Cache (hours)', 'How long to cache Spotify playlist data')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Spotify Matched Tracks (days)</span>
|
||||
<span class="value" id="config-cache-matched-tracks">-</span>
|
||||
<button
|
||||
onclick="openEditCacheSetting('SpotifyMatchedTracksDays', 'Matched Tracks Cache (days)', 'How long to cache Spotify ID to track mappings')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Lyrics (days)</span>
|
||||
<span class="value" id="config-cache-lyrics">-</span>
|
||||
<button
|
||||
onclick="openEditCacheSetting('LyricsDays', 'Lyrics Cache (days)', 'How long to cache fetched lyrics')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Genre Data (days)</span>
|
||||
<span class="value" id="config-cache-genres">-</span>
|
||||
<button
|
||||
onclick="openEditCacheSetting('GenreDays', 'Genre Cache (days)', 'How long to cache genre information')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">External Metadata (days)</span>
|
||||
<span class="value" id="config-cache-metadata">-</span>
|
||||
<button
|
||||
onclick="openEditCacheSetting('MetadataDays', 'Metadata Cache (days)', 'How long to cache SquidWTF/Deezer/Qobuz metadata')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Odesli Lookups (days)</span>
|
||||
<span class="value" id="config-cache-odesli">-</span>
|
||||
<button
|
||||
onclick="openEditCacheSetting('OdesliLookupDays', 'Odesli Lookup Cache (days)', 'How long to cache Odesli URL conversions')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Proxy Images (days)</span>
|
||||
<span class="value" id="config-cache-proxy-images">-</span>
|
||||
<button
|
||||
onclick="openEditCacheSetting('ProxyImagesDays', 'Proxy Images Cache (days)', 'How long to cache proxied Jellyfin images')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -966,7 +631,8 @@
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button onclick="exportEnv()">📥 Export .env</button>
|
||||
<button onclick="document.getElementById('import-env-input').click()">📤 Import .env</button>
|
||||
<input type="file" id="import-env-input" accept=".env" style="display:none" onchange="importEnv(event)">
|
||||
<input type="file" id="import-env-input" accept=".env" style="display:none"
|
||||
onchange="importEnv(event)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -993,27 +659,38 @@
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Track which Jellyfin API endpoints are being called most frequently. Useful for debugging and understanding client behavior.
|
||||
Track which Jellyfin API endpoints are being called most frequently. Useful for debugging and
|
||||
understanding client behavior.
|
||||
</p>
|
||||
|
||||
<div id="endpoints-summary" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 20px;">
|
||||
<div id="endpoints-summary"
|
||||
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 20px;">
|
||||
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Total Requests</div>
|
||||
<div style="font-size: 1.8rem; font-weight: 600; color: var(--accent);" id="endpoints-total-requests">0</div>
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Total
|
||||
Requests</div>
|
||||
<div style="font-size: 1.8rem; font-weight: 600; color: var(--accent);"
|
||||
id="endpoints-total-requests">0</div>
|
||||
</div>
|
||||
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Unique Endpoints</div>
|
||||
<div style="font-size: 1.8rem; font-weight: 600; color: var(--success);" id="endpoints-unique-count">0</div>
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Unique
|
||||
Endpoints</div>
|
||||
<div style="font-size: 1.8rem; font-weight: 600; color: var(--success);"
|
||||
id="endpoints-unique-count">0</div>
|
||||
</div>
|
||||
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Most Called</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: var(--text-primary); word-break: break-all;" id="endpoints-most-called">-</div>
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Most Called
|
||||
</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: var(--text-primary); word-break: break-all;"
|
||||
id="endpoints-most-called">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.9rem;">Show Top</label>
|
||||
<select id="endpoints-top-select" onchange="fetchEndpointUsage()" style="padding: 8px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
||||
<label
|
||||
style="display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.9rem;">Show
|
||||
Top</label>
|
||||
<select id="endpoints-top-select" onchange="fetchEndpointUsage()"
|
||||
style="padding: 8px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
||||
<option value="25">Top 25</option>
|
||||
<option value="50" selected>Top 50</option>
|
||||
<option value="100">Top 100</option>
|
||||
@@ -1045,8 +722,10 @@
|
||||
<div class="card">
|
||||
<h2>About Endpoint Tracking</h2>
|
||||
<p style="color: var(--text-secondary); line-height: 1.6;">
|
||||
Allstarr logs every Jellyfin API endpoint call to help you understand how clients interact with your server.
|
||||
This data is stored in <code style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">/app/cache/endpoint-usage/endpoints.csv</code>
|
||||
Allstarr logs every Jellyfin API endpoint call to help you understand how clients interact with your
|
||||
server.
|
||||
This data is stored in <code
|
||||
style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">/app/cache/endpoint-usage/endpoints.csv</code>
|
||||
and persists across restarts.
|
||||
<br><br>
|
||||
<strong>Common Endpoints:</strong>
|
||||
@@ -1119,7 +798,8 @@
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<h3>Map Track to External Provider</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the
|
||||
Jellyfin mapping modal instead.
|
||||
</p>
|
||||
|
||||
<!-- Track Info -->
|
||||
@@ -1143,7 +823,8 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>External Provider ID</label>
|
||||
<input type="text" id="map-external-id" placeholder="Enter the provider-specific track ID..." oninput="validateExternalMapping()">
|
||||
<input type="text" id="map-external-id" placeholder="Enter the provider-specific track ID..."
|
||||
oninput="validateExternalMapping()">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
For SquidWTF: Use the track ID from the search results or URL<br>
|
||||
For Deezer: Use the track ID from Deezer URLs<br>
|
||||
@@ -1161,25 +842,102 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Local Jellyfin Track Mapping Modal -->
|
||||
<div class="modal" id="local-map-modal">
|
||||
<div class="modal-content" style="max-width: 700px;">
|
||||
<h3>Map Track to Local Jellyfin Track</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Search your Jellyfin library and select a local track to map to this Spotify track.
|
||||
</p>
|
||||
|
||||
<!-- Track Info -->
|
||||
<div class="form-group">
|
||||
<label>Spotify Track (Position <span id="local-map-position"></span>)</label>
|
||||
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||
<strong id="local-map-spotify-title"></strong><br>
|
||||
<span style="color: var(--text-secondary);" id="local-map-spotify-artist"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Section -->
|
||||
<div class="form-group">
|
||||
<label>Search Jellyfin Library</label>
|
||||
<input type="text" id="local-map-search" placeholder="Search for track name or artist...">
|
||||
<button onclick="searchJellyfinTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div id="local-map-results" style="max-height: 300px; overflow-y: auto; margin-top: 16px;"></div>
|
||||
|
||||
<input type="hidden" id="local-map-playlist-name">
|
||||
<input type="hidden" id="local-map-spotify-id">
|
||||
<input type="hidden" id="local-map-jellyfin-id">
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('local-map-modal')">Cancel</button>
|
||||
<button class="primary" onclick="saveLocalMapping()" id="local-map-save-btn" disabled>Save
|
||||
Mapping</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link Playlist Modal -->
|
||||
<div class="modal" id="link-playlist-modal">
|
||||
<div class="modal-content">
|
||||
<h3>Link to Spotify Playlist</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Enter the Spotify playlist ID or URL. Allstarr will automatically download missing tracks from your configured music service.
|
||||
Select a playlist from your Spotify library or enter a playlist ID/URL manually. Allstarr will
|
||||
automatically download missing tracks from your configured music service.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label>Jellyfin Playlist</label>
|
||||
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
|
||||
<input type="hidden" id="link-jellyfin-id">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Spotify Playlist ID or URL</label>
|
||||
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
|
||||
|
||||
<!-- Toggle between select and manual input -->
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<button type="button" id="select-mode-btn" class="primary" onclick="switchLinkMode('select')"
|
||||
style="flex: 1;">Select from My Playlists</button>
|
||||
<button type="button" id="manual-mode-btn" onclick="switchLinkMode('manual')" style="flex: 1;">Enter
|
||||
Manually</button>
|
||||
</div>
|
||||
|
||||
<!-- Select from user playlists -->
|
||||
<div class="form-group" id="link-select-group">
|
||||
<label>Your Spotify Playlists</label>
|
||||
<select id="link-spotify-select" style="width: 100%;">
|
||||
<option value="">Loading playlists...</option>
|
||||
</select>
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
|
||||
Select a playlist from your Spotify library
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Manual input -->
|
||||
<div class="form-group" id="link-manual-group" style="display: none;">
|
||||
<label>Spotify Playlist ID or URL</label>
|
||||
<input type="text" id="link-spotify-id"
|
||||
placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>,
|
||||
or full Spotify URL
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Sync Schedule -->
|
||||
<div class="form-group">
|
||||
<label>Sync Schedule (Cron)</label>
|
||||
<input type="text" id="link-sync-schedule" placeholder="0 8 * * *" value="0 8 * * *"
|
||||
style="font-family: monospace;">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Cron format: <code>minute hour day month dayofweek</code><br>
|
||||
Default: <code>0 8 * * *</code> = 8 AM every day<br>
|
||||
Examples: <code>0 6 * * *</code> = daily at 6 AM, <code>0 20 * * 5</code> = Fridays at 8 PM<br>
|
||||
<a href="https://crontab.guru/" target="_blank" style="color: var(--primary);">Use crontab.guru to
|
||||
build your schedule</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
||||
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
|
||||
@@ -1192,7 +950,8 @@
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<h3>Map Lyrics ID</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Manually map a track to a specific lyrics ID from lrclib.net. You can find lyrics IDs by searching on <a href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a>.
|
||||
Manually map a track to a specific lyrics ID from lrclib.net. You can find lyrics IDs by searching on <a
|
||||
href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a>.
|
||||
</p>
|
||||
|
||||
<!-- Track Info -->
|
||||
@@ -1210,7 +969,8 @@
|
||||
<label>Lyrics ID from lrclib.net</label>
|
||||
<input type="number" id="lyrics-map-id" placeholder="Enter lyrics ID (e.g., 5929990)" min="1">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Search for the track on <a href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a> and copy the ID from the URL or API response
|
||||
Search for the track on <a href="https://lrclib.net" target="_blank"
|
||||
style="color: var(--accent);">lrclib.net</a> and copy the ID from the URL or API response
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -1233,1822 +993,6 @@
|
||||
<p id="restart-status">Applying configuration changes...</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Current edit setting state
|
||||
let currentEditKey = null;
|
||||
let currentEditType = null;
|
||||
let currentEditOptions = null;
|
||||
<script type="module" src="js/main.js"></script>
|
||||
|
||||
// Track if we've already initialized the cookie date to prevent infinite loop
|
||||
let cookieDateInitialized = false;
|
||||
|
||||
// Track if restart is required
|
||||
let restartRequired = false;
|
||||
|
||||
function showRestartBanner() {
|
||||
restartRequired = true;
|
||||
document.getElementById('restart-banner').classList.add('active');
|
||||
}
|
||||
|
||||
function dismissRestartBanner() {
|
||||
document.getElementById('restart-banner').classList.remove('active');
|
||||
}
|
||||
|
||||
// Tab switching with URL hash support
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
||||
const content = document.getElementById('tab-' + tabName);
|
||||
|
||||
if (tab && content) {
|
||||
tab.classList.add('active');
|
||||
content.classList.add('active');
|
||||
window.location.hash = tabName;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
switchTab(tab.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
// Restore tab from URL hash on page load
|
||||
window.addEventListener('load', () => {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
switchTab(hash);
|
||||
}
|
||||
|
||||
// Start auto-refresh for playlists tab (every 5 seconds)
|
||||
startPlaylistAutoRefresh();
|
||||
});
|
||||
|
||||
// Auto-refresh functionality for playlists
|
||||
let playlistAutoRefreshInterval = null;
|
||||
|
||||
function startPlaylistAutoRefresh() {
|
||||
// Clear any existing interval
|
||||
if (playlistAutoRefreshInterval) {
|
||||
clearInterval(playlistAutoRefreshInterval);
|
||||
}
|
||||
|
||||
// Refresh every 5 seconds when on playlists tab
|
||||
playlistAutoRefreshInterval = setInterval(() => {
|
||||
const playlistsTab = document.getElementById('tab-playlists');
|
||||
if (playlistsTab && playlistsTab.classList.contains('active')) {
|
||||
// Silently refresh without showing loading state
|
||||
fetchPlaylists(true);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function stopPlaylistAutoRefresh() {
|
||||
if (playlistAutoRefreshInterval) {
|
||||
clearInterval(playlistAutoRefreshInterval);
|
||||
playlistAutoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Toast notification
|
||||
function showToast(message, type = 'success', duration = 3000) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast ' + type;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), duration);
|
||||
}
|
||||
|
||||
// Modal helpers
|
||||
function openModal(id) {
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('active');
|
||||
}
|
||||
|
||||
// Close modals on backdrop click
|
||||
document.querySelectorAll('.modal').forEach(modal => {
|
||||
modal.addEventListener('click', e => {
|
||||
if (e.target === modal) closeModal(modal.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Format cookie age with color coding
|
||||
function formatCookieAge(setDateStr, hasCookie = false) {
|
||||
if (!setDateStr) {
|
||||
if (hasCookie) {
|
||||
return { text: 'Unknown age', class: 'warning', detail: 'Cookie date not tracked', needsInit: true };
|
||||
}
|
||||
return { text: 'No cookie', class: '', detail: '', needsInit: false };
|
||||
}
|
||||
|
||||
const setDate = new Date(setDateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - setDate;
|
||||
const daysAgo = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const monthsAgo = daysAgo / 30;
|
||||
|
||||
let status = 'success'; // green: < 6 months
|
||||
if (monthsAgo >= 10) status = 'error'; // red: > 10 months
|
||||
else if (monthsAgo >= 6) status = 'warning'; // yellow: 6-10 months
|
||||
|
||||
let text;
|
||||
if (daysAgo === 0) text = 'Set today';
|
||||
else if (daysAgo === 1) text = 'Set yesterday';
|
||||
else if (daysAgo < 30) text = `Set ${daysAgo} days ago`;
|
||||
else if (daysAgo < 60) text = 'Set ~1 month ago';
|
||||
else text = `Set ~${Math.floor(monthsAgo)} months ago`;
|
||||
|
||||
const remaining = 12 - monthsAgo;
|
||||
let detail;
|
||||
if (remaining > 6) detail = 'Cookie typically lasts ~1 year';
|
||||
else if (remaining > 2) detail = `~${Math.floor(remaining)} months until expiration`;
|
||||
else if (remaining > 0) detail = 'Cookie may expire soon!';
|
||||
else detail = 'Cookie may have expired - update if having issues';
|
||||
|
||||
return { text, class: status, detail, needsInit: false };
|
||||
}
|
||||
|
||||
// Initialize cookie date if cookie exists but date is not set
|
||||
async function initCookieDate() {
|
||||
if (cookieDateInitialized) {
|
||||
console.log('Cookie date already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
cookieDateInitialized = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/config/init-cookie-date', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
console.log('Cookie date initialized successfully - restart container to apply');
|
||||
showToast('Cookie date set. Restart container to apply changes.', 'success');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
console.log('Cookie date init response:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to init cookie date:', error);
|
||||
cookieDateInitialized = false; // Allow retry on error
|
||||
}
|
||||
}
|
||||
|
||||
// API calls
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/status');
|
||||
const data = await res.json();
|
||||
|
||||
document.getElementById('version').textContent = 'v' + data.version;
|
||||
document.getElementById('backend-type').textContent = data.backendType;
|
||||
document.getElementById('jellyfin-url').textContent = data.jellyfinUrl || '-';
|
||||
document.getElementById('playlist-count').textContent = data.spotifyImport.playlistCount;
|
||||
document.getElementById('cache-duration').textContent = data.spotify.cacheDurationMinutes + ' min';
|
||||
document.getElementById('isrc-matching').textContent = data.spotify.preferIsrcMatching ? 'Enabled' : 'Disabled';
|
||||
document.getElementById('spotify-user').textContent = data.spotify.user || '-';
|
||||
|
||||
// Update status badge and cookie age
|
||||
const statusBadge = document.getElementById('spotify-status');
|
||||
const authStatus = document.getElementById('spotify-auth-status');
|
||||
const cookieAgeEl = document.getElementById('spotify-cookie-age');
|
||||
|
||||
if (data.spotify.authStatus === 'configured') {
|
||||
statusBadge.className = 'status-badge success';
|
||||
statusBadge.innerHTML = '<span class="status-dot"></span>Spotify Ready';
|
||||
authStatus.textContent = 'Cookie Set';
|
||||
authStatus.className = 'stat-value success';
|
||||
} else if (data.spotify.authStatus === 'missing_cookie') {
|
||||
statusBadge.className = 'status-badge warning';
|
||||
statusBadge.innerHTML = '<span class="status-dot"></span>Cookie Missing';
|
||||
authStatus.textContent = 'No Cookie';
|
||||
authStatus.className = 'stat-value warning';
|
||||
} else {
|
||||
statusBadge.className = 'status-badge';
|
||||
statusBadge.innerHTML = '<span class="status-dot"></span>Not Configured';
|
||||
authStatus.textContent = 'Not Configured';
|
||||
authStatus.className = 'stat-value';
|
||||
}
|
||||
|
||||
// Update cookie age display
|
||||
if (cookieAgeEl) {
|
||||
const hasCookie = data.spotify.hasCookie;
|
||||
const age = formatCookieAge(data.spotify.cookieSetDate, hasCookie);
|
||||
cookieAgeEl.innerHTML = `<span class="${age.class}">${age.text}</span><br><small style="color:var(--text-secondary)">${age.detail}</small>`;
|
||||
|
||||
// Auto-init cookie date if cookie exists but date is not set
|
||||
if (age.needsInit) {
|
||||
console.log('Cookie exists but date not set, initializing...');
|
||||
initCookieDate();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch status:', error);
|
||||
showToast('Failed to fetch status', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlaylists(silent = false) {
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists');
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('playlist-table-body');
|
||||
|
||||
if (data.playlists.length === 0) {
|
||||
if (!silent) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.playlists.map(p => {
|
||||
// Enhanced statistics display
|
||||
const spotifyTotal = p.trackCount || 0;
|
||||
const localCount = p.localTracks || 0;
|
||||
const externalMatched = p.externalMatched || 0;
|
||||
const externalMissing = p.externalMissing || 0;
|
||||
const totalInJellyfin = p.totalInJellyfin || 0;
|
||||
const totalPlayable = p.totalPlayable || (localCount + externalMatched); // Total tracks that will be served
|
||||
|
||||
// Debug: Log the raw data
|
||||
console.log(`Playlist ${p.name}:`, {
|
||||
spotifyTotal,
|
||||
localCount,
|
||||
externalMatched,
|
||||
externalMissing,
|
||||
totalInJellyfin,
|
||||
totalPlayable,
|
||||
rawData: p
|
||||
});
|
||||
|
||||
// Build detailed stats string - show total playable tracks prominently
|
||||
let statsHtml = `<span class="track-count">${totalPlayable}/${spotifyTotal}</span>`;
|
||||
|
||||
// Show breakdown with color coding
|
||||
let breakdownParts = [];
|
||||
if (localCount > 0) {
|
||||
breakdownParts.push(`<span style="color:var(--success)">${localCount} local</span>`);
|
||||
}
|
||||
if (externalMatched > 0) {
|
||||
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} matched</span>`);
|
||||
}
|
||||
if (externalMissing > 0) {
|
||||
breakdownParts.push(`<span style="color:var(--warning)">${externalMissing} missing</span>`);
|
||||
}
|
||||
|
||||
const breakdown = breakdownParts.length > 0
|
||||
? `<br><small style="color:var(--text-secondary)">${breakdownParts.join(' • ')}</small>`
|
||||
: '';
|
||||
|
||||
// Calculate completion percentage based on playable tracks
|
||||
const completionPct = spotifyTotal > 0 ? Math.round((totalPlayable / spotifyTotal) * 100) : 0;
|
||||
const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0;
|
||||
const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0;
|
||||
const missingPct = spotifyTotal > 0 ? Math.round((externalMissing / spotifyTotal) * 100) : 0;
|
||||
const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)';
|
||||
|
||||
// Debug logging
|
||||
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
|
||||
<td>${statsHtml}${breakdown}</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;">
|
||||
<div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div>
|
||||
<div style="width:${externalPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMatched} external matched tracks"></div>
|
||||
<div style="width:${missingPct}%;height:100%;background:#6b7280;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
|
||||
</div>
|
||||
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||
<td>
|
||||
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
||||
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
||||
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch playlists:', error);
|
||||
showToast('Failed to fetch playlists', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTrackMappings() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/mappings/tracks');
|
||||
const data = await res.json();
|
||||
|
||||
// Update summary (only external now)
|
||||
document.getElementById('mappings-total').textContent = data.externalCount || 0;
|
||||
document.getElementById('mappings-external').textContent = data.externalCount || 0;
|
||||
|
||||
const tbody = document.getElementById('mappings-table-body');
|
||||
|
||||
if (data.mappings.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No manual mappings found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to only show external mappings
|
||||
const externalMappings = data.mappings.filter(m => m.type === 'external');
|
||||
|
||||
if (externalMappings.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No external mappings found. Local Jellyfin mappings should be managed via Spotify Import plugin.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = externalMappings.map((m, index) => {
|
||||
const typeColor = 'var(--success)';
|
||||
const typeBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">external</span>`;
|
||||
|
||||
const targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
|
||||
|
||||
const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : '-';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(m.playlist)}</strong></td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${m.spotifyId}</td>
|
||||
<td>${typeBadge}</td>
|
||||
<td>${targetDisplay}</td>
|
||||
<td style="color:var(--text-secondary);font-size:0.85rem;">${createdDate}</td>
|
||||
<td>
|
||||
<button class="danger delete-mapping-btn" style="padding:4px 12px;font-size:0.8rem;" data-playlist="${escapeHtml(m.playlist)}" data-spotify-id="${m.spotifyId}">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add event listeners to all delete buttons
|
||||
document.querySelectorAll('.delete-mapping-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const playlist = this.getAttribute('data-playlist');
|
||||
const spotifyId = this.getAttribute('data-spotify-id');
|
||||
deleteTrackMapping(playlist, spotifyId);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch track mappings:', error);
|
||||
showToast('Failed to fetch track mappings', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTrackMapping(playlist, spotifyId) {
|
||||
if (!confirm(`Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\n• The track may be re-matched with potentially better results\n\nThis action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Mapping removed successfully', 'success');
|
||||
await fetchTrackMappings();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
showToast(error.error || 'Failed to remove mapping', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete mapping:', error);
|
||||
showToast('Failed to remove mapping', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMissingTracks() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists');
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('missing-tracks-table-body');
|
||||
const missingTracks = [];
|
||||
|
||||
// Collect all missing tracks from all playlists
|
||||
for (const playlist of data.playlists) {
|
||||
if (playlist.externalMissing > 0) {
|
||||
// Fetch tracks for this playlist
|
||||
try {
|
||||
const tracksRes = await fetch(`/api/admin/playlists/${encodeURIComponent(playlist.name)}/tracks`);
|
||||
const tracksData = await tracksRes.json();
|
||||
|
||||
// Filter to only missing tracks (isLocal === null)
|
||||
const missing = tracksData.tracks.filter(t => t.isLocal === null);
|
||||
missing.forEach(t => {
|
||||
missingTracks.push({
|
||||
playlist: playlist.name,
|
||||
...t
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch tracks for ${playlist.name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update summary
|
||||
document.getElementById('missing-total').textContent = missingTracks.length;
|
||||
|
||||
if (missingTracks.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">🎉 No missing tracks! All tracks are matched.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = missingTracks.map(t => {
|
||||
const artist = (t.artists && t.artists.length > 0) ? t.artists.join(', ') : '';
|
||||
const searchQuery = `${t.title} ${artist}`;
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(t.playlist)}</strong></td>
|
||||
<td>${escapeHtml(t.title)}</td>
|
||||
<td>${escapeHtml(artist)}</td>
|
||||
<td style="color:var(--text-secondary);">${t.album ? escapeHtml(t.album) : '-'}</td>
|
||||
<td>
|
||||
<button onclick="searchProvider('${escapeJs(searchQuery)}', 'squidwtf')"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">🔍 Search</button>
|
||||
<button onclick="openMapToLocal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--success);border-color:var(--success);">Map to Local</button>
|
||||
<button onclick="openMapToExternal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
|
||||
style="font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch missing tracks:', error);
|
||||
showToast('Failed to fetch missing tracks', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDownloads() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/downloads');
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('downloads-table-body');
|
||||
|
||||
// Update summary
|
||||
document.getElementById('downloads-count').textContent = data.count;
|
||||
document.getElementById('downloads-size').textContent = data.totalSizeFormatted;
|
||||
|
||||
if (data.count === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.files.map(f => {
|
||||
return `
|
||||
<tr data-path="${escapeHtml(f.path)}">
|
||||
<td><strong>${escapeHtml(f.artist)}</strong></td>
|
||||
<td>${escapeHtml(f.album)}</td>
|
||||
<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)}')"
|
||||
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)}')"
|
||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch downloads:', error);
|
||||
showToast('Failed to fetch downloads', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(path) {
|
||||
try {
|
||||
window.open(`/api/admin/downloads/file?path=${encodeURIComponent(path)}`, '_blank');
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error);
|
||||
showToast('Failed to download file', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDownload(path) {
|
||||
if (!confirm(`Delete this file?\n\n${path}\n\nThis action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/downloads?path=${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showToast('File deleted successfully', 'success');
|
||||
|
||||
// Remove the row immediately for live update
|
||||
const escapedPath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const row = document.querySelector(`tr[data-path="${escapedPath}"]`);
|
||||
if (row) {
|
||||
row.remove();
|
||||
}
|
||||
|
||||
// Refresh to update counts
|
||||
await fetchDownloads();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
showToast(error.error || 'Failed to delete file', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error);
|
||||
showToast('Failed to delete file', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/config');
|
||||
const data = await res.json();
|
||||
|
||||
// Spotify API settings
|
||||
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
||||
document.getElementById('config-cache-duration').textContent = data.spotifyApi.cacheDurationMinutes + ' minutes';
|
||||
document.getElementById('config-isrc-matching').textContent = data.spotifyApi.preferIsrcMatching ? 'Enabled' : 'Disabled';
|
||||
|
||||
// Cookie age in config tab
|
||||
const configCookieAge = document.getElementById('config-cookie-age');
|
||||
if (configCookieAge) {
|
||||
const hasCookie = data.spotifyApi.sessionCookie && data.spotifyApi.sessionCookie !== '(not set)';
|
||||
const age = formatCookieAge(data.spotifyApi.sessionCookieSetDate, hasCookie);
|
||||
configCookieAge.innerHTML = `<span class="${age.class}">${age.text}</span> - ${age.detail}`;
|
||||
}
|
||||
|
||||
// Deezer settings
|
||||
document.getElementById('config-deezer-arl').textContent = data.deezer.arl || '(not set)';
|
||||
document.getElementById('config-deezer-quality').textContent = data.deezer.quality;
|
||||
|
||||
// SquidWTF settings
|
||||
document.getElementById('config-squid-quality').textContent = data.squidWtf.quality;
|
||||
|
||||
// MusicBrainz settings
|
||||
document.getElementById('config-musicbrainz-enabled').textContent = data.musicBrainz.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-musicbrainz-username').textContent = data.musicBrainz.username || '(not set)';
|
||||
document.getElementById('config-musicbrainz-password').textContent = data.musicBrainz.password || '(not set)';
|
||||
|
||||
// Qobuz settings
|
||||
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
|
||||
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
|
||||
|
||||
// Jellyfin settings
|
||||
document.getElementById('config-jellyfin-url').textContent = data.jellyfin.url || '-';
|
||||
document.getElementById('config-jellyfin-api-key').textContent = data.jellyfin.apiKey;
|
||||
document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)';
|
||||
document.getElementById('config-jellyfin-library-id').textContent = data.jellyfin.libraryId || '-';
|
||||
|
||||
// Library settings
|
||||
document.getElementById('config-download-path').textContent = data.library?.downloadPath || './downloads';
|
||||
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
|
||||
|
||||
// Sync settings
|
||||
const syncHour = data.spotifyImport.syncStartHour;
|
||||
const syncMin = data.spotifyImport.syncStartMinute;
|
||||
document.getElementById('config-sync-time').textContent = `${String(syncHour).padStart(2, '0')}:${String(syncMin).padStart(2, '0')}`;
|
||||
document.getElementById('config-sync-window').textContent = data.spotifyImport.syncWindowHours + ' hours';
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJellyfinUsers() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/jellyfin/users');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const select = document.getElementById('jellyfin-user-select');
|
||||
select.innerHTML = '<option value="">All Users</option>' +
|
||||
data.users.map(u => `<option value="${u.id}">${escapeHtml(u.name)}</option>`).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function fetchJellyfinPlaylists() {
|
||||
const tbody = document.getElementById('jellyfin-playlist-table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
||||
|
||||
try {
|
||||
// Build URL with optional user filter
|
||||
const userId = document.getElementById('jellyfin-user-select').value;
|
||||
|
||||
let url = '/api/admin/jellyfin/playlists';
|
||||
if (userId) url += '?userId=' + encodeURIComponent(userId);
|
||||
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;color:var(--error);padding:40px;">${errorData.error || 'Failed to fetch playlists'}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.playlists.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists found in Jellyfin</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.playlists.map(p => {
|
||||
const statusBadge = p.isConfigured
|
||||
? '<span class="status-badge success"><span class="status-dot"></span>Linked</span>'
|
||||
: '<span class="status-badge"><span class="status-dot"></span>Not Linked</span>';
|
||||
|
||||
const actionButton = p.isConfigured
|
||||
? `<button class="danger" onclick="unlinkPlaylist('${escapeJs(p.name)}')">Unlink</button>`
|
||||
: `<button class="primary" onclick="openLinkPlaylist('${escapeJs(p.id)}', '${escapeJs(p.name)}')">Link to Spotify</button>`;
|
||||
|
||||
const localCount = p.localTracks || 0;
|
||||
const externalCount = p.externalTracks || 0;
|
||||
const externalAvail = p.externalAvailable || 0;
|
||||
|
||||
return `
|
||||
<tr data-playlist-id="${escapeHtml(p.id)}">
|
||||
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||
<td class="track-count">${localCount}</td>
|
||||
<td class="track-count">${externalCount > 0 ? `${externalAvail}/${externalCount}` : '-'}</td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.linkedSpotifyId || '-'}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${actionButton}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Jellyfin playlists:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--error);padding:40px;">Failed to fetch playlists</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function openLinkPlaylist(jellyfinId, name) {
|
||||
document.getElementById('link-jellyfin-id').value = jellyfinId;
|
||||
document.getElementById('link-jellyfin-name').value = name;
|
||||
document.getElementById('link-spotify-id').value = '';
|
||||
openModal('link-playlist-modal');
|
||||
}
|
||||
|
||||
async function linkPlaylist() {
|
||||
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
||||
const name = document.getElementById('link-jellyfin-name').value;
|
||||
const spotifyId = document.getElementById('link-spotify-id').value.trim();
|
||||
|
||||
if (!spotifyId) {
|
||||
showToast('Spotify Playlist ID is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract ID from various Spotify formats:
|
||||
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
||||
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
|
||||
// - 37i9dQZF1DXcBWIGoYBM5M
|
||||
let cleanSpotifyId = spotifyId;
|
||||
|
||||
// Handle spotify: URI format
|
||||
if (spotifyId.startsWith('spotify:playlist:')) {
|
||||
cleanSpotifyId = spotifyId.replace('spotify:playlist:', '');
|
||||
}
|
||||
// Handle URL format
|
||||
else if (spotifyId.includes('spotify.com/playlist/')) {
|
||||
const match = spotifyId.match(/playlist\/([a-zA-Z0-9]+)/);
|
||||
if (match) cleanSpotifyId = match[1];
|
||||
}
|
||||
// Remove any query parameters or trailing slashes
|
||||
cleanSpotifyId = cleanSpotifyId.split('?')[0].split('#')[0].replace(/\/$/, '');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Playlist linked!', 'success');
|
||||
showRestartBanner();
|
||||
closeModal('link-playlist-modal');
|
||||
|
||||
// Update UI state without refetching all playlists
|
||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||
if (playlistsTable) {
|
||||
const rows = playlistsTable.querySelectorAll('tr');
|
||||
rows.forEach(row => {
|
||||
if (row.dataset.playlistId === jellyfinId) {
|
||||
const actionCell = row.querySelector('td:last-child');
|
||||
if (actionCell) {
|
||||
actionCell.innerHTML = `<button class="danger" onclick="unlinkPlaylist('${escapeJs(name)}')">Unlink</button>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchPlaylists(); // Only refresh the Active Playlists tab
|
||||
} else {
|
||||
showToast(data.error || 'Failed to link playlist', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to link playlist', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkPlaylist(name) {
|
||||
if (!confirm(`Unlink playlist "${name}"? This will stop filling in missing tracks.`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(name)}/unlink`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Playlist unlinked.', 'success');
|
||||
showRestartBanner();
|
||||
|
||||
// Update UI state without refetching all playlists
|
||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||
if (playlistsTable) {
|
||||
const rows = playlistsTable.querySelectorAll('tr');
|
||||
rows.forEach(row => {
|
||||
const nameCell = row.querySelector('td:first-child');
|
||||
if (nameCell && nameCell.textContent === name) {
|
||||
const actionCell = row.querySelector('td:last-child');
|
||||
if (actionCell) {
|
||||
const playlistId = row.dataset.playlistId;
|
||||
actionCell.innerHTML = `<button class="primary" onclick="openLinkPlaylist('${escapeJs(playlistId)}', '${escapeJs(name)}')">Link to Spotify</button>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fetchPlaylists(); // Only refresh the Active Playlists tab
|
||||
} else {
|
||||
showToast(data.error || 'Failed to unlink playlist', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to unlink playlist', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPlaylists() {
|
||||
try {
|
||||
showToast('Refreshing playlists...', 'success');
|
||||
const res = await fetch('/api/admin/playlists/refresh', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
showToast(data.message, 'success');
|
||||
setTimeout(fetchPlaylists, 2000);
|
||||
} catch (error) {
|
||||
showToast('Failed to refresh playlists', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPlaylistCache(name) {
|
||||
if (!confirm(`Clear cache and rebuild for "${name}"?\n\nThis will:\n• Clear Redis cache\n• Delete file caches\n• Rebuild with latest Spotify IDs\n\nThis may take a minute.`)) return;
|
||||
|
||||
try {
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
showToast(`Clearing cache for ${name}...`, 'info');
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`✓ ${data.message} (Cleared ${data.clearedKeys} cache keys, ${data.clearedFiles} files)`, 'success', 5000);
|
||||
// Refresh the playlists table after a delay to show updated counts
|
||||
setTimeout(() => {
|
||||
fetchPlaylists();
|
||||
// Hide warning banner after refresh
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 3000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to clear cache', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to clear cache', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function matchPlaylistTracks(name) {
|
||||
try {
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
showToast(`Matching tracks for ${name}...`, 'success');
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`✓ ${data.message}`, 'success');
|
||||
// Refresh the playlists table after a delay to show updated counts
|
||||
setTimeout(() => {
|
||||
fetchPlaylists();
|
||||
// Hide warning banner after refresh
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function matchAllPlaylists() {
|
||||
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
||||
|
||||
try {
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
showToast('Matching tracks for all playlists...', 'success');
|
||||
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`✓ ${data.message}`, 'success');
|
||||
// Refresh the playlists table after a delay to show updated counts
|
||||
setTimeout(() => {
|
||||
fetchPlaylists();
|
||||
// Hide warning banner after refresh
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAndMatchAll() {
|
||||
if (!confirm('Clear caches, refresh from Spotify, and match all tracks?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Match all tracks against local library and external providers\n\nThis may take several minutes.')) return;
|
||||
|
||||
try {
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
showToast('Starting full refresh and match...', 'info', 3000);
|
||||
|
||||
// Step 1: Clear all caches
|
||||
showToast('Step 1/3: Clearing caches...', 'info', 2000);
|
||||
await fetch('/api/admin/cache/clear', { method: 'POST' });
|
||||
|
||||
// Wait for cache to be fully cleared
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Step 2: Refresh playlists from Spotify
|
||||
showToast('Step 2/3: Fetching from Spotify...', 'info', 2000);
|
||||
await fetch('/api/admin/playlists/refresh', { method: 'POST' });
|
||||
|
||||
// Wait for Spotify fetch to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// Step 3: Match all tracks
|
||||
showToast('Step 3/3: Matching all tracks (this may take several minutes)...', 'info', 3000);
|
||||
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`✓ Full refresh and match complete!`, 'success', 5000);
|
||||
// Refresh the playlists table after a delay
|
||||
setTimeout(() => {
|
||||
fetchPlaylists();
|
||||
// Hide warning banner after refresh
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 3000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to complete refresh and match', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function searchProvider(query, provider) {
|
||||
// Use SquidWTF HiFi API with round-robin base URLs for all searches
|
||||
// Get a random base URL from the backend
|
||||
try {
|
||||
const response = await fetch('/api/admin/squidwtf-base-url');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.baseUrl) {
|
||||
// Use the HiFi API search endpoint: /search/?s=query
|
||||
const searchUrl = `${data.baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||
window.open(searchUrl, '_blank');
|
||||
} else {
|
||||
showToast('Failed to get search URL', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to get search URL', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function capitalizeProvider(provider) {
|
||||
// Capitalize provider names for display
|
||||
const providerMap = {
|
||||
'squidwtf': 'SquidWTF',
|
||||
'deezer': 'Deezer',
|
||||
'qobuz': 'Qobuz'
|
||||
};
|
||||
return providerMap[provider?.toLowerCase()] || provider;
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
if (!confirm('Clear all cached playlist data?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/cache/clear', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
showToast(data.message, 'success');
|
||||
fetchPlaylists();
|
||||
} catch (error) {
|
||||
showToast('Failed to clear cache', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function exportEnv() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/export-env');
|
||||
if (!res.ok) {
|
||||
throw new Error('Export failed');
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `.env.backup.${new Date().toISOString().split('T')[0]}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
showToast('.env file exported successfully', 'success');
|
||||
} catch (error) {
|
||||
showToast('Failed to export .env file', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function importEnv(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!confirm('Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.')) {
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch('/api/admin/import-env', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(data.message, 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to import .env file', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to import .env file', 'error');
|
||||
}
|
||||
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
async function restartContainer() {
|
||||
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/restart', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
// Show the restart overlay
|
||||
document.getElementById('restart-overlay').classList.add('active');
|
||||
document.getElementById('restart-status').textContent = 'Stopping container...';
|
||||
|
||||
// Wait a bit then start checking if the server is back
|
||||
setTimeout(() => {
|
||||
document.getElementById('restart-status').textContent = 'Waiting for server to come back...';
|
||||
checkServerAndReload();
|
||||
}, 3000);
|
||||
} else {
|
||||
showToast(data.message || data.error || 'Failed to restart', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to restart container', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkServerAndReload() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60; // Try for 60 seconds
|
||||
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/status', {
|
||||
method: 'GET',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (res.ok) {
|
||||
document.getElementById('restart-status').textContent = 'Server is back! Reloading...';
|
||||
dismissRestartBanner();
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Server still restarting
|
||||
}
|
||||
|
||||
attempts++;
|
||||
document.getElementById('restart-status').textContent = `Waiting for server to come back... (${attempts}s)`;
|
||||
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(checkHealth, 1000);
|
||||
} else {
|
||||
document.getElementById('restart-overlay').classList.remove('active');
|
||||
showToast('Server may still be restarting. Please refresh manually.', 'warning');
|
||||
}
|
||||
};
|
||||
|
||||
checkHealth();
|
||||
}
|
||||
|
||||
function openAddPlaylist() {
|
||||
document.getElementById('new-playlist-name').value = '';
|
||||
document.getElementById('new-playlist-id').value = '';
|
||||
openModal('add-playlist-modal');
|
||||
}
|
||||
|
||||
async function addPlaylist() {
|
||||
const name = document.getElementById('new-playlist-name').value.trim();
|
||||
const id = document.getElementById('new-playlist-id').value.trim();
|
||||
|
||||
if (!name || !id) {
|
||||
showToast('Name and ID are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, spotifyId: id })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Playlist added.', 'success');
|
||||
showRestartBanner();
|
||||
closeModal('add-playlist-modal');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to add playlist', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to add playlist', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removePlaylist(name) {
|
||||
if (!confirm(`Remove playlist "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Playlist removed.', 'success');
|
||||
showRestartBanner();
|
||||
fetchPlaylists();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to remove playlist', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to remove playlist', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function viewTracks(name) {
|
||||
document.getElementById('tracks-modal-title').textContent = name + ' - Tracks';
|
||||
document.getElementById('tracks-list').innerHTML = '<div class="loading"><span class="spinner"></span> Loading tracks...</div>';
|
||||
openModal('tracks-modal');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.tracks.length === 0) {
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('tracks-list').innerHTML = data.tracks.map(t => {
|
||||
let statusBadge = '';
|
||||
let mapButton = '';
|
||||
let lyricsBadge = '';
|
||||
|
||||
// Add lyrics status badge
|
||||
if (t.hasLyrics) {
|
||||
lyricsBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:#3b82f6;color:white;"><span class="status-dot" style="background:white;"></span>Lyrics</span>';
|
||||
}
|
||||
|
||||
if (t.isLocal === true) {
|
||||
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
||||
// Add manual mapping indicator for local tracks
|
||||
if (t.isManualMapping && t.manualMappingType === 'jellyfin') {
|
||||
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
|
||||
}
|
||||
} else if (t.isLocal === false) {
|
||||
const provider = capitalizeProvider(t.externalProvider) || 'External';
|
||||
statusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
|
||||
// Add manual mapping indicator for external tracks
|
||||
if (t.isManualMapping && t.manualMappingType === 'external') {
|
||||
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
|
||||
}
|
||||
// Add both mapping buttons for external tracks using data attributes
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
mapButton = `<button class="small map-track-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
|
||||
<button class="small map-external-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
||||
} else {
|
||||
// isLocal is null/undefined - track is missing (not found locally or externally)
|
||||
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:var(--bg-tertiary);color:var(--text-secondary);"><span class="status-dot" style="background:var(--text-secondary);"></span>Missing</span>';
|
||||
// Add both mapping buttons for missing tracks
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
mapButton = `<button class="small map-track-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
|
||||
<button class="small map-external-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
||||
}
|
||||
|
||||
// Build search link with track name and artist
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
const searchLinkText = `${t.title} - ${firstArtist}`;
|
||||
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
|
||||
|
||||
// Add lyrics mapping button
|
||||
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>`;
|
||||
|
||||
return `
|
||||
<div class="track-item" data-position="${t.position}">
|
||||
<span class="track-position">${t.position + 1}</span>
|
||||
<div class="track-info">
|
||||
<h4>${escapeHtml(t.title)}${statusBadge}${lyricsBadge}${mapButton}${lyricsMapButton}</h4>
|
||||
<span class="artists">${escapeHtml((t.artists || []).join(', '))}</span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
${t.album ? escapeHtml(t.album) : ''}
|
||||
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
|
||||
${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>' : ''}
|
||||
${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>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add event listeners to map buttons
|
||||
document.querySelectorAll('.map-track-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const playlistName = this.getAttribute('data-playlist-name');
|
||||
const position = parseInt(this.getAttribute('data-position'));
|
||||
const title = this.getAttribute('data-title');
|
||||
const artist = this.getAttribute('data-artist');
|
||||
const spotifyId = this.getAttribute('data-spotify-id');
|
||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listeners to external map buttons
|
||||
document.querySelectorAll('.map-external-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const playlistName = this.getAttribute('data-playlist-name');
|
||||
const position = parseInt(this.getAttribute('data-position'));
|
||||
const title = this.getAttribute('data-title');
|
||||
const artist = this.getAttribute('data-artist');
|
||||
const spotifyId = this.getAttribute('data-spotify-id');
|
||||
openExternalMap(playlistName, position, title, artist, spotifyId);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Generic edit setting modal
|
||||
function openEditSetting(envKey, label, inputType, helpText = '', options = []) {
|
||||
currentEditKey = envKey;
|
||||
currentEditType = inputType;
|
||||
currentEditOptions = options;
|
||||
|
||||
document.getElementById('edit-setting-title').textContent = 'Edit ' + label;
|
||||
document.getElementById('edit-setting-label').textContent = label;
|
||||
|
||||
const helpEl = document.getElementById('edit-setting-help');
|
||||
if (helpText) {
|
||||
helpEl.textContent = helpText;
|
||||
helpEl.style.display = 'block';
|
||||
} else {
|
||||
helpEl.style.display = 'none';
|
||||
}
|
||||
|
||||
const container = document.getElementById('edit-setting-input-container');
|
||||
|
||||
if (inputType === 'toggle') {
|
||||
container.innerHTML = `
|
||||
<select id="edit-setting-value">
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
`;
|
||||
} else if (inputType === 'select') {
|
||||
container.innerHTML = `
|
||||
<select id="edit-setting-value">
|
||||
${options.map(opt => `<option value="${opt}">${opt}</option>`).join('')}
|
||||
</select>
|
||||
`;
|
||||
} else if (inputType === 'password') {
|
||||
container.innerHTML = `<input type="password" id="edit-setting-value" placeholder="Enter new value" autocomplete="off">`;
|
||||
} else if (inputType === 'number') {
|
||||
container.innerHTML = `<input type="number" id="edit-setting-value" placeholder="Enter value">`;
|
||||
} else {
|
||||
container.innerHTML = `<input type="text" id="edit-setting-value" placeholder="Enter value">`;
|
||||
}
|
||||
|
||||
openModal('edit-setting-modal');
|
||||
}
|
||||
|
||||
async function saveEditSetting() {
|
||||
const value = document.getElementById('edit-setting-value').value.trim();
|
||||
|
||||
if (!value && currentEditType !== 'toggle') {
|
||||
showToast('Value is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ updates: { [currentEditKey]: value } })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Setting updated.', 'success');
|
||||
showRestartBanner();
|
||||
closeModal('edit-setting-modal');
|
||||
fetchConfig();
|
||||
fetchStatus();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to update setting', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to update setting', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Manual track mapping
|
||||
let searchTimeout = null;
|
||||
|
||||
async function searchJellyfinTracks() {
|
||||
const query = document.getElementById('map-search-query').value.trim();
|
||||
|
||||
if (!query) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear URL input when searching
|
||||
document.getElementById('map-jellyfin-url').value = '';
|
||||
|
||||
// Debounce search
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(async () => {
|
||||
document.getElementById('map-search-results').innerHTML = '<div class="loading"><span class="spinner"></span> Searching...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
|
||||
const data = await res.json();
|
||||
|
||||
if (data.tracks.length === 0) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">No tracks found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('map-search-results').innerHTML = data.tracks.map(t => `
|
||||
<div class="track-item" style="cursor: pointer; border: 2px solid transparent;" onclick="selectJellyfinTrack('${t.id}', this)">
|
||||
<div class="track-info">
|
||||
<h4>${escapeHtml(t.title)}</h4>
|
||||
<span class="artists">${escapeHtml(t.artist)}</span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
${t.album ? escapeHtml(t.album) : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Search failed</p>';
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function extractJellyfinId() {
|
||||
const url = document.getElementById('map-jellyfin-url').value.trim();
|
||||
|
||||
if (!url) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear search input when using URL
|
||||
document.getElementById('map-search-query').value = '';
|
||||
|
||||
// Extract ID from URL patterns:
|
||||
// https://jellyfin.example.com/web/#/details?id=XXXXX&serverId=...
|
||||
// https://jellyfin.example.com/web/index.html#!/details?id=XXXXX
|
||||
let jellyfinId = null;
|
||||
|
||||
try {
|
||||
const idMatch = url.match(/[?&]id=([a-f0-9]+)/i);
|
||||
if (idMatch) {
|
||||
jellyfinId = idMatch[1];
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid URL format
|
||||
}
|
||||
|
||||
if (!jellyfinId) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Could not extract track ID from URL. Make sure it contains "?id=..."</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch track details to show preview
|
||||
document.getElementById('map-search-results').innerHTML = '<div class="loading"><span class="spinner"></span> Loading track details...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/jellyfin/track/' + jellyfinId);
|
||||
const track = await res.json();
|
||||
|
||||
if (res.ok && track.id) {
|
||||
document.getElementById('map-selected-jellyfin-id').value = track.id;
|
||||
document.getElementById('map-save-btn').disabled = false;
|
||||
|
||||
document.getElementById('map-search-results').innerHTML = `
|
||||
<div class="track-item" style="border: 2px solid var(--accent); background: var(--bg-tertiary);">
|
||||
<div class="track-info">
|
||||
<h4>${escapeHtml(track.title)}</h4>
|
||||
<span class="artists">${escapeHtml(track.artist)}</span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
${track.album ? escapeHtml(track.album) : ''}
|
||||
</div>
|
||||
</div>
|
||||
<p style="text-align: center; color: var(--success); padding: 12px; margin-top: 8px;">
|
||||
✓ Track loaded from URL. Click "Save Mapping" to confirm.
|
||||
</p>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Track not found in Jellyfin</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Failed to load track details</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function selectJellyfinTrack(jellyfinId, element) {
|
||||
// Remove selection from all tracks
|
||||
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
|
||||
el.style.border = '2px solid transparent';
|
||||
el.style.background = '';
|
||||
});
|
||||
|
||||
// Highlight selected track
|
||||
element.style.border = '2px solid var(--accent)';
|
||||
element.style.background = 'var(--bg-tertiary)';
|
||||
|
||||
// Store selected ID and enable save button
|
||||
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
|
||||
document.getElementById('map-save-btn').disabled = false;
|
||||
}
|
||||
|
||||
// Validate external mapping input
|
||||
function validateExternalMapping() {
|
||||
const externalId = document.getElementById('map-external-id').value.trim();
|
||||
const saveBtn = document.getElementById('map-save-btn');
|
||||
|
||||
// Enable save button if external ID is provided
|
||||
saveBtn.disabled = !externalId;
|
||||
}
|
||||
|
||||
// Open manual mapping modal (external only)
|
||||
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||
document.getElementById('map-playlist-name').value = playlistName;
|
||||
document.getElementById('map-position').textContent = position + 1;
|
||||
document.getElementById('map-spotify-title').textContent = title;
|
||||
document.getElementById('map-spotify-artist').textContent = artist;
|
||||
document.getElementById('map-spotify-id').value = spotifyId;
|
||||
|
||||
// Reset fields
|
||||
document.getElementById('map-external-id').value = '';
|
||||
document.getElementById('map-external-provider').value = 'SquidWTF';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
|
||||
openModal('manual-map-modal');
|
||||
}
|
||||
|
||||
// Alias for backward compatibility
|
||||
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||
}
|
||||
|
||||
// Save manual mapping (external only)
|
||||
async function saveManualMapping() {
|
||||
const playlistName = document.getElementById('map-playlist-name').value;
|
||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
|
||||
|
||||
const externalProvider = document.getElementById('map-external-provider').value;
|
||||
const externalId = document.getElementById('map-external-id').value.trim();
|
||||
|
||||
if (!externalId) {
|
||||
showToast('Please enter an external provider ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
spotifyId,
|
||||
externalProvider,
|
||||
externalId
|
||||
};
|
||||
|
||||
// Show loading state
|
||||
const saveBtn = document.getElementById('map-save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`✓ Track mapped to ${requestBody.externalProvider} - rebuilding playlist...`, 'success');
|
||||
closeModal('manual-map-modal');
|
||||
|
||||
// Show rebuilding indicator
|
||||
showPlaylistRebuildingIndicator(playlistName);
|
||||
|
||||
// Show detailed info toast after a moment
|
||||
setTimeout(() => {
|
||||
showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
|
||||
}, 1000);
|
||||
|
||||
// Update the track in the UI without refreshing
|
||||
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
|
||||
if (trackItem) {
|
||||
const titleEl = trackItem.querySelector('.track-info h4');
|
||||
if (titleEl) {
|
||||
// Update status badge to show provider
|
||||
const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status
|
||||
const capitalizedProvider = capitalizeProvider(requestBody.externalProvider);
|
||||
const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(capitalizedProvider)}</span>`;
|
||||
titleEl.innerHTML = escapeHtml(currentTitle) + newStatusBadge;
|
||||
}
|
||||
|
||||
// Remove search link since it's now mapped
|
||||
const searchLink = trackItem.querySelector('.track-meta a');
|
||||
if (searchLink) {
|
||||
searchLink.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Also refresh the playlist counts in the background
|
||||
fetchPlaylists();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to save mapping', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
showToast('Request timed out - mapping may still be processing', 'warning');
|
||||
} else {
|
||||
showToast('Failed to save mapping', 'error');
|
||||
}
|
||||
} finally {
|
||||
// Reset button state
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showPlaylistRebuildingIndicator(playlistName) {
|
||||
// Find the playlist in the UI and show rebuilding state
|
||||
const playlistCards = document.querySelectorAll('.playlist-card');
|
||||
for (const card of playlistCards) {
|
||||
const nameEl = card.querySelector('h3');
|
||||
if (nameEl && nameEl.textContent.trim() === playlistName) {
|
||||
// Add rebuilding indicator
|
||||
const existingIndicator = card.querySelector('.rebuilding-indicator');
|
||||
if (!existingIndicator) {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'rebuilding-indicator';
|
||||
indicator.style.cssText = `
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: var(--warning);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 10;
|
||||
`;
|
||||
indicator.innerHTML = '<span class="spinner" style="width: 10px; height: 10px;"></span>Rebuilding...';
|
||||
card.style.position = 'relative';
|
||||
card.appendChild(indicator);
|
||||
|
||||
// Auto-remove after 30 seconds and refresh
|
||||
setTimeout(() => {
|
||||
indicator.remove();
|
||||
fetchPlaylists(); // Refresh to get updated counts
|
||||
}, 30000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeJs(text) {
|
||||
if (!text) return '';
|
||||
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
// Lyrics ID mapping functions
|
||||
function openLyricsMap(artist, title, album, durationSeconds) {
|
||||
document.getElementById('lyrics-map-artist').textContent = artist;
|
||||
document.getElementById('lyrics-map-title').textContent = title;
|
||||
document.getElementById('lyrics-map-album').textContent = album || '(No album)';
|
||||
document.getElementById('lyrics-map-artist-value').value = artist;
|
||||
document.getElementById('lyrics-map-title-value').value = title;
|
||||
document.getElementById('lyrics-map-album-value').value = album || '';
|
||||
document.getElementById('lyrics-map-duration').value = durationSeconds;
|
||||
document.getElementById('lyrics-map-id').value = '';
|
||||
|
||||
openModal('lyrics-map-modal');
|
||||
}
|
||||
|
||||
async function saveLyricsMapping() {
|
||||
const artist = document.getElementById('lyrics-map-artist-value').value;
|
||||
const title = document.getElementById('lyrics-map-title-value').value;
|
||||
const album = document.getElementById('lyrics-map-album-value').value;
|
||||
const durationSeconds = parseInt(document.getElementById('lyrics-map-duration').value);
|
||||
const lyricsId = parseInt(document.getElementById('lyrics-map-id').value);
|
||||
|
||||
if (!lyricsId || lyricsId <= 0) {
|
||||
showToast('Please enter a valid lyrics ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById('lyrics-map-save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/lyrics/map', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
artist,
|
||||
title,
|
||||
album,
|
||||
durationSeconds,
|
||||
lyricsId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
if (data.cached && data.lyrics) {
|
||||
showToast(`✓ Lyrics mapped and cached: ${data.lyrics.trackName} by ${data.lyrics.artistName}`, 'success', 5000);
|
||||
} else {
|
||||
showToast('✓ Lyrics mapping saved successfully', 'success');
|
||||
}
|
||||
closeModal('lyrics-map-modal');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to save lyrics mapping', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to save lyrics mapping', 'error');
|
||||
} finally {
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
fetchStatus();
|
||||
fetchPlaylists();
|
||||
fetchTrackMappings();
|
||||
fetchMissingTracks();
|
||||
fetchDownloads();
|
||||
fetchJellyfinUsers();
|
||||
fetchJellyfinPlaylists();
|
||||
fetchConfig();
|
||||
fetchEndpointUsage();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(() => {
|
||||
fetchStatus();
|
||||
fetchPlaylists();
|
||||
fetchTrackMappings();
|
||||
fetchMissingTracks();
|
||||
fetchDownloads();
|
||||
|
||||
// Refresh endpoint usage if on that tab
|
||||
const endpointsTab = document.getElementById('tab-endpoints');
|
||||
if (endpointsTab && endpointsTab.classList.contains('active')) {
|
||||
fetchEndpointUsage();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Endpoint Usage Functions
|
||||
async function fetchEndpointUsage() {
|
||||
try {
|
||||
const topSelect = document.getElementById('endpoints-top-select');
|
||||
const top = topSelect ? topSelect.value : 50;
|
||||
|
||||
const res = await fetch(`/api/admin/debug/endpoint-usage?top=${top}`);
|
||||
const data = await res.json();
|
||||
|
||||
// Update summary stats
|
||||
document.getElementById('endpoints-total-requests').textContent = data.totalRequests?.toLocaleString() || '0';
|
||||
document.getElementById('endpoints-unique-count').textContent = data.totalEndpoints?.toLocaleString() || '0';
|
||||
|
||||
const mostCalled = data.endpoints && data.endpoints.length > 0
|
||||
? data.endpoints[0].endpoint
|
||||
: '-';
|
||||
document.getElementById('endpoints-most-called').textContent = mostCalled;
|
||||
|
||||
// Update table
|
||||
const tbody = document.getElementById('endpoints-table-body');
|
||||
|
||||
if (!data.endpoints || data.endpoints.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No endpoint usage data available yet. Data will appear as clients make requests.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.endpoints.map((ep, index) => {
|
||||
const percentage = data.totalRequests > 0
|
||||
? ((ep.count / data.totalRequests) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
|
||||
// Color code based on usage
|
||||
let countColor = 'var(--text-primary)';
|
||||
if (ep.count > 1000) countColor = 'var(--error)';
|
||||
else if (ep.count > 100) countColor = 'var(--warning)';
|
||||
else if (ep.count > 10) countColor = 'var(--accent)';
|
||||
|
||||
// Highlight common patterns
|
||||
let endpointDisplay = ep.endpoint;
|
||||
if (ep.endpoint.includes('/stream')) {
|
||||
endpointDisplay = `<span style="color:var(--success)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else if (ep.endpoint.includes('/Playing')) {
|
||||
endpointDisplay = `<span style="color:var(--accent)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else if (ep.endpoint.includes('/Search')) {
|
||||
endpointDisplay = `<span style="color:var(--warning)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else {
|
||||
endpointDisplay = escapeHtml(ep.endpoint);
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="color:var(--text-secondary);text-align:center;">${index + 1}</td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${endpointDisplay}</td>
|
||||
<td style="text-align:right;font-weight:600;color:${countColor}">${ep.count.toLocaleString()}</td>
|
||||
<td style="text-align:right;color:var(--text-secondary)">${percentage}%</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch endpoint usage:', error);
|
||||
const tbody = document.getElementById('endpoints-table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--error);padding:40px;">Failed to load endpoint usage data</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function clearEndpointUsage() {
|
||||
if (!confirm('Are you sure you want to clear all endpoint usage data? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/debug/endpoint-usage', { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
|
||||
showToast(data.message || 'Endpoint usage data cleared', 'success');
|
||||
fetchEndpointUsage();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear endpoint usage:', error);
|
||||
showToast('Failed to clear endpoint usage data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,329 @@
|
||||
// API calls
|
||||
|
||||
export async function fetchStatus() {
|
||||
const res = await fetch('/api/admin/status');
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchPlaylists() {
|
||||
const res = await fetch('/api/admin/playlists');
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchPlaylistTracks(name) {
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/tracks`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchTrackMappings() {
|
||||
const res = await fetch('/api/admin/mappings/tracks');
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function deleteTrackMapping(playlist, spotifyId) {
|
||||
const res = await fetch(
|
||||
`/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Failed to remove mapping');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchDownloads() {
|
||||
const res = await fetch('/api/admin/downloads');
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function deleteDownload(path) {
|
||||
const res = await fetch(`/api/admin/downloads?path=${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchConfig() {
|
||||
const res = await fetch('/api/admin/config');
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function updateConfig(key, value) {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Failed to update setting');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchJellyfinUsers() {
|
||||
const res = await fetch('/api/admin/jellyfin/users');
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchJellyfinPlaylists(userId = null) {
|
||||
let url = '/api/admin/jellyfin/playlists';
|
||||
if (userId) url += '?userId=' + encodeURIComponent(userId);
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchSpotifyUserPlaylists() {
|
||||
const res = await fetch('/api/admin/spotify/user-playlists');
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Failed to fetch Spotify playlists');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function linkPlaylist(jellyfinId, spotifyId, syncSchedule) {
|
||||
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ spotifyPlaylistId: spotifyId, syncSchedule })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Failed to link playlist');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function unlinkPlaylist(name) {
|
||||
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(name)}/unlink`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Failed to unlink playlist');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function refreshPlaylists() {
|
||||
const res = await fetch('/api/admin/playlists/refresh', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function clearPlaylistCache(name) {
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function matchPlaylistTracks(name) {
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function matchAllPlaylists() {
|
||||
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
const res = await fetch('/api/admin/cache/clear', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function restartContainer() {
|
||||
const res = await fetch('/api/admin/restart', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchEndpointUsage(top = 50) {
|
||||
const res = await fetch(`/api/admin/debug/endpoint-usage?top=${top}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function clearEndpointUsage() {
|
||||
const res = await fetch('/api/admin/debug/endpoint-usage', { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function addPlaylist(name, spotifyId) {
|
||||
const res = await fetch('/api/admin/playlists', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, spotifyId })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Failed to add playlist');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function removePlaylist(name) {
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Failed to remove playlist');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function editPlaylistSchedule(playlistName, syncSchedule) {
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/schedule`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ syncSchedule })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Failed to update schedule');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function searchJellyfin(query) {
|
||||
const res = await fetch(`/api/admin/jellyfin/search?query=${encodeURIComponent(query)}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function getJellyfinTrack(jellyfinId) {
|
||||
const res = await fetch(`/api/admin/jellyfin/track/${jellyfinId}`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function saveTrackMapping(playlistName, mapping) {
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/map`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mapping)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Failed to save mapping');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function saveLyricsMapping(artist, title, album, durationSeconds, lyricsId) {
|
||||
const res = await fetch('/api/admin/lyrics/map', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ artist, title, album, durationSeconds, lyricsId })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Failed to save lyrics mapping');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function updateConfigSetting(key, value) {
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ updates: { [key]: value } })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Failed to update setting');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function initCookieDate() {
|
||||
const res = await fetch('/api/admin/config/init-cookie-date', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function exportEnv() {
|
||||
const res = await fetch('/api/admin/export-env');
|
||||
if (!res.ok) {
|
||||
throw new Error('Export failed');
|
||||
}
|
||||
return await res.blob();
|
||||
}
|
||||
|
||||
export async function importEnv(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch('/api/admin/import-env', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || 'Failed to import .env file');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function getSquidWTFBaseUrl() {
|
||||
const res = await fetch('/api/admin/squidwtf-base-url');
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// Helper functions for complex UI operations
|
||||
|
||||
import { escapeHtml, escapeJs, showToast, capitalizeProvider } from './utils.js';
|
||||
import * as API from './api.js';
|
||||
import { openModal, closeModal } from './modals.js';
|
||||
|
||||
let searchTimeout = null;
|
||||
|
||||
// View tracks modal
|
||||
export async function viewTracks(name) {
|
||||
document.getElementById('tracks-modal-title').textContent = name + ' - Tracks';
|
||||
document.getElementById('tracks-list').innerHTML = '<div class="loading"><span class="spinner"></span> Loading tracks...</div>';
|
||||
openModal('tracks-modal');
|
||||
|
||||
try {
|
||||
const data = await API.fetchPlaylistTracks(name);
|
||||
|
||||
if (!data || !data.tracks) {
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Invalid data received from server</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.tracks.length === 0) {
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('tracks-list').innerHTML = data.tracks.map((t, index) => {
|
||||
let statusBadge = '';
|
||||
let mapButton = '';
|
||||
let lyricsBadge = '';
|
||||
|
||||
if (t.hasLyrics) {
|
||||
lyricsBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:#3b82f6;color:white;"><span class="status-dot" style="background:white;"></span>Lyrics</span>';
|
||||
}
|
||||
|
||||
if (t.isLocal === true) {
|
||||
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
||||
if (t.isManualMapping && t.manualMappingType === 'jellyfin') {
|
||||
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
|
||||
}
|
||||
} else if (t.isLocal === false) {
|
||||
const provider = capitalizeProvider(t.externalProvider) || 'External';
|
||||
statusBadge = `<span class="status-badge info" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
|
||||
if (t.isManualMapping && t.manualMappingType === 'external') {
|
||||
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
|
||||
}
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
mapButton = `<button class="small map-track-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
|
||||
<button class="small map-external-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
||||
} else {
|
||||
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:rgba(245, 158, 11, 0.2);color:#f59e0b;"><span class="status-dot" style="background:#f59e0b;"></span>Missing</span>';
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
mapButton = `<button class="small map-track-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
|
||||
<button class="small map-external-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
||||
}
|
||||
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
const searchLinkText = `${t.title} - ${firstArtist}`;
|
||||
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
|
||||
|
||||
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>`;
|
||||
|
||||
return `
|
||||
<div class="track-item" data-position="${t.position}">
|
||||
<span class="track-position">${index + 1}</span>
|
||||
<div class="track-info">
|
||||
<h4>${escapeHtml(t.title)}${statusBadge}${lyricsBadge}${mapButton}${lyricsMapButton}</h4>
|
||||
<span class="artists">${escapeHtml((t.artists || []).join(', '))}</span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
${t.album ? escapeHtml(t.album) : ''}
|
||||
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
|
||||
${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>' : ''}
|
||||
${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>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add event listeners
|
||||
document.querySelectorAll('.map-track-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const playlistName = this.getAttribute('data-playlist-name');
|
||||
const position = parseInt(this.getAttribute('data-position'));
|
||||
const title = this.getAttribute('data-title');
|
||||
const artist = this.getAttribute('data-artist');
|
||||
const spotifyId = this.getAttribute('data-spotify-id');
|
||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.map-external-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const playlistName = this.getAttribute('data-playlist-name');
|
||||
const position = parseInt(this.getAttribute('data-position'));
|
||||
const title = this.getAttribute('data-title');
|
||||
const artist = this.getAttribute('data-artist');
|
||||
const spotifyId = this.getAttribute('data-spotify-id');
|
||||
openExternalMap(playlistName, position, title, artist, spotifyId);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in viewTracks:', error);
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + error.message + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Manual mapping to local Jellyfin track
|
||||
export function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||
document.getElementById('manual-map-title').textContent = `${title} - ${artist}`;
|
||||
document.getElementById('manual-map-playlist').value = playlistName;
|
||||
document.getElementById('manual-map-position').value = position;
|
||||
document.getElementById('manual-map-spotify-id').value = spotifyId;
|
||||
document.getElementById('jellyfin-search-query').value = `${title} ${artist}`;
|
||||
document.getElementById('jellyfin-results').innerHTML = '<p style="color:var(--text-secondary);text-align:center;padding:20px;">Enter search terms and click Search</p>';
|
||||
openModal('manual-map-modal');
|
||||
}
|
||||
|
||||
// Manual mapping to external provider
|
||||
export function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||
document.getElementById('external-map-title').textContent = `${title} - ${artist}`;
|
||||
document.getElementById('external-map-playlist').value = playlistName;
|
||||
document.getElementById('external-map-position').value = position;
|
||||
document.getElementById('external-map-spotify-id').value = spotifyId;
|
||||
document.getElementById('external-map-external-id').value = '';
|
||||
document.getElementById('external-map-provider').value = 'squidwtf';
|
||||
openModal('external-map-modal');
|
||||
}
|
||||
|
||||
// Search Jellyfin for tracks
|
||||
export async function searchJellyfinTracks() {
|
||||
const query = document.getElementById('jellyfin-search-query').value.trim();
|
||||
if (!query) {
|
||||
showToast('Please enter a search query', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const resultsDiv = document.getElementById('jellyfin-results');
|
||||
resultsDiv.innerHTML = '<div class="loading"><span class="spinner"></span> Searching...</div>';
|
||||
|
||||
try {
|
||||
const data = await API.searchJellyfin(query);
|
||||
|
||||
if (!data.results || data.results.length === 0) {
|
||||
resultsDiv.innerHTML = '<p style="color:var(--text-secondary);text-align:center;padding:20px;">No results found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsDiv.innerHTML = data.results.map(track => {
|
||||
return `
|
||||
<div class="jellyfin-result" onclick="selectJellyfinTrack('${escapeJs(track.id)}')">
|
||||
<div>
|
||||
<strong>${escapeHtml(track.name)}</strong>
|
||||
<br>
|
||||
<span style="color:var(--text-secondary);">${escapeHtml(track.artist || '')}</span>
|
||||
${track.album ? '<br><small>' + escapeHtml(track.album) + '</small>' : ''}
|
||||
</div>
|
||||
<div style="font-family:monospace;font-size:0.75rem;color:var(--text-secondary);">
|
||||
${track.id}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
resultsDiv.innerHTML = '<p style="color:var(--error);text-align:center;padding:20px;">Search failed: ' + error.message + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Select a Jellyfin track from search results
|
||||
export async function selectJellyfinTrack(jellyfinId) {
|
||||
try {
|
||||
const data = await API.getJellyfinTrack(jellyfinId);
|
||||
|
||||
document.getElementById('manual-map-jellyfin-id').value = jellyfinId;
|
||||
document.getElementById('manual-map-preview').innerHTML = `
|
||||
<strong>Selected:</strong> ${escapeHtml(data.track.name)}<br>
|
||||
<span style="color:var(--text-secondary);">Artist: ${escapeHtml(data.track.artist || 'Unknown')}</span><br>
|
||||
${data.track.album ? '<span style="color:var(--text-secondary);">Album: ' + escapeHtml(data.track.album) + '</span>' : ''}
|
||||
`;
|
||||
|
||||
showToast('Track selected. Click "Save Mapping" to confirm.', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch track details:', error);
|
||||
showToast('Failed to fetch track details', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Save local (Jellyfin) mapping
|
||||
export async function saveLocalMapping() {
|
||||
const playlistName = document.getElementById('manual-map-playlist').value;
|
||||
const position = parseInt(document.getElementById('manual-map-position').value);
|
||||
const spotifyId = document.getElementById('manual-map-spotify-id').value;
|
||||
const jellyfinId = document.getElementById('manual-map-jellyfin-id').value;
|
||||
|
||||
if (!jellyfinId) {
|
||||
showToast('Please select a Jellyfin track first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById('manual-map-save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
await API.saveTrackMapping(playlistName, {
|
||||
position,
|
||||
spotifyId,
|
||||
jellyfinId,
|
||||
type: 'jellyfin'
|
||||
});
|
||||
|
||||
showToast('✓ Mapping saved successfully', 'success');
|
||||
closeModal('manual-map-modal');
|
||||
|
||||
if (window.fetchPlaylists) window.fetchPlaylists();
|
||||
if (window.fetchTrackMappings) window.fetchTrackMappings();
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Failed to save mapping', 'error');
|
||||
} finally {
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save external provider mapping
|
||||
export async function saveManualMapping() {
|
||||
const playlistName = document.getElementById('external-map-playlist').value;
|
||||
const position = parseInt(document.getElementById('external-map-position').value);
|
||||
const spotifyId = document.getElementById('external-map-spotify-id').value;
|
||||
const externalId = document.getElementById('external-map-external-id').value.trim();
|
||||
const provider = document.getElementById('external-map-provider').value;
|
||||
|
||||
if (!externalId) {
|
||||
showToast('Please enter an external track ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateExternalMapping(externalId, provider)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById('external-map-save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
await API.saveTrackMapping(playlistName, {
|
||||
position,
|
||||
spotifyId,
|
||||
externalId,
|
||||
externalProvider: provider,
|
||||
type: 'external'
|
||||
});
|
||||
|
||||
showToast('✓ External mapping saved successfully', 'success');
|
||||
closeModal('external-map-modal');
|
||||
|
||||
if (window.fetchPlaylists) window.fetchPlaylists();
|
||||
if (window.fetchTrackMappings) window.fetchTrackMappings();
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Failed to save mapping', 'error');
|
||||
} finally {
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract Jellyfin ID from URL or raw ID
|
||||
export function extractJellyfinId() {
|
||||
const input = document.getElementById('manual-map-jellyfin-url').value.trim();
|
||||
if (!input) return;
|
||||
|
||||
let jellyfinId = '';
|
||||
|
||||
if (input.includes('/')) {
|
||||
const match = input.match(/[a-f0-9]{32}/i);
|
||||
if (match) {
|
||||
jellyfinId = match[0];
|
||||
}
|
||||
} else if (/^[a-f0-9]{32}$/i.test(input)) {
|
||||
jellyfinId = input;
|
||||
}
|
||||
|
||||
if (jellyfinId) {
|
||||
document.getElementById('manual-map-jellyfin-id').value = jellyfinId;
|
||||
selectJellyfinTrack(jellyfinId);
|
||||
} else {
|
||||
showToast('Invalid Jellyfin ID or URL format', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate external mapping ID format
|
||||
export function validateExternalMapping(externalId, provider) {
|
||||
if (provider === 'squidwtf') {
|
||||
if (!/^https?:\/\//.test(externalId)) {
|
||||
showToast('SquidWTF requires a full URL (e.g., https://squid.wtf/music/...)', 'error');
|
||||
return false;
|
||||
}
|
||||
} else if (provider === 'deezer') {
|
||||
if (!/^\d+$/.test(externalId) && !externalId.startsWith('http')) {
|
||||
showToast('Deezer ID should be numeric or a full URL', 'error');
|
||||
return false;
|
||||
}
|
||||
} else if (provider === 'qobuz') {
|
||||
if (!externalId.includes('/') && !/^\d+$/.test(externalId)) {
|
||||
showToast('Qobuz ID format appears invalid', 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Open lyrics mapping modal
|
||||
export function openLyricsMap(artist, title, album, durationSeconds) {
|
||||
document.getElementById('lyrics-map-artist').textContent = artist;
|
||||
document.getElementById('lyrics-map-title').textContent = title;
|
||||
document.getElementById('lyrics-map-album').textContent = album || '(No album)';
|
||||
document.getElementById('lyrics-map-artist-value').value = artist;
|
||||
document.getElementById('lyrics-map-title-value').value = title;
|
||||
document.getElementById('lyrics-map-album-value').value = album || '';
|
||||
document.getElementById('lyrics-map-duration').value = durationSeconds;
|
||||
document.getElementById('lyrics-map-id').value = '';
|
||||
|
||||
openModal('lyrics-map-modal');
|
||||
}
|
||||
|
||||
// Save lyrics mapping
|
||||
export async function saveLyricsMapping() {
|
||||
const artist = document.getElementById('lyrics-map-artist-value').value;
|
||||
const title = document.getElementById('lyrics-map-title-value').value;
|
||||
const album = document.getElementById('lyrics-map-album-value').value;
|
||||
const durationSeconds = parseInt(document.getElementById('lyrics-map-duration').value);
|
||||
const lyricsId = parseInt(document.getElementById('lyrics-map-id').value);
|
||||
|
||||
if (!lyricsId || lyricsId <= 0) {
|
||||
showToast('Please enter a valid lyrics ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById('lyrics-map-save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const data = await API.saveLyricsMapping(artist, title, album, durationSeconds, lyricsId);
|
||||
|
||||
if (data.cached && data.lyrics) {
|
||||
showToast(`✓ Lyrics mapped and cached: ${data.lyrics.trackName} by ${data.lyrics.artistName}`, 'success', 5000);
|
||||
} else {
|
||||
showToast('✓ Lyrics mapping saved successfully', 'success');
|
||||
}
|
||||
closeModal('lyrics-map-modal');
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Failed to save lyrics mapping', 'error');
|
||||
} finally {
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search provider (open in new tab)
|
||||
export async function searchProvider(query, provider) {
|
||||
try {
|
||||
const data = await API.getSquidWTFBaseUrl();
|
||||
const baseUrl = data.squidWtfBaseUrl || 'https://squid.wtf';
|
||||
const searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
|
||||
window.open(searchUrl, '_blank');
|
||||
} catch (error) {
|
||||
console.error('Failed to get SquidWTF base URL:', error);
|
||||
window.open(`https://squid.wtf/music/search?q=${encodeURIComponent(query)}`, '_blank');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,890 @@
|
||||
// Main entry point - ES6 modules
|
||||
|
||||
import { escapeHtml, escapeJs, showToast, formatCookieAge, capitalizeProvider } from './utils.js';
|
||||
import * as API from './api.js';
|
||||
import * as UI from './ui.js';
|
||||
import { openModal, closeModal, setupModalBackdropClose } from './modals.js';
|
||||
import { viewTracks, openManualMap, openExternalMap, searchJellyfinTracks, selectJellyfinTrack, saveLocalMapping, saveManualMapping, extractJellyfinId, validateExternalMapping, openLyricsMap, saveLyricsMapping, searchProvider } from './helpers.js';
|
||||
|
||||
// Global state
|
||||
let currentEditKey = null;
|
||||
let currentEditType = null;
|
||||
let currentEditOptions = null;
|
||||
let cookieDateInitialized = false;
|
||||
let restartRequired = false;
|
||||
let playlistAutoRefreshInterval = null;
|
||||
let currentLinkMode = 'select';
|
||||
let spotifyUserPlaylists = [];
|
||||
|
||||
// Make functions globally available for onclick handlers
|
||||
window.showToast = showToast;
|
||||
window.escapeHtml = escapeHtml;
|
||||
window.escapeJs = escapeJs;
|
||||
window.openModal = openModal;
|
||||
window.closeModal = closeModal;
|
||||
window.capitalizeProvider = capitalizeProvider;
|
||||
|
||||
// Restart banner
|
||||
window.showRestartBanner = function() {
|
||||
restartRequired = true;
|
||||
document.getElementById('restart-banner').classList.add('active');
|
||||
};
|
||||
|
||||
window.dismissRestartBanner = function() {
|
||||
document.getElementById('restart-banner').classList.remove('active');
|
||||
};
|
||||
|
||||
// Tab switching
|
||||
window.switchTab = function(tabName) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
||||
const content = document.getElementById('tab-' + tabName);
|
||||
|
||||
if (tab && content) {
|
||||
tab.classList.add('active');
|
||||
content.classList.add('active');
|
||||
window.location.hash = tabName;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize cookie date
|
||||
async function initCookieDate() {
|
||||
if (cookieDateInitialized) {
|
||||
console.log('Cookie date already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
cookieDateInitialized = true;
|
||||
|
||||
try {
|
||||
await API.initCookieDate();
|
||||
console.log('Cookie date initialized successfully - restart container to apply');
|
||||
showToast('Cookie date set. Restart container to apply changes.', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to init cookie date:', error);
|
||||
cookieDateInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and update status
|
||||
window.fetchStatus = async function() {
|
||||
try {
|
||||
const data = await API.fetchStatus();
|
||||
UI.updateStatusUI(data);
|
||||
|
||||
// Update cookie age
|
||||
const cookieAgeEl = document.getElementById('spotify-cookie-age');
|
||||
if (cookieAgeEl) {
|
||||
const hasCookie = data.spotify.hasCookie;
|
||||
const age = formatCookieAge(data.spotify.cookieSetDate, hasCookie);
|
||||
cookieAgeEl.innerHTML = `<span class="${age.class}">${age.text}</span><br><small style="color:var(--text-secondary)">${age.detail}</small>`;
|
||||
|
||||
if (age.needsInit) {
|
||||
console.log('Cookie exists but date not set, initializing...');
|
||||
initCookieDate();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch status:', error);
|
||||
showToast('Failed to fetch status: ' + error.message, 'error');
|
||||
UI.showErrorState(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch playlists
|
||||
window.fetchPlaylists = async function(silent = false) {
|
||||
try {
|
||||
const data = await API.fetchPlaylists();
|
||||
UI.updatePlaylistsUI(data);
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
console.error('Failed to fetch playlists:', error);
|
||||
showToast('Failed to fetch playlists', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch track mappings
|
||||
window.fetchTrackMappings = async function() {
|
||||
try {
|
||||
const data = await API.fetchTrackMappings();
|
||||
UI.updateTrackMappingsUI(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch track mappings:', error);
|
||||
showToast('Failed to fetch track mappings', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete track mapping
|
||||
window.deleteTrackMapping = async function(playlist, spotifyId) {
|
||||
if (!confirm(`Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\n• The track may be re-matched with potentially better results\n\nThis action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.deleteTrackMapping(playlist, spotifyId);
|
||||
showToast('Mapping removed successfully', 'success');
|
||||
await window.fetchTrackMappings();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete mapping:', error);
|
||||
showToast(error.message || 'Failed to remove mapping', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch missing tracks
|
||||
window.fetchMissingTracks = async function() {
|
||||
try {
|
||||
const data = await API.fetchPlaylists();
|
||||
const tbody = document.getElementById('missing-tracks-table-body');
|
||||
const missingTracks = [];
|
||||
|
||||
// Collect all missing tracks from all playlists
|
||||
for (const playlist of data.playlists) {
|
||||
if (playlist.externalMissing > 0) {
|
||||
try {
|
||||
const tracksData = await API.fetchPlaylistTracks(playlist.name);
|
||||
const missing = tracksData.tracks.filter(t => t.isLocal === null);
|
||||
missing.forEach(t => {
|
||||
missingTracks.push({
|
||||
playlist: playlist.name,
|
||||
...t
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch tracks for ${playlist.name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update summary
|
||||
document.getElementById('missing-total').textContent = missingTracks.length;
|
||||
|
||||
if (missingTracks.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">🎉 No missing tracks! All tracks are matched.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = missingTracks.map(t => {
|
||||
const artist = (t.artists && t.artists.length > 0) ? t.artists.join(', ') : '';
|
||||
const searchQuery = `${t.title} ${artist}`;
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(t.playlist)}</strong></td>
|
||||
<td>${escapeHtml(t.title)}</td>
|
||||
<td>${escapeHtml(artist)}</td>
|
||||
<td style="color:var(--text-secondary);">${t.album ? escapeHtml(t.album) : '-'}</td>
|
||||
<td>
|
||||
<button onclick="searchProvider('${escapeJs(searchQuery)}', 'squidwtf')"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">🔍 Search</button>
|
||||
<button onclick="openMapToLocal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--success);border-color:var(--success);">Map to Local</button>
|
||||
<button onclick="openMapToExternal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
|
||||
style="font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch missing tracks:', error);
|
||||
showToast('Failed to fetch missing tracks', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch downloads
|
||||
window.fetchDownloads = async function() {
|
||||
try {
|
||||
const data = await API.fetchDownloads();
|
||||
const tbody = document.getElementById('downloads-table-body');
|
||||
|
||||
document.getElementById('downloads-count').textContent = data.count;
|
||||
document.getElementById('downloads-size').textContent = data.totalSizeFormatted;
|
||||
|
||||
if (data.count === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.files.map(f => {
|
||||
return `
|
||||
<tr data-path="${escapeHtml(f.path)}">
|
||||
<td><strong>${escapeHtml(f.artist)}</strong></td>
|
||||
<td>${escapeHtml(f.album)}</td>
|
||||
<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)}')"
|
||||
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)}')"
|
||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch downloads:', error);
|
||||
showToast('Failed to fetch downloads', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.downloadFile = function(path) {
|
||||
try {
|
||||
window.open(`/api/admin/downloads/file?path=${encodeURIComponent(path)}`, '_blank');
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error);
|
||||
showToast('Failed to download file', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.deleteDownload = async function(path) {
|
||||
if (!confirm(`Delete this file?\n\n${path}\n\nThis action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.deleteDownload(path);
|
||||
showToast('File deleted successfully', 'success');
|
||||
|
||||
const escapedPath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const row = document.querySelector(`tr[data-path="${escapedPath}"]`);
|
||||
if (row) row.remove();
|
||||
|
||||
await window.fetchDownloads();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error);
|
||||
showToast(error.message || 'Failed to delete file', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch config
|
||||
window.fetchConfig = async function() {
|
||||
try {
|
||||
const data = await API.fetchConfig();
|
||||
UI.updateConfigUI(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch Jellyfin playlists
|
||||
window.fetchJellyfinPlaylists = async function() {
|
||||
const tbody = document.getElementById('jellyfin-playlist-table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
||||
|
||||
try {
|
||||
const userId = document.getElementById('jellyfin-user-select')?.value;
|
||||
const data = await API.fetchJellyfinPlaylists(userId);
|
||||
UI.updateJellyfinPlaylistsUI(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Jellyfin playlists:', error);
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--error);padding:40px;">Failed to fetch playlists</td></tr>';
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch Jellyfin users
|
||||
window.fetchJellyfinUsers = async function() {
|
||||
try {
|
||||
const data = await API.fetchJellyfinUsers();
|
||||
if (data) {
|
||||
UI.updateJellyfinUsersUI(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh playlists
|
||||
window.refreshPlaylists = async function() {
|
||||
try {
|
||||
showToast('Refreshing playlists...', 'success');
|
||||
const data = await API.refreshPlaylists();
|
||||
showToast(data.message, 'success');
|
||||
setTimeout(window.fetchPlaylists, 2000);
|
||||
} catch (error) {
|
||||
showToast('Failed to refresh playlists', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Clear playlist cache
|
||||
window.clearPlaylistCache = async function(name) {
|
||||
if (!confirm(`Rebuild "${name}" from scratch?\n\nThis will:\n• Fetch fresh Spotify playlist data\n• Clear all caches\n• Re-match all tracks\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`)) return;
|
||||
|
||||
try {
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
showToast(`Rebuilding ${name} from scratch...`, 'info');
|
||||
const data = await API.clearPlaylistCache(name);
|
||||
showToast(`✓ ${data.message}`, 'success', 5000);
|
||||
UI.showPlaylistRebuildingIndicator(name);
|
||||
setTimeout(() => {
|
||||
window.fetchPlaylists();
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
showToast('Failed to clear cache', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// Match playlist tracks
|
||||
window.matchPlaylistTracks = async function(name) {
|
||||
try {
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
showToast(`Re-matching local tracks for ${name}...`, 'info');
|
||||
const data = await API.matchPlaylistTracks(name);
|
||||
showToast(`✓ ${data.message}`, 'success');
|
||||
setTimeout(() => {
|
||||
window.fetchPlaylists();
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
showToast('Failed to re-match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// Match all playlists
|
||||
window.matchAllPlaylists = async function() {
|
||||
if (!confirm('Re-match local tracks for ALL playlists?\n\nUse this when your local library has changed.\n\nThis may take a few minutes.')) return;
|
||||
|
||||
try {
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
showToast('Matching tracks for all playlists...', 'success');
|
||||
const data = await API.matchAllPlaylists();
|
||||
showToast(`✓ ${data.message}`, 'success');
|
||||
setTimeout(() => {
|
||||
window.fetchPlaylists();
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
showToast('Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh and match all
|
||||
window.refreshAndMatchAll = async function() {
|
||||
if (!confirm('Clear caches, refresh from Spotify, and match all tracks?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Match all tracks against local library and external providers\n\nThis may take several minutes.')) return;
|
||||
|
||||
try {
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
showToast('Starting full refresh and match...', 'info', 3000);
|
||||
|
||||
showToast('Step 1/3: Clearing caches...', 'info', 2000);
|
||||
await API.clearCache();
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
showToast('Step 2/3: Fetching from Spotify...', 'info', 2000);
|
||||
await API.refreshPlaylists();
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
showToast('Step 3/3: Matching all tracks (this may take several minutes)...', 'info', 3000);
|
||||
const data = await API.matchAllPlaylists();
|
||||
showToast(`✓ Full refresh and match complete!`, 'success', 5000);
|
||||
|
||||
setTimeout(() => {
|
||||
window.fetchPlaylists();
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
showToast('Failed to complete refresh and match', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// Clear cache
|
||||
window.clearCache = async function() {
|
||||
if (!confirm('Clear all cached playlist data?')) return;
|
||||
|
||||
try {
|
||||
const data = await API.clearCache();
|
||||
showToast(data.message, 'success');
|
||||
window.fetchPlaylists();
|
||||
} catch (error) {
|
||||
showToast('Failed to clear cache', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Export/Import env
|
||||
window.exportEnv = async function() {
|
||||
try {
|
||||
const blob = await API.exportEnv();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `.env.backup.${new Date().toISOString().split('T')[0]}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
showToast('.env file exported successfully', 'success');
|
||||
} catch (error) {
|
||||
showToast('Failed to export .env file', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.importEnv = async function(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!confirm('Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.')) {
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await API.importEnv(file);
|
||||
showToast(data.message, 'success');
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Failed to import .env file', 'error');
|
||||
}
|
||||
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
// Restart container
|
||||
window.restartContainer = async function() {
|
||||
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.restartContainer();
|
||||
document.getElementById('restart-overlay').classList.add('active');
|
||||
document.getElementById('restart-status').textContent = 'Stopping container...';
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('restart-status').textContent = 'Waiting for server to come back...';
|
||||
checkServerAndReload();
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
showToast('Failed to restart container', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
async function checkServerAndReload() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60;
|
||||
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/status', {
|
||||
method: 'GET',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (res.ok) {
|
||||
document.getElementById('restart-status').textContent = 'Server is back! Reloading...';
|
||||
window.dismissRestartBanner();
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Server still restarting
|
||||
}
|
||||
|
||||
attempts++;
|
||||
document.getElementById('restart-status').textContent = `Waiting for server to come back... (${attempts}s)`;
|
||||
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(checkHealth, 1000);
|
||||
} else {
|
||||
document.getElementById('restart-overlay').classList.remove('active');
|
||||
showToast('Server may still be restarting. Please refresh manually.', 'warning');
|
||||
}
|
||||
};
|
||||
|
||||
checkHealth();
|
||||
}
|
||||
|
||||
// Link mode switching
|
||||
window.switchLinkMode = function(mode) {
|
||||
currentLinkMode = mode;
|
||||
|
||||
const selectGroup = document.getElementById('link-select-group');
|
||||
const manualGroup = document.getElementById('link-manual-group');
|
||||
const selectBtn = document.getElementById('select-mode-btn');
|
||||
const manualBtn = document.getElementById('manual-mode-btn');
|
||||
|
||||
if (mode === 'select') {
|
||||
selectGroup.style.display = 'block';
|
||||
manualGroup.style.display = 'none';
|
||||
selectBtn.classList.add('primary');
|
||||
manualBtn.classList.remove('primary');
|
||||
} else {
|
||||
selectGroup.style.display = 'none';
|
||||
manualGroup.style.display = 'block';
|
||||
selectBtn.classList.remove('primary');
|
||||
manualBtn.classList.add('primary');
|
||||
}
|
||||
};
|
||||
|
||||
// Open link playlist modal
|
||||
window.openLinkPlaylist = async function(jellyfinId, name) {
|
||||
document.getElementById('link-jellyfin-id').value = jellyfinId;
|
||||
document.getElementById('link-jellyfin-name').value = name;
|
||||
document.getElementById('link-spotify-id').value = '';
|
||||
|
||||
window.switchLinkMode('select');
|
||||
|
||||
if (spotifyUserPlaylists.length === 0) {
|
||||
const select = document.getElementById('link-spotify-select');
|
||||
select.innerHTML = '<option value="">Loading playlists...</option>';
|
||||
|
||||
try {
|
||||
spotifyUserPlaylists = await API.fetchSpotifyUserPlaylists();
|
||||
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
|
||||
|
||||
if (availablePlaylists.length === 0) {
|
||||
select.innerHTML = '<option value="">No playlists available</option>';
|
||||
window.switchLinkMode('manual');
|
||||
} else {
|
||||
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
|
||||
availablePlaylists.map(p =>
|
||||
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
|
||||
).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
select.innerHTML = '<option value="">Failed to load playlists</option>';
|
||||
window.switchLinkMode('manual');
|
||||
}
|
||||
}
|
||||
|
||||
openModal('link-playlist-modal');
|
||||
};
|
||||
|
||||
// Link playlist
|
||||
window.linkPlaylist = async function() {
|
||||
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
||||
const name = document.getElementById('link-jellyfin-name').value;
|
||||
const syncSchedule = document.getElementById('link-sync-schedule').value.trim();
|
||||
|
||||
if (!syncSchedule) {
|
||||
showToast('Sync schedule is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const cronParts = syncSchedule.split(/\s+/);
|
||||
if (cronParts.length !== 5) {
|
||||
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let spotifyId = '';
|
||||
if (currentLinkMode === 'select') {
|
||||
spotifyId = document.getElementById('link-spotify-select').value;
|
||||
if (!spotifyId) {
|
||||
showToast('Please select a Spotify playlist', 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
spotifyId = document.getElementById('link-spotify-id').value.trim();
|
||||
if (!spotifyId) {
|
||||
showToast('Spotify Playlist ID is required', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean Spotify ID
|
||||
let cleanSpotifyId = spotifyId;
|
||||
if (spotifyId.startsWith('spotify:playlist:')) {
|
||||
cleanSpotifyId = spotifyId.replace('spotify:playlist:', '');
|
||||
} else if (spotifyId.includes('spotify.com/playlist/')) {
|
||||
const match = spotifyId.match(/playlist\/([a-zA-Z0-9]+)/);
|
||||
if (match) cleanSpotifyId = match[1];
|
||||
}
|
||||
cleanSpotifyId = cleanSpotifyId.split('?')[0].split('#')[0].replace(/\/$/, '');
|
||||
|
||||
try {
|
||||
await API.linkPlaylist(jellyfinId, cleanSpotifyId, syncSchedule);
|
||||
showToast('Playlist linked!', 'success');
|
||||
window.showRestartBanner();
|
||||
closeModal('link-playlist-modal');
|
||||
spotifyUserPlaylists = [];
|
||||
window.fetchPlaylists();
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Failed to link playlist', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Unlink playlist
|
||||
window.unlinkPlaylist = async function(name) {
|
||||
if (!confirm(`Unlink playlist "${name}"? This will stop filling in missing tracks.`)) return;
|
||||
|
||||
try {
|
||||
await API.unlinkPlaylist(name);
|
||||
showToast('Playlist unlinked.', 'success');
|
||||
window.showRestartBanner();
|
||||
spotifyUserPlaylists = [];
|
||||
window.fetchPlaylists();
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Failed to unlink playlist', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Add playlist
|
||||
window.openAddPlaylist = function() {
|
||||
document.getElementById('new-playlist-name').value = '';
|
||||
document.getElementById('new-playlist-id').value = '';
|
||||
openModal('add-playlist-modal');
|
||||
};
|
||||
|
||||
window.addPlaylist = async function() {
|
||||
const name = document.getElementById('new-playlist-name').value.trim();
|
||||
const id = document.getElementById('new-playlist-id').value.trim();
|
||||
|
||||
if (!name || !id) {
|
||||
showToast('Name and ID are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.addPlaylist(name, id);
|
||||
showToast('Playlist added.', 'success');
|
||||
window.showRestartBanner();
|
||||
closeModal('add-playlist-modal');
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Failed to add playlist', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Edit playlist schedule
|
||||
window.editPlaylistSchedule = async function(playlistName, currentSchedule) {
|
||||
const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * * = Daily 8 AM\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM\n\nUse https://crontab.guru/ to build your schedule`, currentSchedule);
|
||||
|
||||
if (!newSchedule || newSchedule === currentSchedule) return;
|
||||
|
||||
const cronParts = newSchedule.trim().split(/\s+/);
|
||||
if (cronParts.length !== 5) {
|
||||
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.editPlaylistSchedule(playlistName, newSchedule.trim());
|
||||
showToast('Sync schedule updated!', 'success');
|
||||
window.showRestartBanner();
|
||||
window.fetchPlaylists();
|
||||
} catch (error) {
|
||||
console.error('Failed to update schedule:', error);
|
||||
showToast(error.message || 'Failed to update schedule', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Remove playlist
|
||||
window.removePlaylist = async function(name) {
|
||||
if (!confirm(`Remove playlist "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
await API.removePlaylist(name);
|
||||
showToast('Playlist removed.', 'success');
|
||||
window.showRestartBanner();
|
||||
window.fetchPlaylists();
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Failed to remove playlist', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// View tracks
|
||||
window.viewTracks = viewTracks;
|
||||
|
||||
// Manual mapping functions
|
||||
window.openManualMap = openManualMap;
|
||||
window.openExternalMap = openExternalMap;
|
||||
window.searchJellyfinTracks = searchJellyfinTracks;
|
||||
window.selectJellyfinTrack = selectJellyfinTrack;
|
||||
window.saveLocalMapping = saveLocalMapping;
|
||||
window.saveManualMapping = saveManualMapping;
|
||||
window.extractJellyfinId = extractJellyfinId;
|
||||
window.validateExternalMapping = validateExternalMapping;
|
||||
|
||||
// Lyrics mapping
|
||||
window.openLyricsMap = openLyricsMap;
|
||||
window.saveLyricsMapping = saveLyricsMapping;
|
||||
|
||||
// Search provider
|
||||
window.searchProvider = searchProvider;
|
||||
|
||||
// Settings editing
|
||||
window.openEditSetting = function(envKey, label, inputType, helpText = '', options = []) {
|
||||
currentEditKey = envKey;
|
||||
currentEditType = inputType;
|
||||
currentEditOptions = options;
|
||||
|
||||
document.getElementById('edit-setting-title').textContent = 'Edit ' + label;
|
||||
document.getElementById('edit-setting-label').textContent = label;
|
||||
|
||||
const helpEl = document.getElementById('edit-setting-help');
|
||||
if (helpText) {
|
||||
helpEl.textContent = helpText;
|
||||
helpEl.style.display = 'block';
|
||||
} else {
|
||||
helpEl.style.display = 'none';
|
||||
}
|
||||
|
||||
const container = document.getElementById('edit-setting-input-container');
|
||||
|
||||
if (inputType === 'toggle') {
|
||||
container.innerHTML = `
|
||||
<select id="edit-setting-value">
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
`;
|
||||
} else if (inputType === 'select') {
|
||||
container.innerHTML = `
|
||||
<select id="edit-setting-value">
|
||||
${options.map(opt => `<option value="${opt}">${opt}</option>`).join('')}
|
||||
</select>
|
||||
`;
|
||||
} else if (inputType === 'password') {
|
||||
container.innerHTML = `<input type="password" id="edit-setting-value" placeholder="Enter new value" autocomplete="off">`;
|
||||
} else if (inputType === 'number') {
|
||||
container.innerHTML = `<input type="number" id="edit-setting-value" placeholder="Enter value">`;
|
||||
} else {
|
||||
container.innerHTML = `<input type="text" id="edit-setting-value" placeholder="Enter value">`;
|
||||
}
|
||||
|
||||
openModal('edit-setting-modal');
|
||||
};
|
||||
|
||||
window.openEditCacheSetting = function(settingKey, label, helpText) {
|
||||
currentEditKey = settingKey;
|
||||
currentEditType = 'number';
|
||||
|
||||
document.getElementById('edit-setting-title').textContent = 'Edit ' + label;
|
||||
document.getElementById('edit-setting-label').textContent = label;
|
||||
|
||||
const helpEl = document.getElementById('edit-setting-help');
|
||||
if (helpText) {
|
||||
helpEl.textContent = helpText + ' (Requires restart to apply)';
|
||||
helpEl.style.display = 'block';
|
||||
} else {
|
||||
helpEl.style.display = 'none';
|
||||
}
|
||||
|
||||
const container = document.getElementById('edit-setting-input-container');
|
||||
container.innerHTML = `<input type="number" id="edit-setting-value" placeholder="Enter value" min="1">`;
|
||||
|
||||
openModal('edit-setting-modal');
|
||||
};
|
||||
|
||||
window.saveEditSetting = async function() {
|
||||
const value = document.getElementById('edit-setting-value').value.trim();
|
||||
|
||||
if (!value && currentEditType !== 'toggle') {
|
||||
showToast('Value is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.updateConfigSetting(currentEditKey, value);
|
||||
showToast('Setting updated.', 'success');
|
||||
window.showRestartBanner();
|
||||
closeModal('edit-setting-modal');
|
||||
window.fetchConfig();
|
||||
window.fetchStatus();
|
||||
} catch (error) {
|
||||
showToast(error.message || 'Failed to update setting', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Endpoint usage
|
||||
window.fetchEndpointUsage = async function() {
|
||||
try {
|
||||
const topSelect = document.getElementById('endpoints-top-select');
|
||||
const top = topSelect ? topSelect.value : 50;
|
||||
const data = await API.fetchEndpointUsage(top);
|
||||
UI.updateEndpointUsageUI(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch endpoint usage:', error);
|
||||
const tbody = document.getElementById('endpoints-table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--error);padding:40px;">Failed to load endpoint usage data</td></tr>';
|
||||
}
|
||||
};
|
||||
|
||||
window.clearEndpointUsage = async function() {
|
||||
if (!confirm('Are you sure you want to clear all endpoint usage data? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await API.clearEndpointUsage();
|
||||
showToast(data.message || 'Endpoint usage data cleared', 'success');
|
||||
window.fetchEndpointUsage();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear endpoint usage:', error);
|
||||
showToast('Failed to clear endpoint usage data', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-refresh functionality
|
||||
function startPlaylistAutoRefresh() {
|
||||
if (playlistAutoRefreshInterval) {
|
||||
clearInterval(playlistAutoRefreshInterval);
|
||||
}
|
||||
|
||||
playlistAutoRefreshInterval = setInterval(() => {
|
||||
const playlistsTab = document.getElementById('tab-playlists');
|
||||
if (playlistsTab && playlistsTab.classList.contains('active')) {
|
||||
window.fetchPlaylists(true);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function stopPlaylistAutoRefresh() {
|
||||
if (playlistAutoRefreshInterval) {
|
||||
clearInterval(playlistAutoRefreshInterval);
|
||||
playlistAutoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('🚀 Allstarr Admin UI (Modular) loaded');
|
||||
|
||||
// Setup tab switching
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
window.switchTab(tab.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
// Restore tab from URL hash
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
window.switchTab(hash);
|
||||
}
|
||||
|
||||
// Setup modal backdrop close
|
||||
setupModalBackdropClose();
|
||||
|
||||
// Initial data load
|
||||
window.fetchStatus();
|
||||
window.fetchPlaylists();
|
||||
window.fetchTrackMappings();
|
||||
window.fetchMissingTracks();
|
||||
window.fetchDownloads();
|
||||
window.fetchJellyfinUsers();
|
||||
window.fetchJellyfinPlaylists();
|
||||
window.fetchConfig();
|
||||
window.fetchEndpointUsage();
|
||||
|
||||
// Start auto-refresh
|
||||
startPlaylistAutoRefresh();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(() => {
|
||||
window.fetchStatus();
|
||||
window.fetchPlaylists();
|
||||
window.fetchTrackMappings();
|
||||
window.fetchMissingTracks();
|
||||
window.fetchDownloads();
|
||||
|
||||
const endpointsTab = document.getElementById('tab-endpoints');
|
||||
if (endpointsTab && endpointsTab.classList.contains('active')) {
|
||||
window.fetchEndpointUsage();
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
console.log('✅ Main.js module loaded');
|
||||
@@ -0,0 +1,17 @@
|
||||
// Modal management
|
||||
|
||||
export function openModal(id) {
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
|
||||
export function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('active');
|
||||
}
|
||||
|
||||
export function setupModalBackdropClose() {
|
||||
document.querySelectorAll('.modal').forEach(modal => {
|
||||
modal.addEventListener('click', e => {
|
||||
if (e.target === modal) closeModal(modal.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// UI updates and DOM manipulation
|
||||
|
||||
import { escapeHtml, escapeJs, capitalizeProvider } from './utils.js';
|
||||
|
||||
export function updateStatusUI(data) {
|
||||
const versionEl = document.getElementById('version');
|
||||
if (versionEl) versionEl.textContent = 'v' + data.version;
|
||||
|
||||
const backendTypeEl = document.getElementById('backend-type');
|
||||
if (backendTypeEl) backendTypeEl.textContent = data.backendType;
|
||||
|
||||
const jellyfinUrlEl = document.getElementById('jellyfin-url');
|
||||
if (jellyfinUrlEl) jellyfinUrlEl.textContent = data.jellyfinUrl || '-';
|
||||
|
||||
const playlistCountEl = document.getElementById('playlist-count');
|
||||
if (playlistCountEl) playlistCountEl.textContent = data.spotifyImport.playlistCount;
|
||||
|
||||
const cacheDurationEl = document.getElementById('cache-duration');
|
||||
if (cacheDurationEl) cacheDurationEl.textContent = data.spotify.cacheDurationMinutes + ' min';
|
||||
|
||||
const isrcMatchingEl = document.getElementById('isrc-matching');
|
||||
if (isrcMatchingEl) isrcMatchingEl.textContent = data.spotify.preferIsrcMatching ? 'Enabled' : 'Disabled';
|
||||
|
||||
const spotifyUserEl = document.getElementById('spotify-user');
|
||||
if (spotifyUserEl) spotifyUserEl.textContent = data.spotify.user || '-';
|
||||
|
||||
const statusBadge = document.getElementById('spotify-status');
|
||||
const authStatus = document.getElementById('spotify-auth-status');
|
||||
|
||||
if (data.spotify.authStatus === 'configured') {
|
||||
if (statusBadge) {
|
||||
statusBadge.className = 'status-badge success';
|
||||
statusBadge.innerHTML = '<span class="status-dot"></span>Spotify Ready';
|
||||
}
|
||||
if (authStatus) {
|
||||
authStatus.textContent = 'Cookie Set';
|
||||
authStatus.className = 'stat-value success';
|
||||
}
|
||||
} else if (data.spotify.authStatus === 'missing_cookie') {
|
||||
if (statusBadge) {
|
||||
statusBadge.className = 'status-badge warning';
|
||||
statusBadge.innerHTML = '<span class="status-dot"></span>Cookie Missing';
|
||||
}
|
||||
if (authStatus) {
|
||||
authStatus.textContent = 'No Cookie';
|
||||
authStatus.className = 'stat-value warning';
|
||||
}
|
||||
} else {
|
||||
if (statusBadge) {
|
||||
statusBadge.className = 'status-badge';
|
||||
statusBadge.innerHTML = '<span class="status-dot"></span>Not Configured';
|
||||
}
|
||||
if (authStatus) {
|
||||
authStatus.textContent = 'Not Configured';
|
||||
authStatus.className = 'stat-value';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updatePlaylistsUI(data) {
|
||||
const tbody = document.getElementById('playlist-table-body');
|
||||
|
||||
if (data.playlists.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.playlists.map(p => {
|
||||
const spotifyTotal = p.trackCount || 0;
|
||||
const localCount = p.localTracks || 0;
|
||||
const externalMatched = p.externalMatched || 0;
|
||||
const externalMissing = p.externalMissing || 0;
|
||||
const totalPlayable = p.totalPlayable || (localCount + externalMatched);
|
||||
|
||||
let statsHtml = `<span class="track-count">${totalPlayable}/${spotifyTotal}</span>`;
|
||||
|
||||
let breakdownParts = [];
|
||||
if (localCount > 0) {
|
||||
breakdownParts.push(`<span style="color:var(--success)">${localCount} Local</span>`);
|
||||
}
|
||||
if (externalMatched > 0) {
|
||||
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} External</span>`);
|
||||
}
|
||||
if (externalMissing > 0) {
|
||||
breakdownParts.push(`<span style="color:var(--warning)">${externalMissing} Missing</span>`);
|
||||
}
|
||||
|
||||
const breakdown = breakdownParts.length > 0
|
||||
? `<br><small style="color:var(--text-secondary)">${breakdownParts.join(' • ')}</small>`
|
||||
: '';
|
||||
|
||||
const completionPct = spotifyTotal > 0 ? Math.round((totalPlayable / spotifyTotal) * 100) : 0;
|
||||
const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0;
|
||||
const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0;
|
||||
const missingPct = spotifyTotal > 0 ? Math.round((externalMissing / spotifyTotal) * 100) : 0;
|
||||
const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)';
|
||||
|
||||
const syncSchedule = p.syncSchedule || '0 8 * * *';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;">
|
||||
${escapeHtml(syncSchedule)}
|
||||
<button onclick="editPlaylistSchedule('${escapeJs(p.name)}', '${escapeJs(syncSchedule)}')" style="margin-left:4px;font-size:0.75rem;padding:2px 6px;">Edit</button>
|
||||
</td>
|
||||
<td>${statsHtml}${breakdown}</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;">
|
||||
<div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div>
|
||||
<div style="width:${externalPct}%;height:100%;background:#3b82f6;transition:width 0.3s;" title="${externalMatched} external tracks"></div>
|
||||
<div style="width:${missingPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
|
||||
</div>
|
||||
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||
<td>
|
||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')" title="Re-match when local library changed">Re-match Local</button>
|
||||
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')" title="Rebuild when Spotify playlist changed" style="background:var(--accent);border-color:var(--accent);">Rebuild Remote</button>
|
||||
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
||||
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function updateTrackMappingsUI(data) {
|
||||
document.getElementById('mappings-total').textContent = data.externalCount || 0;
|
||||
document.getElementById('mappings-external').textContent = data.externalCount || 0;
|
||||
|
||||
const tbody = document.getElementById('mappings-table-body');
|
||||
|
||||
if (data.mappings.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No manual mappings found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const externalMappings = data.mappings.filter(m => m.type === 'external');
|
||||
|
||||
if (externalMappings.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No external mappings found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = externalMappings.map(m => {
|
||||
const typeColor = 'var(--success)';
|
||||
const typeBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">external</span>`;
|
||||
const targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
|
||||
const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : '-';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(m.playlist)}</strong></td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${m.spotifyId}</td>
|
||||
<td>${typeBadge}</td>
|
||||
<td>${targetDisplay}</td>
|
||||
<td style="color:var(--text-secondary);font-size:0.85rem;">${createdDate}</td>
|
||||
<td>
|
||||
<button class="danger delete-mapping-btn" style="padding:4px 12px;font-size:0.8rem;" data-playlist="${escapeHtml(m.playlist)}" data-spotify-id="${m.spotifyId}">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function updateDownloadsUI(data) {
|
||||
const tbody = document.getElementById('downloads-table-body');
|
||||
|
||||
document.getElementById('downloads-count').textContent = data.count;
|
||||
document.getElementById('downloads-size').textContent = data.totalSizeFormatted;
|
||||
|
||||
if (data.count === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.files.map(f => {
|
||||
return `
|
||||
<tr data-path="${escapeHtml(f.path)}">
|
||||
<td><strong>${escapeHtml(f.artist)}</strong></td>
|
||||
<td>${escapeHtml(f.album)}</td>
|
||||
<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)}')"
|
||||
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)}')"
|
||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function updateConfigUI(data) {
|
||||
document.getElementById('config-backend-type').textContent = data.backendType || 'Jellyfin';
|
||||
document.getElementById('config-music-service').textContent = data.musicService || 'SquidWTF';
|
||||
document.getElementById('config-storage-mode').textContent = data.library?.storageMode || 'Cache';
|
||||
document.getElementById('config-cache-duration-hours').textContent = data.library?.cacheDurationHours || '24';
|
||||
document.getElementById('config-download-mode').textContent = data.library?.downloadMode || 'Track';
|
||||
document.getElementById('config-explicit-filter').textContent = data.explicitFilter || 'All';
|
||||
document.getElementById('config-enable-external-playlists').textContent = data.enableExternalPlaylists ? 'Yes' : 'No';
|
||||
document.getElementById('config-playlists-directory').textContent = data.playlistsDirectory || '(not set)';
|
||||
document.getElementById('config-redis-enabled').textContent = data.redisEnabled ? 'Yes' : 'No';
|
||||
|
||||
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
||||
document.getElementById('config-cache-duration').textContent = data.spotifyApi.cacheDurationMinutes + ' minutes';
|
||||
document.getElementById('config-isrc-matching').textContent = data.spotifyApi.preferIsrcMatching ? 'Enabled' : 'Disabled';
|
||||
|
||||
document.getElementById('config-deezer-arl').textContent = data.deezer.arl || '(not set)';
|
||||
document.getElementById('config-deezer-quality').textContent = data.deezer.quality;
|
||||
document.getElementById('config-squid-quality').textContent = data.squidWtf.quality;
|
||||
document.getElementById('config-musicbrainz-enabled').textContent = data.musicBrainz.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
|
||||
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
|
||||
document.getElementById('config-jellyfin-url').textContent = data.jellyfin.url || '-';
|
||||
document.getElementById('config-jellyfin-api-key').textContent = data.jellyfin.apiKey;
|
||||
document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)';
|
||||
document.getElementById('config-jellyfin-library-id').textContent = data.jellyfin.libraryId || '-';
|
||||
document.getElementById('config-download-path').textContent = data.library?.downloadPath || './downloads';
|
||||
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
|
||||
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' hours';
|
||||
|
||||
if (data.cache) {
|
||||
document.getElementById('config-cache-playlist-images').textContent = data.cache.playlistImagesHours || '168';
|
||||
document.getElementById('config-cache-spotify-items').textContent = data.cache.spotifyPlaylistItemsHours || '168';
|
||||
document.getElementById('config-cache-matched-tracks').textContent = data.cache.spotifyMatchedTracksDays || '30';
|
||||
document.getElementById('config-cache-lyrics').textContent = data.cache.lyricsDays || '14';
|
||||
document.getElementById('config-cache-genres').textContent = data.cache.genreDays || '30';
|
||||
document.getElementById('config-cache-metadata').textContent = data.cache.metadataDays || '7';
|
||||
document.getElementById('config-cache-odesli').textContent = data.cache.odesliLookupDays || '60';
|
||||
document.getElementById('config-cache-proxy-images').textContent = data.cache.proxyImagesDays || '14';
|
||||
}
|
||||
}
|
||||
|
||||
export function updateJellyfinPlaylistsUI(data) {
|
||||
const tbody = document.getElementById('jellyfin-playlist-table-body');
|
||||
|
||||
if (data.playlists.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists found in Jellyfin</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.playlists.map(p => {
|
||||
const statusBadge = p.isConfigured
|
||||
? '<span class="status-badge success"><span class="status-dot"></span>Linked</span>'
|
||||
: '<span class="status-badge"><span class="status-dot"></span>Not Linked</span>';
|
||||
|
||||
const actionButton = p.isConfigured
|
||||
? `<button class="danger" onclick="unlinkPlaylist('${escapeJs(p.name)}')">Unlink</button>`
|
||||
: `<button class="primary" onclick="openLinkPlaylist('${escapeJs(p.id)}', '${escapeJs(p.name)}')">Link to Spotify</button>`;
|
||||
|
||||
const localCount = p.localTracks || 0;
|
||||
const externalCount = p.externalTracks || 0;
|
||||
const externalAvail = p.externalAvailable || 0;
|
||||
|
||||
return `
|
||||
<tr data-playlist-id="${escapeHtml(p.id)}">
|
||||
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||
<td class="track-count">${localCount}</td>
|
||||
<td class="track-count">${externalCount > 0 ? `${externalAvail}/${externalCount}` : '-'}</td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.linkedSpotifyId || '-'}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${actionButton}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function updateJellyfinUsersUI(data) {
|
||||
const select = document.getElementById('jellyfin-user-select');
|
||||
select.innerHTML = '<option value="">All Users</option>' +
|
||||
data.users.map(u => `<option value="${u.id}">${escapeHtml(u.name)}</option>`).join('');
|
||||
}
|
||||
|
||||
export function updateEndpointUsageUI(data) {
|
||||
document.getElementById('endpoints-total-requests').textContent = data.totalRequests?.toLocaleString() || '0';
|
||||
document.getElementById('endpoints-unique-count').textContent = data.totalEndpoints?.toLocaleString() || '0';
|
||||
|
||||
const mostCalled = data.endpoints && data.endpoints.length > 0
|
||||
? data.endpoints[0].endpoint
|
||||
: '-';
|
||||
document.getElementById('endpoints-most-called').textContent = mostCalled;
|
||||
|
||||
const tbody = document.getElementById('endpoints-table-body');
|
||||
|
||||
if (!data.endpoints || data.endpoints.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No endpoint usage data available yet.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.endpoints.map((ep, index) => {
|
||||
const percentage = data.totalRequests > 0
|
||||
? ((ep.count / data.totalRequests) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
|
||||
let countColor = 'var(--text-primary)';
|
||||
if (ep.count > 1000) countColor = 'var(--error)';
|
||||
else if (ep.count > 100) countColor = 'var(--warning)';
|
||||
else if (ep.count > 10) countColor = 'var(--accent)';
|
||||
|
||||
let endpointDisplay = ep.endpoint;
|
||||
if (ep.endpoint.includes('/stream')) {
|
||||
endpointDisplay = `<span style="color:var(--success)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else if (ep.endpoint.includes('/Playing')) {
|
||||
endpointDisplay = `<span style="color:var(--accent)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else if (ep.endpoint.includes('/Search')) {
|
||||
endpointDisplay = `<span style="color:var(--warning)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else {
|
||||
endpointDisplay = escapeHtml(ep.endpoint);
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="color:var(--text-secondary);text-align:center;">${index + 1}</td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${endpointDisplay}</td>
|
||||
<td style="text-align:right;font-weight:600;color:${countColor}">${ep.count.toLocaleString()}</td>
|
||||
<td style="text-align:right;color:var(--text-secondary)">${percentage}%</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function showErrorState(message) {
|
||||
const statusBadge = document.getElementById('spotify-status');
|
||||
if (statusBadge) {
|
||||
statusBadge.className = 'status-badge error';
|
||||
statusBadge.innerHTML = '<span class="status-dot"></span>Connection Error';
|
||||
}
|
||||
const authStatus = document.getElementById('spotify-auth-status');
|
||||
if (authStatus) authStatus.textContent = 'Error';
|
||||
}
|
||||
|
||||
export function showPlaylistRebuildingIndicator(playlistName) {
|
||||
const playlistCards = document.querySelectorAll('.playlist-card');
|
||||
for (const card of playlistCards) {
|
||||
const nameEl = card.querySelector('h3');
|
||||
if (nameEl && nameEl.textContent.trim() === playlistName) {
|
||||
const existingIndicator = card.querySelector('.rebuilding-indicator');
|
||||
if (!existingIndicator) {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'rebuilding-indicator';
|
||||
indicator.style.cssText = `
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: var(--warning);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 10;
|
||||
`;
|
||||
indicator.innerHTML = '<span class="spinner" style="width: 10px; height: 10px;"></span>Rebuilding...';
|
||||
card.style.position = 'relative';
|
||||
card.appendChild(indicator);
|
||||
|
||||
setTimeout(() => {
|
||||
indicator.remove();
|
||||
}, 30000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Utility functions
|
||||
|
||||
export function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export function escapeJs(text) {
|
||||
return text.replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
export function showToast(message, type = 'success', duration = 3000) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast ' + type;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), duration);
|
||||
}
|
||||
|
||||
export function formatCookieAge(setDateStr, hasCookie = false) {
|
||||
if (!setDateStr) {
|
||||
if (hasCookie) {
|
||||
return { text: 'Unknown age', class: 'warning', detail: 'Cookie date not tracked', needsInit: true };
|
||||
}
|
||||
return { text: 'No cookie', class: '', detail: '', needsInit: false };
|
||||
}
|
||||
|
||||
const setDate = new Date(setDateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - setDate;
|
||||
const daysAgo = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const monthsAgo = daysAgo / 30;
|
||||
|
||||
let status = 'success'; // green: < 6 months
|
||||
if (monthsAgo >= 10) status = 'error'; // red: > 10 months
|
||||
else if (monthsAgo >= 6) status = 'warning'; // yellow: 6-10 months
|
||||
|
||||
let text;
|
||||
if (daysAgo === 0) text = 'Set today';
|
||||
else if (daysAgo === 1) text = 'Set yesterday';
|
||||
else if (daysAgo < 30) text = `Set ${daysAgo} days ago`;
|
||||
else if (daysAgo < 60) text = 'Set ~1 month ago';
|
||||
else text = `Set ~${Math.floor(monthsAgo)} months ago`;
|
||||
|
||||
const remaining = 12 - monthsAgo;
|
||||
let detail;
|
||||
if (remaining > 6) detail = 'Cookie typically lasts ~1 year';
|
||||
else if (remaining > 2) detail = `~${Math.floor(remaining)} months until expiration`;
|
||||
else if (remaining > 0) detail = 'Cookie may expire soon!';
|
||||
else detail = 'Cookie may have expired - update if having issues';
|
||||
|
||||
return { text, class: status, detail, needsInit: false };
|
||||
}
|
||||
|
||||
export function capitalizeProvider(provider) {
|
||||
const providerMap = {
|
||||
'squidwtf': 'SquidWTF',
|
||||
'deezer': 'Deezer',
|
||||
'qobuz': 'Qobuz'
|
||||
};
|
||||
return providerMap[provider?.toLowerCase()] || provider;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Spotify Track Mappings - Allstarr</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--text-primary: #f0f6fc;
|
||||
--text-secondary: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--accent-hover: #79c0ff;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--error: #f85149;
|
||||
--border: #30363d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mappings-table {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-header h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.search-box:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 12px 20px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
th.sortable:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.track-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.track-artwork {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.track-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge.local {
|
||||
background: rgba(63, 185, 80, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge.external {
|
||||
background: rgba(88, 166, 255, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge.manual {
|
||||
background: rgba(210, 153, 34, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.badge.auto {
|
||||
background: rgba(139, 148, 158, 0.2);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination .page-info {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
border: 1px solid var(--error);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin: 20px 0;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
<script src="/spotify-mappings.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Spotify Track Mappings</h1>
|
||||
<a href="/" class="back-link">
|
||||
← Back to Dashboard
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="stats-grid" id="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Mappings</div>
|
||||
<div class="stat-value" id="stat-total">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Local Tracks</div>
|
||||
<div class="stat-value" id="stat-local">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">External Tracks</div>
|
||||
<div class="stat-value" id="stat-external">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Manual Mappings</div>
|
||||
<div class="stat-value" id="stat-manual">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Auto Mappings</div>
|
||||
<div class="stat-value" id="stat-auto">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mappings-table">
|
||||
<div class="table-header">
|
||||
<h2>All Mappings</h2>
|
||||
<div class="table-controls">
|
||||
<div class="filters">
|
||||
<select class="filter-select" id="filter-type">
|
||||
<option value="all">All Types</option>
|
||||
<option value="local">Local Only</option>
|
||||
<option value="external">External Only</option>
|
||||
</select>
|
||||
<select class="filter-select" id="filter-source">
|
||||
<option value="all">All Sources</option>
|
||||
<option value="manual">Manual Only</option>
|
||||
<option value="auto">Auto Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="text" class="search-box" placeholder="Search by title, artist, or Spotify ID..." id="search">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="content">
|
||||
<div class="loading">Loading mappings...</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination" id="pagination" style="display: none;">
|
||||
<button id="prev-btn">← Previous</button>
|
||||
<span class="page-info" id="page-info">Page 1 of 1</span>
|
||||
<button id="next-btn">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,362 @@
|
||||
// Spotify Mappings Page JavaScript
|
||||
// Handles filtering, sorting, pagination, and CRUD operations for Spotify track mappings
|
||||
|
||||
let currentPage = 1;
|
||||
const pageSize = 50;
|
||||
let currentFilters = {
|
||||
targetType: 'all',
|
||||
source: 'all',
|
||||
search: '',
|
||||
sortBy: null,
|
||||
sortOrder: 'asc'
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads mappings from the API with current filters and pagination
|
||||
*/
|
||||
async function loadMappings() {
|
||||
try {
|
||||
// Build query string with filters
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage,
|
||||
pageSize: pageSize,
|
||||
enrichMetadata: true
|
||||
});
|
||||
|
||||
if (currentFilters.targetType !== 'all') {
|
||||
params.append('targetType', currentFilters.targetType);
|
||||
}
|
||||
|
||||
if (currentFilters.source !== 'all') {
|
||||
params.append('source', currentFilters.source);
|
||||
}
|
||||
|
||||
if (currentFilters.search) {
|
||||
params.append('search', currentFilters.search);
|
||||
}
|
||||
|
||||
if (currentFilters.sortBy) {
|
||||
params.append('sortBy', currentFilters.sortBy);
|
||||
params.append('sortOrder', currentFilters.sortOrder);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/spotify/mappings?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to load mappings');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update stats (using PascalCase from C# API)
|
||||
document.getElementById('stat-total').textContent = data.stats.TotalMappings.toLocaleString();
|
||||
document.getElementById('stat-local').textContent = data.stats.LocalMappings.toLocaleString();
|
||||
document.getElementById('stat-external').textContent = data.stats.ExternalMappings.toLocaleString();
|
||||
document.getElementById('stat-manual').textContent = data.stats.ManualMappings.toLocaleString();
|
||||
document.getElementById('stat-auto').textContent = data.stats.AutoMappings.toLocaleString();
|
||||
|
||||
// Update pagination
|
||||
updatePagination(data.pagination);
|
||||
|
||||
// Render table
|
||||
renderMappings(data.mappings);
|
||||
} catch (error) {
|
||||
console.error('Error loading mappings:', error);
|
||||
document.getElementById('content').innerHTML =
|
||||
`<div class="error">Failed to load mappings: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates pagination controls
|
||||
*/
|
||||
function updatePagination(pagination) {
|
||||
document.getElementById('page-info').textContent =
|
||||
`Page ${pagination.page} of ${pagination.totalPages} (${pagination.totalCount} total)`;
|
||||
document.getElementById('prev-btn').disabled = currentPage === 1;
|
||||
document.getElementById('next-btn').disabled = currentPage === pagination.totalPages;
|
||||
document.getElementById('pagination').style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the mappings table
|
||||
*/
|
||||
function renderMappings(mappings) {
|
||||
const content = document.getElementById('content');
|
||||
|
||||
if (mappings.length === 0) {
|
||||
content.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No mappings found</h3>
|
||||
<p>Try adjusting your filters or search query.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = mappings.map(mapping => {
|
||||
const metadata = mapping.Metadata || {};
|
||||
const artworkUrl = metadata.ArtworkUrl || '/placeholder.png';
|
||||
const title = metadata.Title || 'Unknown Track';
|
||||
const artist = metadata.Artist || 'Unknown Artist';
|
||||
const targetInfo = mapping.TargetType === 'local'
|
||||
? mapping.LocalId
|
||||
: `${mapping.ExternalProvider}:${mapping.ExternalId}`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="track-info">
|
||||
<img src="${artworkUrl}" alt="${title}" class="track-artwork"
|
||||
onerror="this.src='/placeholder.png'">
|
||||
<div class="track-details">
|
||||
<div class="track-title">${escapeHtml(title)}</div>
|
||||
<div class="track-artist">${escapeHtml(artist)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">${mapping.SpotifyId}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${mapping.TargetType}">${mapping.TargetType}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">${targetInfo}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${mapping.Source}">${mapping.Source}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="mono">${new Date(mapping.CreatedAt).toLocaleDateString()}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions-cell">
|
||||
<button class="action-btn" onclick="mapToLocal('${mapping.SpotifyId}', '${escapeHtml(title)}', '${escapeHtml(artist)}')">
|
||||
Map to Local
|
||||
</button>
|
||||
<button class="action-btn" onclick="mapToExternal('${mapping.SpotifyId}', '${escapeHtml(title)}', '${escapeHtml(artist)}')">
|
||||
Map to External
|
||||
</button>
|
||||
<button class="action-btn danger" onclick="deleteMapping('${mapping.SpotifyId}', '${escapeHtml(title)}')">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const sortIndicator = (column) => {
|
||||
if (currentFilters.sortBy === column) {
|
||||
return currentFilters.sortOrder === 'asc' ? ' ▲' : ' ▼';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
content.innerHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" onclick="sortBy('title')">Track${sortIndicator('title')}</th>
|
||||
<th class="sortable" onclick="sortBy('spotifyid')">Spotify ID${sortIndicator('spotifyid')}</th>
|
||||
<th class="sortable" onclick="sortBy('type')">Type${sortIndicator('type')}</th>
|
||||
<th>Target ID</th>
|
||||
<th class="sortable" onclick="sortBy('source')">Source${sortIndicator('source')}</th>
|
||||
<th class="sortable" onclick="sortBy('created')">Created${sortIndicator('created')}</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts the table by the specified column
|
||||
*/
|
||||
function sortBy(column) {
|
||||
if (currentFilters.sortBy === column) {
|
||||
// Toggle sort order
|
||||
currentFilters.sortOrder = currentFilters.sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// New column, default to ascending
|
||||
currentFilters.sortBy = column;
|
||||
currentFilters.sortOrder = 'asc';
|
||||
}
|
||||
|
||||
currentPage = 1; // Reset to first page
|
||||
loadMappings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies filters and reloads mappings
|
||||
*/
|
||||
function applyFilters() {
|
||||
currentFilters.targetType = document.getElementById('filter-type').value;
|
||||
currentFilters.source = document.getElementById('filter-source').value;
|
||||
currentFilters.search = document.getElementById('search').value;
|
||||
|
||||
currentPage = 1; // Reset to first page when filtering
|
||||
loadMappings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Spotify track to a local Jellyfin track
|
||||
*/
|
||||
async function mapToLocal(spotifyId, title, artist) {
|
||||
const query = prompt(`Search Jellyfin for "${title}" by ${artist}:`, `${title} ${artist}`);
|
||||
if (!query) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/jellyfin/search?query=${encodeURIComponent(query)}`);
|
||||
if (!response.ok) throw new Error('Search failed');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.tracks.length === 0) {
|
||||
alert('No tracks found in Jellyfin. Try a different search query.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show selection dialog
|
||||
const trackList = data.tracks.map((t, i) =>
|
||||
`${i + 1}. ${t.title} by ${t.artist} (${t.album || 'Unknown Album'})`
|
||||
).join('\n');
|
||||
|
||||
const selection = prompt(`Found ${data.tracks.length} tracks:\n\n${trackList}\n\nEnter track number (1-${data.tracks.length}):`, '1');
|
||||
if (!selection) return;
|
||||
|
||||
const index = parseInt(selection) - 1;
|
||||
if (index < 0 || index >= data.tracks.length) {
|
||||
alert('Invalid selection');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedTrack = data.tracks[index];
|
||||
|
||||
// Save mapping
|
||||
const saveResponse = await fetch(`/api/admin/spotify/mappings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
SpotifyId: spotifyId,
|
||||
TargetType: 'local',
|
||||
LocalId: selectedTrack.id,
|
||||
Metadata: {
|
||||
Title: selectedTrack.title,
|
||||
Artist: selectedTrack.artist,
|
||||
Album: selectedTrack.album
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!saveResponse.ok) throw new Error('Failed to save mapping');
|
||||
|
||||
alert(`✓ Mapped to local track: ${selectedTrack.title}`);
|
||||
loadMappings(); // Reload
|
||||
} catch (error) {
|
||||
console.error('Error mapping to local:', error);
|
||||
alert(`Failed to map to local: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Spotify track to an external provider track
|
||||
*/
|
||||
async function mapToExternal(spotifyId, title, artist) {
|
||||
const provider = prompt('Enter external provider (squidwtf, deezer, qobuz):', 'squidwtf');
|
||||
if (!provider) return;
|
||||
|
||||
const externalId = prompt(`Enter ${provider} track ID:`, '');
|
||||
if (!externalId) return;
|
||||
|
||||
try {
|
||||
const saveResponse = await fetch(`/api/admin/spotify/mappings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
SpotifyId: spotifyId,
|
||||
TargetType: 'external',
|
||||
ExternalProvider: provider,
|
||||
ExternalId: externalId,
|
||||
Metadata: {
|
||||
Title: title,
|
||||
Artist: artist
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!saveResponse.ok) throw new Error('Failed to save mapping');
|
||||
|
||||
alert(`✓ Mapped to external track: ${provider}:${externalId}`);
|
||||
loadMappings(); // Reload
|
||||
} catch (error) {
|
||||
console.error('Error mapping to external:', error);
|
||||
alert(`Failed to map to external: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a Spotify track mapping
|
||||
*/
|
||||
async function deleteMapping(spotifyId, title) {
|
||||
if (!confirm(`Delete mapping for "${title}"?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/spotify/mappings/${spotifyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete mapping');
|
||||
|
||||
alert(`✓ Deleted mapping for "${title}"`);
|
||||
loadMappings(); // Reload
|
||||
} catch (error) {
|
||||
console.error('Error deleting mapping:', error);
|
||||
alert(`Failed to delete mapping: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes event listeners
|
||||
*/
|
||||
function initializeEventListeners() {
|
||||
// Search with debounce
|
||||
let searchTimeout;
|
||||
document.getElementById('search').addEventListener('input', () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(applyFilters, 300);
|
||||
});
|
||||
|
||||
// Filter dropdowns
|
||||
document.getElementById('filter-type').addEventListener('change', applyFilters);
|
||||
document.getElementById('filter-source').addEventListener('change', applyFilters);
|
||||
|
||||
// Pagination
|
||||
document.getElementById('prev-btn').addEventListener('click', () => {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
loadMappings();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('next-btn').addEventListener('click', () => {
|
||||
currentPage++;
|
||||
loadMappings();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeEventListeners();
|
||||
loadMappings();
|
||||
});
|
||||
@@ -0,0 +1,507 @@
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--text-primary: #f0f6fc;
|
||||
--text-secondary: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--accent-hover: #79c0ff;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--error: #f85149;
|
||||
--border: #30363d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h1 .version {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.success { background: rgba(63, 185, 80, 0.2); color: var(--success); }
|
||||
.status-badge.warning { background: rgba(210, 153, 34, 0.2); color: var(--warning); }
|
||||
.status-badge.error { background: rgba(248, 81, 73, 0.2); color: var(--error); }
|
||||
.status-badge.info { background: rgba(59, 130, 246, 0.2); color: #3b82f6; }
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card h2 .actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value.success { color: var(--success); }
|
||||
.stat-value.warning { color: var(--warning); }
|
||||
.stat-value.error { color: var(--error); }
|
||||
|
||||
button {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: rgba(248, 81, 73, 0.3);
|
||||
}
|
||||
|
||||
.playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.playlist-table th,
|
||||
.playlist-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.playlist-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.playlist-table tr:hover td {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.playlist-table .track-count {
|
||||
font-family: monospace;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.playlist-table .cache-age {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
input, select {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 120px auto;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.config-section h3 {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.config-item .label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.config-item .value {
|
||||
font-family: monospace;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 1000;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.toast.success { border-color: var(--success); }
|
||||
.toast.error { border-color: var(--error); }
|
||||
.toast.warning { border-color: var(--warning); }
|
||||
.toast.info { border-color: var(--accent); }
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.restart-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
z-index: 9999;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.restart-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.restart-overlay .spinner-large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.restart-overlay h2 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.restart-overlay p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.restart-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--warning);
|
||||
color: var(--bg-primary);
|
||||
padding: 12px 20px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
z-index: 9998;
|
||||
display: none;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.restart-banner.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.restart-banner button {
|
||||
margin-left: 16px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.restart-banner button:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 75%;
|
||||
width: 75%;
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-content .form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-content .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-content .form-group input,
|
||||
.modal-content .form-group select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tracks-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.track-item {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.track-position {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.track-info h4 {
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.track-info .artists {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.track-meta {
|
||||
text-align: right;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
+14
-2
@@ -17,8 +17,11 @@ services:
|
||||
networks:
|
||||
- allstarr-network
|
||||
|
||||
# Spotify Lyrics API sidecar service
|
||||
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
|
||||
spotify-lyrics:
|
||||
image: akashrchandran/spotify-lyrics-api:latest
|
||||
platform: linux/amd64
|
||||
container_name: allstarr-spotify-lyrics
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -70,6 +73,17 @@ services:
|
||||
- Redis__ConnectionString=redis:6379
|
||||
- Redis__Enabled=${REDIS_ENABLED:-true}
|
||||
|
||||
# ===== CACHE TTL SETTINGS =====
|
||||
- Cache__SearchResultsMinutes=${CACHE_SEARCH_RESULTS_MINUTES:-120}
|
||||
- Cache__PlaylistImagesHours=${CACHE_PLAYLIST_IMAGES_HOURS:-168}
|
||||
- Cache__SpotifyPlaylistItemsHours=${CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS:-168}
|
||||
- Cache__SpotifyMatchedTracksDays=${CACHE_SPOTIFY_MATCHED_TRACKS_DAYS:-30}
|
||||
- Cache__LyricsDays=${CACHE_LYRICS_DAYS:-14}
|
||||
- Cache__GenreDays=${CACHE_GENRE_DAYS:-30}
|
||||
- Cache__MetadataDays=${CACHE_METADATA_DAYS:-7}
|
||||
- Cache__OdesliLookupDays=${CACHE_ODESLI_LOOKUP_DAYS:-60}
|
||||
- Cache__ProxyImagesDays=${CACHE_PROXY_IMAGES_DAYS:-14}
|
||||
|
||||
# ===== SUBSONIC BACKEND =====
|
||||
- Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533}
|
||||
- Subsonic__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly}
|
||||
@@ -104,8 +118,6 @@ services:
|
||||
|
||||
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
|
||||
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
|
||||
- SpotifyApi__ClientId=${SPOTIFY_API_CLIENT_ID:-}
|
||||
- SpotifyApi__ClientSecret=${SPOTIFY_API_CLIENT_SECRET:-}
|
||||
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
|
||||
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
|
||||
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
||||
|
||||
Reference in New Issue
Block a user