Compare commits

..

1 Commits

Author SHA1 Message Date
joshpatra 40338ce25f v1.0.0: Lots of WebUI fixes, API fixes, refactored all of caching, general bug fixes, redid all log messages
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 23:24:40 -05:00
64 changed files with 7062 additions and 11023 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# ===== BACKEND SELECTION =====
# Choose which media server backend to use: Subsonic or Jellyfin
BACKEND_TYPE=Jellyfin
BACKEND_TYPE=Subsonic
# ===== REDIS CACHE (REQUIRED) =====
# Redis is the primary cache for all runtime data (search results, playlists, lyrics, etc.)
-167
View File
@@ -1,167 +0,0 @@
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);
}
}
-189
View File
@@ -1,189 +0,0 @@
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;
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ public class JellyfinProxyServiceTests
ClientName = "TestClient",
DeviceName = "TestDevice",
DeviceId = "test-device-id",
ClientVersion = "1.0.3"
ClientVersion = "1.0.1"
};
var httpContext = new DefaultHttpContext();
-82
View File
@@ -1,82 +0,0 @@
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));
}
}
+1 -1
View File
@@ -151,7 +151,7 @@ public class QobuzDownloadServiceTests : IDisposable
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"<html><script src=""/resources/1.0.3-b001/bundle.js""></script></html>")
Content = new StringContent(@"<html><script src=""/resources/1.0.1-b001/bundle.js""></script></html>")
};
_httpMessageHandlerMock.Protected()
@@ -1,217 +0,0 @@
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
}
}
@@ -1,173 +0,0 @@
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
}
}
@@ -1,35 +0,0 @@
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));
}
}
+3675 -14
View File
@@ -1,18 +1,22 @@
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>
/// 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
/// 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.
/// </summary>
[ApiController]
[Route("api/admin")]
@@ -20,15 +24,3672 @@ namespace allstarr.Controllers;
public class AdminController : ControllerBase
{
private readonly ILogger<AdminController> _logger;
public AdminController(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)
{
_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.1",
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("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 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(),
["syncSchedule"] = config.SyncSchedule ?? "0 8 * * 1",
["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.LogError(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.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
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
// Local tracks: Have Jellyfin ID OR no external provider keys
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, Jellyfin, 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
{
// Local track (has Jellyfin ID or no external provider)
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.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, 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.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 = $"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.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?>>();
// Also track items by position for fallback matching
var itemsByPosition = new Dictionary<int, Dictionary<string, object?>>();
for (int i = 0; i < cachedPlaylistItems.Count; i++)
{
var item = cachedPlaylistItems[i];
// Try to get Spotify ID from ProviderIds (works for both local and external)
bool hasSpotifyId = false;
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;
hasSpotifyId = true;
}
}
// If no Spotify ID found, use position-based matching as fallback
if (!hasSpotifyId)
{
itemsByPosition[i] = 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;
// First try to match by Spotify ID
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out cachedItem))
{
_logger.LogDebug("Matched track {Title} by Spotify ID", track.Title);
}
// Fallback: Try position-based matching for items without Spotify ID
else if (itemsByPosition.TryGetValue(track.Position, out cachedItem))
{
_logger.LogDebug("Matched track {Title} by position {Position}", track.Title, track.Position);
}
if (cachedItem != null)
{
// 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 (SquidWTF, Deezer, Qobuz, Tidal)
// If found, it's an external track
if (providerIds.Keys.Any(k =>
k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase) ||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase)))
{
isLocal = false;
externalProvider = "SquidWTF";
_logger.LogDebug("✓ Track {Title} identified as SquidWTF", track.Title);
}
else if (providerIds.Keys.Any(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase)))
{
isLocal = false;
externalProvider = "Deezer";
_logger.LogDebug("✓ Track {Title} identified as Deezer", track.Title);
}
else if (providerIds.Keys.Any(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase)))
{
isLocal = false;
externalProvider = "Qobuz";
_logger.LogDebug("✓ Track {Title} identified as Qobuz", track.Title);
}
else if (providerIds.Keys.Any(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase)))
{
isLocal = false;
externalProvider = "Tidal";
_logger.LogDebug("✓ Track {Title} identified as Tidal", track.Title);
}
else
{
// No external provider key found - it's a local Jellyfin track
// Local tracks may have: Jellyfin ID, MusicBrainz IDs, ISRC, etc.
isLocal = true;
_logger.LogDebug("✓ Track {Title} identified as LOCAL (has ProviderIds but no external provider)", track.Title);
}
}
else
{
// ProviderIds exists but is null after parsing - treat as local
isLocal = true;
_logger.LogDebug("✓ Track {Title} identified as LOCAL (ProviderIds null)", track.Title);
}
}
else
{
// Track is in cache but has NO ProviderIds property at all
// This is typical for local Jellyfin tracks - 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
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.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
InvalidatePlaylistSummaryCache();
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 = $"spotify:matched:ordered:{decodedName}";
await _cache.DeleteAsync(matchedTracksKey);
_logger.LogDebug("Cleared matched tracks cache");
// Clear the playlist items cache
var playlistItemsCacheKey = $"spotify:playlist:items:{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
InvalidatePlaylistSummaryCache();
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[]
{
$"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
$"spotify:playlist:jellyfin-signature:{decodedName}", // Jellyfin signature
$"spotify:playlist:{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 = 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} (including Spotify data)", decodedName);
// Trigger rebuild (will fetch fresh Spotify data)
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
// Invalidate playlist summary cache
InvalidatePlaylistSummaryCache();
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 = 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.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 = 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.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 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.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);
}
}
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>
[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 = 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.LogDebug("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.LogDebug("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.LogDebug("Config file updated successfully at {Path}", _envFilePath);
// Invalidate playlist summary cache if playlists were updated
if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS"))
{
InvalidatePlaylistSummaryCache();
}
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.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[]
{
$"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.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("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 the user's Spotify account
/// </summary>
[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 ReadPlaylistsFromEnvFile();
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("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!);
}
// 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 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.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 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
SyncSchedule = request.SyncSchedule ?? "0 8 * * 1" // Default to Monday 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 * * 1"
}).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);
}
/// <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 ReadPlaylistsFromEnvFile();
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 * * 1"
}).ToArray()
);
var updateRequest = new ConfigUpdateRequest
{
Updates = new Dictionary<string, string>
{
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
}
};
return await UpdateConfig(updateRequest);
}
private string GetJellyfinAuthHeader()
{
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.1\", 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","cronSchedule"],...]
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 * * 1"
});
}
}
}
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.LogDebug("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 });
}
}
[HttpGet("health")]
public IActionResult Health()
/// <summary>
/// Forces garbage collection to free up memory (emergency use only).
/// </summary>
[HttpPost("force-gc")]
public IActionResult ForceGarbageCollection()
{
return Ok(new { status = "healthy", message = "Admin API is running" });
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.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" });
}
}
#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.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" });
}
}
#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.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>
/// 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.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 = 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.LogError(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;
public string SyncSchedule { get; set; } = "0 8 * * 1"; // Default: 8 AM every Monday
}
public class UpdateScheduleRequest
{
public string SyncSchedule { 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.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 = FormatFileSize(fileInfo.Length),
lastModified = fileInfo.LastWriteTimeUtc,
extension = fileInfo.Extension
});
totalSize += fileInfo.Length;
}
_logger.LogDebug("📂 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.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" });
}
}
private 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]}";
}
}
/// <summary>
/// Request model for updating configuration
/// </summary>
public class ConfigUpdateRequest
{
public Dictionary<string, string> Updates { get; set; } = new();
}
-514
View File
@@ -1,514 +0,0 @@
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>
}
@@ -1,390 +0,0 @@
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>
}
-207
View File
@@ -1,207 +0,0 @@
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>
}
@@ -1,473 +0,0 @@
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);
}
}
+90 -27
View File
@@ -13,7 +13,6 @@ 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;
@@ -125,7 +124,7 @@ public class JellyfinController : ControllerBase
// Only cache actual searches, not browse operations
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
{
var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex);
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
var cachedResult = await _cache.GetAsync<object>(cacheKey);
if (cachedResult != null)
@@ -422,7 +421,7 @@ public class JellyfinController : ControllerBase
// Cache search results in Redis (15 min TTL, no file persistence)
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
{
var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex);
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm, CacheExtensions.SearchResultsTTL.TotalMinutes);
}
@@ -887,7 +886,14 @@ public class JellyfinController : ControllerBase
var request = new HttpRequestMessage(HttpMethod.Get, jellyfinUrl);
// Forward auth headers
AuthHeaderHelper.ForwardAuthHeaders(Request.Headers, request);
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());
}
// Forward Range header for seeking
if (Request.Headers.TryGetValue("Range", out var range))
@@ -2809,7 +2815,7 @@ public class JellyfinController : ControllerBase
{
LocalAddress = Request.Host.ToString(),
ServerName = serverName ?? "Allstarr",
Version = version ?? "1.0.3",
Version = version ?? "1.0.1",
ProductName = "Allstarr (Jellyfin Proxy)",
OperatingSystem = Environment.OSVersion.Platform.ToString(),
Id = _settings.DeviceId,
@@ -2835,13 +2841,6 @@ 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))
@@ -2945,7 +2944,23 @@ public class JellyfinController : ControllerBase
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Forward auth headers from client
AuthHeaderHelper.ForwardAuthHeaders(Request.Headers, request);
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);
}
}
var response = await _proxyService.HttpClient.SendAsync(request);
@@ -3198,7 +3213,7 @@ public class JellyfinController : ControllerBase
var playlistName = playlistConfig.Name;
// Get matched external tracks (tracks that were successfully downloaded/matched)
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
_logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks",
@@ -3494,7 +3509,7 @@ public class JellyfinController : ControllerBase
var jellyfinPlaylistChanged = cachedJellyfinSignature != currentJellyfinSignature;
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
var cacheKey = $"spotify:playlist:items:{spotifyPlaylistName}";
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
if (cachedItems != null && cachedItems.Count > 0 && !jellyfinPlaylistChanged)
@@ -3534,7 +3549,7 @@ public class JellyfinController : ControllerBase
}
// Check for ordered matched tracks from SpotifyTrackMatchingService
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(spotifyPlaylistName);
var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}";
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
if (orderedTracks == null || orderedTracks.Count == 0)
@@ -3810,7 +3825,7 @@ public class JellyfinController : ControllerBase
_logger.LogWarning("No existing tracks found in Jellyfin playlist - may need UserId parameter");
}
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(spotifyPlaylistName);
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
// Fallback to file cache if Redis is empty
@@ -3955,13 +3970,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, AdminHelperService.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, AdminHelperService.SanitizeFileName(song.Album));
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
// Check if track already exists in kept folder
if (Directory.Exists(keptAlbumPath))
{
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
if (existingFiles.Length > 0)
{
@@ -3974,14 +3989,14 @@ public class JellyfinController : ControllerBase
// Look for the track in cache folder first
var cacheBasePath = "/tmp/allstarr-cache";
var cacheArtistPath = Path.Combine(cacheBasePath, AdminHelperService.SanitizeFileName(song.Artist));
var cacheAlbumPath = Path.Combine(cacheArtistPath, AdminHelperService.SanitizeFileName(song.Album));
var cacheArtistPath = Path.Combine(cacheBasePath, PathHelper.SanitizeFileName(song.Artist));
var cacheAlbumPath = Path.Combine(cacheArtistPath, PathHelper.SanitizeFileName(song.Album));
string? sourceFilePath = null;
if (Directory.Exists(cacheAlbumPath))
{
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
var cacheFiles = Directory.GetFiles(cacheAlbumPath, $"*{sanitizedTitle}*");
if (cacheFiles.Length > 0)
{
@@ -4251,12 +4266,12 @@ public class JellyfinController : ControllerBase
if (song == null) return;
var keptBasePath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var keptArtistPath = Path.Combine(keptBasePath, AdminHelperService.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, AdminHelperService.SanitizeFileName(song.Album));
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
if (!Directory.Exists(keptAlbumPath)) return;
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
var trackFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
foreach (var trackFile in trackFiles)
@@ -4507,6 +4522,54 @@ public class JellyfinController : ControllerBase
#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>
@@ -4583,7 +4646,7 @@ public class JellyfinController : ControllerBase
// Search through each playlist's matched tracks cache
foreach (var playlist in playlists)
{
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name);
var cacheKey = $"spotify:matched:ordered:{playlist.Name}";
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
if (matchedTracks == null || matchedTracks.Count == 0)
-258
View File
@@ -1,258 +0,0 @@
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>
}
-161
View File
@@ -1,161 +0,0 @@
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>
}
-1516
View File
@@ -1,1516 +0,0 @@
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>
}
@@ -1,537 +0,0 @@
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);
}
}
}
}
-11
View File
@@ -10,24 +10,13 @@ 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();
}
}
+1 -15
View File
@@ -32,7 +32,7 @@ public class ApiKeyAuthFilter : IAsyncActionFilter
?? request.Headers["X-Emby-Token"].FirstOrDefault();
// Validate API key
if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(_settings.ApiKey) || !FixedTimeEquals(apiKey, _settings.ApiKey))
if (string.IsNullOrEmpty(apiKey) || !string.Equals(apiKey, _settings.ApiKey, StringComparison.Ordinal))
{
_logger.LogWarning("Unauthorized access attempt to {Path} from {IP}",
request.Path,
@@ -49,18 +49,4 @@ public class ApiKeyAuthFilter : IAsyncActionFilter
_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);
}
}
@@ -110,9 +110,7 @@ public class WebSocketProxyMiddleware
jellyfinWsUrl += context.Request.QueryString.Value;
}
// 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);
_logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl);
// Connect to Jellyfin WebSocket
serverWebSocket = new ClientWebSocket();
@@ -141,7 +139,7 @@ public class WebSocketProxyMiddleware
}
// Set user agent
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0.3");
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0.1");
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
@@ -212,32 +210,6 @@ 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,
-83
View File
@@ -1,83 +0,0 @@
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>
+1 -1
View File
@@ -43,7 +43,7 @@ public class JellyfinSettings
/// <summary>
/// Client version reported to Jellyfin
/// </summary>
public string ClientVersion { get; set; } = "1.0.3";
public string ClientVersion { get; set; } = "1.0.1";
/// <summary>
/// Device ID reported to Jellyfin
@@ -49,10 +49,10 @@ public class SpotifyPlaylistConfig
/// <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)
/// Example: "0 8 * * 1" = 8 AM every Monday
/// Default: "0 8 * * 1" (weekly on Monday at 8 AM)
/// </summary>
public string SyncSchedule { get; set; } = "0 8 * * *";
public string SyncSchedule { get; set; } = "0 8 * * 1";
}
/// <summary>
@@ -1,94 +0,0 @@
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; }
}
+6 -32
View File
@@ -137,9 +137,6 @@ 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"));
@@ -168,7 +165,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","cronSchedule"],["Name2","SpotifyId2","JellyfinId2","first|last","cronSchedule"]]
// Format: [["Name","SpotifyId","JellyfinId","first|last"],["Name2","SpotifyId2","JellyfinId2","first|last"]]
var playlistsEnv = builder.Configuration.GetValue<string>("SpotifyImport:Playlists");
if (!string.IsNullOrWhiteSpace(playlistsEnv))
{
@@ -195,11 +192,10 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
LocalTracksPosition = arr.Length >= 4 &&
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
? LocalTracksPosition.Last
: LocalTracksPosition.First,
SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * *"
: LocalTracksPosition.First
};
options.Playlists.Add(config);
Console.WriteLine($" Added: {config.Name} (Spotify: {config.Id}, Jellyfin: {config.JellyfinId}, Position: {config.LocalTracksPosition}, Schedule: {config.SyncSchedule})");
Console.WriteLine($" Added: {config.Name} (Spotify: {config.Id}, Jellyfin: {config.JellyfinId}, Position: {config.LocalTracksPosition})");
}
}
}
@@ -211,7 +207,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\",\"cronSchedule\"],[\"Name2\",\"SpotifyId2\",\"JellyfinId2\",\"first|last\",\"cronSchedule\"]]");
Console.WriteLine("Expected format: [[\"Name\",\"SpotifyId\",\"JellyfinId\",\"first|last\"],[\"Name2\",\"SpotifyId2\",\"JellyfinId2\",\"first|last\"]]");
Console.WriteLine("Will try legacy format instead");
}
}
@@ -590,15 +586,6 @@ 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>());
@@ -731,21 +718,8 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
var isController = base.IsController(typeInfo);
if (!isController) return false;
// 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;
}
// AdminController should always be registered (for web UI)
if (typeInfo.Name == "AdminController") return true;
// Only register the controller matching the configured backend type
return _backendType switch
@@ -1,402 +0,0 @@
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;
}
}
}
@@ -1,168 +0,0 @@
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)}";
}
}
@@ -242,18 +242,8 @@ 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 virtual string? ExtractExternalIdFromAlbumId(string albumId)
{
var prefix = $"ext-{ProviderName}-album-";
if (albumId.StartsWith(prefix))
{
return albumId[prefix.Length..];
}
return null;
}
protected abstract string? ExtractExternalIdFromAlbumId(string albumId);
#endregion
-107
View File
@@ -1,107 +0,0 @@
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
}
@@ -161,7 +161,7 @@ public class CacheWarmingService : IHostedService
var fileName = Path.GetFileNameWithoutExtension(file);
var playlistName = fileName.Replace("_items", "");
var redisKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
var redisKey = $"spotify:playlist:items:{playlistName}";
await _cache.SetAsync(redisKey, items, CacheExtensions.SpotifyPlaylistItemsTTL);
warmedCount++;
@@ -199,7 +199,7 @@ public class CacheWarmingService : IHostedService
var fileName = Path.GetFileNameWithoutExtension(file);
var playlistName = fileName.Replace("_matched", "");
var redisKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
var redisKey = $"spotify:matched:ordered:{playlistName}";
await _cache.SetAsync(redisKey, matchedTracks, CacheExtensions.SpotifyMatchedTracksTTL);
warmedCount++;
@@ -1,41 +0,0 @@
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
};
}
}
+6 -49
View File
@@ -41,22 +41,8 @@ public static class PathHelper
var albumFolder = Path.Combine(artistFolder, safeAlbum);
var trackPrefix = trackNumber.HasValue ? $"{trackNumber:D2} - " : "";
// 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 idSuffix = !string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId)
? $" [{provider}-{externalId}]"
: "";
var fileName = $"{trackPrefix}{safeTitle}{idSuffix}{extension}";
@@ -80,24 +66,12 @@ 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].TrimEnd('.');
sanitized = sanitized[..100];
}
if (string.IsNullOrWhiteSpace(sanitized)) return "Unknown";
return sanitized;
return sanitized.Trim();
}
/// <summary>
@@ -145,38 +119,21 @@ 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);
if (string.IsNullOrEmpty(directory))
{
// If no directory part is present, use current directory
directory = Directory.GetCurrentDirectory();
}
var directory = Path.GetDirectoryName(basePath)!;
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;
-73
View File
@@ -1,73 +0,0 @@
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);
}
}
@@ -81,6 +81,15 @@ 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)
{
@@ -112,14 +121,14 @@ public class DeezerDownloadService : BaseDownloadService
outputPath = PathHelper.ResolveUniquePath(outputPath);
// Download the encrypted file
var response = await RetryHelper.RetryWithBackoffAsync(async () =>
var response = await 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();
@@ -150,7 +159,7 @@ public class DeezerDownloadService : BaseDownloadService
throw new Exception("ARL token required for Deezer downloads");
}
await RetryHelper.RetryWithBackoffAsync(async () =>
await 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");
@@ -177,12 +186,11 @@ public class DeezerDownloadService : BaseDownloadService
}
Logger.LogInformation("Deezer token refreshed successfully");
return true;
}
else
{
throw new Exception("Invalid ARL token");
}
}, Logger);
throw new Exception("Invalid ARL token");
});
}
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
@@ -449,6 +457,43 @@ 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
@@ -47,7 +47,7 @@ public class DeezerMetadataService : IMusicMetadataService
foreach (var track in data.EnumerateArray())
{
var song = ParseDeezerTrack(track);
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
if (ShouldIncludeSong(song))
{
songs.Add(song);
}
@@ -260,7 +260,7 @@ public class DeezerMetadataService : IMusicMetadataService
song.AlbumId = album.Id;
song.AlbumArtist = album.Artist;
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
if (ShouldIncludeSong(song))
{
album.Songs.Add(song);
}
@@ -636,7 +636,7 @@ public class DeezerMetadataService : IMusicMetadataService
// Override album name to be the playlist name
song.Album = playlistName;
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
if (ShouldIncludeSong(song))
{
songs.Add(song);
}
@@ -704,4 +704,33 @@ 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
};
}
}
@@ -176,11 +176,64 @@ public class JellyfinProxyService
// Forward authentication headers from client if provided
if (clientHeaders != null && clientHeaders.Count > 0)
{
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
if (authHeaderAdded)
// Try X-Emby-Authorization first (case-insensitive)
foreach (var header in clientHeaders)
{
_logger.LogTrace("Forwarded authentication headers");
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;
}
}
// Try X-Emby-Token (simpler format used by some clients)
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.LogDebug("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;
}
}
}
// Check for api_key query parameter (some clients use this)
@@ -276,12 +329,63 @@ public class JellyfinProxyService
bool authHeaderAdded = false;
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
// Forward authentication headers from client
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
if (authHeaderAdded)
// Forward authentication headers from client (case-insensitive)
// Try X-Emby-Authorization first
foreach (var header in clientHeaders)
{
_logger.LogTrace("Forwarded authentication headers");
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;
}
}
// Try X-Emby-Token
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.LogDebug("Converted Authorization to X-Emby-Authorization");
}
else
{
// Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogTrace("Forwarded Authorization header");
}
authHeaderAdded = true;
break;
}
}
}
// For authentication endpoints, credentials are in the body, not headers
@@ -432,17 +536,51 @@ public class JellyfinProxyService
bool authHeaderAdded = false;
// Forward authentication headers from client
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
// 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;
}
}
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");
}
else
{
// Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogDebug("Forwarded Authorization header");
}
authHeaderAdded = true;
break;
}
}
}
if (!authHeaderAdded)
{
_logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url);
}
else
{
_logger.LogTrace("Forwarded authentication headers");
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+1 -1
View File
@@ -18,7 +18,7 @@ public class LrclibService
ILogger<LrclibService> logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.3 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.1 (https://github.com/SoPat712/allstarr)");
_cache = cache;
_logger = logger;
}
@@ -22,7 +22,7 @@ public class LyricsPlusService
ILogger<LyricsPlusService> logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.3 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.1 (https://github.com/SoPat712/allstarr)");
_cache = cache;
_logger = logger;
}
@@ -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 = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
var playlistItemsKey = $"spotify:playlist:items:{playlistName}";
var playlistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
// Build a map of Spotify ID -> Jellyfin Item ID for quick lookup
@@ -25,7 +25,7 @@ public class MusicBrainzService
ILogger<MusicBrainzService> logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.3 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.1 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_settings = settings.Value;
@@ -80,6 +80,15 @@ 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)
{
@@ -68,7 +68,10 @@ public class QobuzMetadataService : IMusicMetadataService
foreach (var track in items.EnumerateArray())
{
var song = ParseQobuzTrack(track);
songs.Add(song);
if (ShouldIncludeSong(song))
{
songs.Add(song);
}
}
}
@@ -238,7 +241,10 @@ public class QobuzMetadataService : IMusicMetadataService
song.AlbumId = album.Id;
song.AlbumArtist = album.Artist;
album.Songs.Add(song);
if (ShouldIncludeSong(song))
{
album.Songs.Add(song);
}
}
}
@@ -423,7 +429,10 @@ public class QobuzMetadataService : IMusicMetadataService
song.Album = playlistName;
song.Track = trackIndex;
songs.Add(song);
if (ShouldIncludeSong(song))
{
songs.Add(song);
}
trackIndex++;
}
}
@@ -826,4 +835,14 @@ 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;
}
}
@@ -1,167 +0,0 @@
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; }
}
}
@@ -1,394 +0,0 @@
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; }
}
@@ -1,332 +0,0 @@
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);
}
}
@@ -188,7 +188,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
foreach (var playlistName in _playlistIdToName.Values)
{
var filePath = GetCacheFilePath(playlistName);
var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
var cacheKey = $"spotify:missing:{playlistName}";
// Check file cache
if (File.Exists(filePath))
@@ -245,7 +245,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (tracks != null && tracks.Count > 0)
{
var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
var cacheKey = $"spotify:missing:{playlistName}";
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
// No expiration - cache persists until next Jellyfin job generates new file
@@ -310,7 +310,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
CancellationToken cancellationToken,
DateTime? hintTime = null)
{
var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
var cacheKey = $"spotify:missing:{playlistName}";
// Check if we have existing cache
var existingTracks = await _cache.GetAsync<List<MissingTrack>>(cacheKey);
@@ -486,7 +486,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (tracks.Count > 0)
{
var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
var cacheKey = $"spotify:missing:{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
@@ -261,7 +261,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
foreach (var playlist in _spotifyImportSettings.Playlists)
{
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * *" : playlist.SyncSchedule;
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
}
@@ -279,7 +279,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
foreach (var config in _spotifyImportSettings.Playlists)
{
var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * *" : config.SyncSchedule;
var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * 1" : config.SyncSchedule;
try
{
@@ -27,8 +27,6 @@ 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
@@ -42,16 +40,12 @@ public class SpotifyTrackMatchingService : BackgroundService
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;
}
@@ -118,7 +112,7 @@ public class SpotifyTrackMatchingService : BackgroundService
foreach (var playlist in _spotifySettings.Playlists)
{
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * *" : playlist.SyncSchedule;
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
try
{
@@ -316,7 +310,7 @@ public class SpotifyTrackMatchingService : BackgroundService
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
// Get playlist tracks with full metadata including ISRC and position
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
@@ -444,155 +438,32 @@ 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 unmatched tracks in batches
for (int i = 0; i < unmatchedSpotifyTracks.Count; i += BatchSize)
// Process tracks in batches for parallel searching
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
for (int i = 0; i < orderedTracks.Count; i += BatchSize)
{
if (cancellationToken.IsCancellationRequested) break;
var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList();
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);
// 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)>();
// 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
// Try ISRC match first if available and enabled
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
{
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
@@ -602,7 +473,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
// Fuzzy search external providers
// Always try fuzzy matching to get more candidates
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
spotifyTrack.Title,
spotifyTrack.Artists,
@@ -610,137 +481,97 @@ public class SpotifyTrackMatchingService : BackgroundService
foreach (var (song, score) in fuzzySongs)
{
if (!song.IsLocal) // Only external tracks
{
candidates.Add((song, score, "fuzzy-external"));
}
candidates.Add((song, score, "fuzzy"));
}
return (spotifyTrack, candidates);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to match track: {Title}", spotifyTrack.Title);
_logger.LogError(ex, "Failed to match track: {Title} - {Artist}",
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
return (spotifyTrack, new List<(Song, double, string)>());
}
}).ToList();
// Wait for all tracks in this batch to complete
var batchResults = await Task.WhenAll(batchTasks);
foreach (var result in batchResults)
// Collect all candidates
foreach (var (spotifyTrack, candidates) in batchResults)
{
foreach (var candidate in result.Item2)
foreach (var (song, score, matchType) in candidates)
{
allCandidates.Add((result.Item1, candidate.Item1, candidate.Item2, candidate.Item3));
allCandidates.Add((spotifyTrack, song, score, matchType));
}
}
if (i + BatchSize < unmatchedSpotifyTracks.Count)
// Rate limiting between batches
if (i + BatchSize < orderedTracks.Count)
{
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
}
}
// PHASE 4: Greedy assignment for external matches
// GREEDY ASSIGNMENT: Assign each Spotify track to its best unique match
var usedSongIds = new HashSet<string>();
var externalAssignments = new Dictionary<string, (Song Song, double Score, string MatchType)>();
var assignments = new Dictionary<string, (Song Song, double Score, string MatchType)>();
foreach (var (spotifyTrack, song, score, matchType) in allCandidates.OrderByDescending(c => c.Score))
// Sort candidates by score (highest first)
var sortedCandidates = allCandidates
.OrderByDescending(c => c.Score)
.ToList();
foreach (var (spotifyTrack, song, score, matchType) in sortedCandidates)
{
if (externalAssignments.ContainsKey(spotifyTrack.SpotifyId)) continue;
if (usedSongIds.Contains(song.Id)) continue;
// Skip if this Spotify track already has a match
if (assignments.ContainsKey(spotifyTrack.SpotifyId))
continue;
externalAssignments[spotifyTrack.SpotifyId] = (song, score, matchType);
// Skip if this song is already used
if (usedSongIds.Contains(song.Id))
continue;
// Assign this match
assignments[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);
}
// PHASE 5: Build final matched tracks list (local + external)
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
// Build final matched tracks list
foreach (var spotifyTrack in orderedTracks)
{
MatchedTrack? matched = null;
// Check local matches first
if (localMatches.TryGetValue(spotifyTrack.SpotifyId, out var localMatch))
if (assignments.TryGetValue(spotifyTrack.SpotifyId, out var match))
{
matched = new MatchedTrack
var matched = new MatchedTrack
{
Position = spotifyTrack.Position,
SpotifyId = spotifyTrack.SpotifyId,
SpotifyTitle = spotifyTrack.Title,
SpotifyArtist = spotifyTrack.PrimaryArtist,
Isrc = spotifyTrack.Isrc,
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
MatchType = match.MatchType,
MatchedSong = match.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} → no match", spotifyTrack.Position, spotifyTrack.Title);
}
if (matched != null)
{
matchedTracks.Add(matched);
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
}
}
if (matchedTracks.Count > 0)
{
// 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));
@@ -799,10 +630,9 @@ public class SpotifyTrackMatchingService : BackgroundService
/// Returns multiple candidate matches with scores for greedy assignment.
/// FOLLOWS OPTIMAL ORDER:
/// 1. Strip decorators (done in FuzzyMatcher)
/// <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.
/// 2. Substring matching (done in FuzzyMatcher)
/// 3. Levenshtein distance (done in FuzzyMatcher)
/// This method just collects candidates; greedy assignment happens later.
/// </summary>
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
string title,
@@ -812,130 +642,41 @@ 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 allCandidates = new List<(Song Song, double Score)>();
var results = await metadataService.SearchSongsAsync(query, limit: 10);
// STEP 1: Search LOCAL Jellyfin library FIRST
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
if (proxyService != null)
{
try
if (results.Count == 0) return new List<(Song, double)>();
// STEP 2-3: Score all results (substring + Levenshtein already in CalculateSimilarityAggressive)
var scoredResults = results
.Select(song => new
{
// Search Jellyfin for local tracks
var searchParams = new Dictionary<string, string>
{
["searchTerm"] = query,
["includeItemTypes"] = "Audio",
["recursive"] = "true",
["limit"] = "10"
};
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,
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
x.Song,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.Where(x =>
x.TotalScore >= 40 ||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
x.TitleScore >= 85)
.OrderByDescending(x => x.TotalScore)
.Select(x => (x.Song, x.TotalScore))
.ToList();
allCandidates.AddRange(scoredLocal);
// If we found good local matches, return them (don't search external)
if (scoredLocal.Any(x => x.TotalScore >= 70))
{
_logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search",
scoredLocal.Count, title);
return allCandidates;
}
}
}
}
catch (Exception ex)
Song = song,
// Use aggressive matching which follows optimal order internally
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
_logger.LogWarning(ex, "Failed to search local library for '{Title}'", title);
}
}
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 =>
x.TotalScore >= 40 ||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
x.TitleScore >= 85)
.OrderByDescending(x => x.TotalScore)
.Select(x => (x.Song, x.TotalScore))
.ToList();
// 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;
return scoredResults;
}
catch
{
@@ -943,26 +684,14 @@ 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.
/// SEARCHES LOCAL FIRST, then external if no local match found.
/// Attempts to match a track by ISRC using provider search.
/// </summary>
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
{
try
{
// STEP 1: Search LOCAL Jellyfin library FIRST by ISRC
// Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search
// Local tracks will be found via fuzzy matching instead
// STEP 2: Search EXTERNAL by ISRC
// Search by ISRC directly - most providers support this
var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1);
if (results.Count > 0 && results[0].Isrc == isrc)
{
@@ -1074,7 +803,7 @@ public class SpotifyTrackMatchingService : BackgroundService
IMusicMetadataService metadataService,
CancellationToken cancellationToken)
{
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
var missingTracksKey = $"spotify:missing:{playlistName}";
var matchedTracksKey = $"spotify:matched:{playlistName}";
// Check if we already have matched tracks cached
@@ -1167,6 +896,54 @@ 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.
@@ -1518,19 +1295,9 @@ public class SpotifyTrackMatchingService : BackgroundService
itemDict["ProviderIds"] = providerIds;
}
if (providerIds != null)
if (providerIds != null && !providerIds.ContainsKey("Jellyfin"))
{
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;
}
providerIds["Jellyfin"] = jellyfinId;
_logger.LogDebug("Fuzzy matched local track {Title} with Jellyfin ID {Id} (score: {Score:F1})",
spotifyTrack.Title, jellyfinId, bestScore);
}
@@ -1555,21 +1322,6 @@ public class SpotifyTrackMatchingService : BackgroundService
// Convert external song to Jellyfin item format
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
// Add Spotify ID to ProviderIds for matching in track details endpoint
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!externalItem.ContainsKey("ProviderIds"))
{
externalItem["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
finalItems.Add(externalItem);
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as matched (external)
externalUsedCount++;
@@ -1657,7 +1409,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// Save to Redis cache with same expiration as matched tracks (until next cron run)
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
var cacheKey = $"spotify:playlist:items:{playlistName}";
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
// Save to file cache for persistence
@@ -91,10 +91,21 @@ 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)
{
@@ -119,7 +119,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (count >= limit) break;
var song = ParseTidalTrack(track);
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
if (ShouldIncludeSong(song))
{
songs.Add(song);
}
@@ -348,7 +348,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (trackWrapper.TryGetProperty("item", out var track))
{
var song = ParseTidalTrack(track);
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
if (ShouldIncludeSong(song))
{
album.Songs.Add(song);
}
@@ -574,7 +574,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Override album name to be the playlist name
song.Album = playlistName;
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
if (ShouldIncludeSong(song))
{
songs.Add(song);
}
+3 -3
View File
@@ -5,9 +5,9 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>allstarr</RootNamespace>
<Version>1.0.3</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion>
<Version>1.0.1</Version>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
</PropertyGroup>
<ItemGroup>
-16
View File
@@ -1,16 +0,0 @@
// ============================================================================
// 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.');
+2834 -239
View File
@@ -1,26 +1,531 @@
<!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>
<link rel="stylesheet" href="styles.css">
</head>
<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-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); }
}
</style>
</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.3</span>
Allstarr <span class="version" id="version">v1.0.0</span>
</h1>
<div id="status-indicator">
<span class="status-badge" id="spotify-status">
@@ -29,7 +534,7 @@
</span>
</div>
</header>
<div class="tabs">
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
@@ -37,7 +542,7 @@
<div class="tab" data-tab="config">Configuration</div>
<div class="tab" data-tab="endpoints">API Analytics</div>
</div>
<!-- Dashboard Tab -->
<div class="tab-content active" id="tab-dashboard">
<div class="grid">
@@ -64,7 +569,7 @@
<span class="stat-value" id="isrc-matching">-</span>
</div>
</div>
<div class="card">
<h2>Jellyfin</h2>
<div class="stat-row">
@@ -81,7 +586,7 @@
</div>
</div>
</div>
<div class="card">
<h2>
Quick Actions
@@ -90,11 +595,10 @@
<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>
<!-- Link Playlists Tab -->
<div class="tab-content" id="tab-jellyfin-playlists">
<div class="card">
@@ -105,23 +609,19 @@
</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>
</div>
<table class="playlist-table">
<thead>
<tr>
@@ -143,34 +643,25 @@
</table>
</div>
</div>
<!-- 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>
Injected Spotify Playlists
<div class="actions">
<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>
<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 injected into Jellyfin 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>
@@ -193,7 +684,7 @@
</tbody>
</table>
</div>
<!-- Manual Track Mappings Section -->
<div class="card">
<h2>
@@ -203,19 +694,16 @@
</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">
@@ -238,7 +726,7 @@
</tbody>
</table>
</div>
<!-- Missing Tracks Section -->
<div class="card">
<h2>
@@ -248,15 +736,12 @@
</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">
@@ -278,7 +763,7 @@
</tbody>
</table>
</div>
<!-- Kept Downloads Section -->
<div class="card">
<h2>
@@ -290,17 +775,14 @@
<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">
@@ -323,7 +805,7 @@
</table>
</div>
</div>
<!-- Configuration Tab -->
<div class="tab-content" id="tab-config">
<div class="card">
@@ -332,50 +814,42 @@
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
@@ -384,25 +858,22 @@
</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);">
<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 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>
@@ -411,90 +882,80 @@
<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>
<div class="card">
<h2>Deezer Settings</h2>
<div class="config-section">
<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>
<div class="card">
<h2>SquidWTF / Tidal Settings</h2>
<div class="config-section">
<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', '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>
<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>
<div class="card">
<h2>MusicBrainz Settings</h2>
<div class="config-section">
<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>
<div class="card">
<h2>Qobuz Settings</h2>
<div class="config-section">
<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>
<div class="card">
<h2>Jellyfin Settings</h2>
<div class="config-section">
@@ -506,32 +967,28 @@
<div class="config-item">
<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 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>
<div class="card">
<h2>Library Settings</h2>
<div class="config-section">
<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>
@@ -540,89 +997,77 @@
</div>
</div>
</div>
<div class="card">
<h2>Spotify Import Settings</h2>
<div class="config-section">
<div class="config-item">
<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>
<button onclick="openEditSetting('SPOTIFY_IMPORT_ENABLED', 'Spotify Import Enabled', 'toggle')">Edit</button>
</div>
<div class="config-item">
<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>
<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.
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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<button onclick="openEditCacheSetting('ProxyImagesDays', 'Proxy Images Cache (days)', 'How long to cache proxied Jellyfin images')">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>Configuration Backup</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
@@ -631,11 +1076,10 @@
<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>
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
<h2 style="color: var(--error);">Danger Zone</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
@@ -647,7 +1091,7 @@
</div>
</div>
</div>
<!-- API Analytics Tab -->
<div class="tab-content" id="tab-endpoints">
<div class="card">
@@ -659,45 +1103,34 @@
</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>
<option value="500">Top 500</option>
</select>
</div>
<div style="max-height: 600px; overflow-y: auto;">
<table class="playlist-table">
<thead>
@@ -718,29 +1151,27 @@
</table>
</div>
</div>
<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>
<ul style="margin-top: 8px; margin-left: 20px;">
<li><code>/Users/{userId}/Items</code> - Browse library items</li>
<li><code>/Items/{itemId}</code> - Get item details</li>
<li><code>/Audio/{itemId}/stream</code> - Stream audio</li>
<li><code>/Sessions/Playing</code> - Report playback status</li>
<li><code>/Search/Hints</code> - Search functionality</li>
</ul>
<ul style="margin-top: 8px; margin-left: 20px;">
<li><code>/Users/{userId}/Items</code> - Browse library items</li>
<li><code>/Items/{itemId}</code> - Get item details</li>
<li><code>/Audio/{itemId}/stream</code> - Stream audio</li>
<li><code>/Sessions/Playing</code> - Report playback status</li>
<li><code>/Search/Hints</code> - Search functionality</li>
</ul>
</p>
</div>
</div>
</div>
<!-- Add Playlist Modal -->
<div class="modal" id="add-playlist-modal">
<div class="modal-content">
@@ -759,7 +1190,7 @@
</div>
</div>
</div>
<!-- Edit Setting Modal -->
<div class="modal" id="edit-setting-modal">
<div class="modal-content">
@@ -777,7 +1208,7 @@
</div>
</div>
</div>
<!-- Track List Modal -->
<div class="modal" id="tracks-modal">
<div class="modal-content" style="max-width: 90%; width: 90%;">
@@ -792,16 +1223,15 @@
</div>
</div>
</div>
<!-- Manual Track Mapping Modal -->
<div class="modal" id="manual-map-modal">
<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
Jellyfin mapping modal 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 -->
<div class="form-group">
<label>Spotify Track (Position <span id="map-position"></span>)</label>
@@ -810,7 +1240,7 @@
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
</div>
</div>
<!-- External Mapping Section -->
<div id="external-mapping-section">
<div class="form-group">
@@ -823,8 +1253,7 @@
</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>
@@ -832,7 +1261,7 @@
</small>
</div>
</div>
<input type="hidden" id="map-playlist-name">
<input type="hidden" id="map-spotify-id">
<div class="modal-actions">
@@ -841,7 +1270,7 @@
</div>
</div>
</div>
<!-- Local Jellyfin Track Mapping Modal -->
<div class="modal" id="local-map-modal">
<div class="modal-content" style="max-width: 700px;">
@@ -849,7 +1278,7 @@
<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>
@@ -858,50 +1287,46 @@
<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>
<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;">
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.
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>
<!-- 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>
<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>
@@ -912,48 +1337,43 @@
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">
<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
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;">
<input type="text" id="link-sync-schedule" placeholder="0 8 * * 1" value="0 8 * * 1" 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>
Default: <code>0 8 * * 1</code> = 8 AM every Monday<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>
<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>
</div>
</div>
</div>
<!-- Lyrics ID Mapping Modal -->
<div class="modal" id="lyrics-map-modal">
<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 -->
<div class="form-group">
<label>Track</label>
@@ -963,36 +1383,2211 @@
<small style="color: var(--text-secondary);" id="lyrics-map-album"></small>
</div>
</div>
<!-- Lyrics ID Input -->
<div class="form-group">
<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>
<input type="hidden" id="lyrics-map-artist-value">
<input type="hidden" id="lyrics-map-title-value">
<input type="hidden" id="lyrics-map-album-value">
<input type="hidden" id="lyrics-map-duration">
<div class="modal-actions">
<button onclick="closeModal('lyrics-map-modal')">Cancel</button>
<button class="primary" onclick="saveLyricsMapping()" id="lyrics-map-save-btn">Save Mapping</button>
</div>
</div>
</div>
<!-- Restart Overlay -->
<div class="restart-overlay" id="restart-overlay">
<div class="spinner-large"></div>
<h2>Restarting Container</h2>
<p id="restart-status">Applying configuration changes...</p>
</div>
<script type="module" src="js/main.js"></script>
</html>
<script>
// Current edit setting state
let currentEditKey = null;
let currentEditType = null;
let currentEditOptions = null;
// 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="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 => {
// 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} 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>`
: '';
// 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}%`);
const syncSchedule = p.syncSchedule || '0 8 * * 1';
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 (uses cached Spotify data)">Re-match Local</button>
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')" title="Rebuild when Spotify playlist changed (fetches fresh data)" 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('');
} 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();
// Core settings
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';
// Show/hide cache duration based on storage mode
const cacheDurationRow = document.getElementById('cache-duration-row');
if (cacheDurationRow) {
cacheDurationRow.style.display = data.library?.storageMode === 'Cache' ? 'grid' : 'none';
}
// 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
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' hours';
// Cache settings
if (data.cache) {
document.getElementById('config-cache-search').textContent = data.cache.searchResultsMinutes || '120';
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';
}
} 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>';
}
}
let currentLinkMode = 'select'; // 'select' or 'manual'
let spotifyUserPlaylists = []; // Cache of user playlists
function switchLinkMode(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');
}
}
async function fetchSpotifyUserPlaylists() {
try {
const res = await fetch('/api/admin/spotify/user-playlists');
if (!res.ok) {
const error = await res.json();
console.error('Failed to fetch Spotify playlists:', res.status, error);
// Show user-friendly error message
if (res.status === 429) {
showToast('Spotify rate limit reached. Please wait a moment and try again.', 'warning', 5000);
} else if (res.status === 401) {
showToast('Spotify authentication failed. Check your sp_dc cookie.', 'error', 5000);
}
return [];
}
const data = await res.json();
return data.playlists || [];
} catch (error) {
console.error('Failed to fetch Spotify playlists:', error);
return [];
}
}
async function openLinkPlaylist(jellyfinId, name) {
document.getElementById('link-jellyfin-id').value = jellyfinId;
document.getElementById('link-jellyfin-name').value = name;
document.getElementById('link-spotify-id').value = '';
// Reset to select mode
switchLinkMode('select');
// Fetch user playlists if not already cached
if (spotifyUserPlaylists.length === 0) {
const select = document.getElementById('link-spotify-select');
select.innerHTML = '<option value="">Loading playlists...</option>';
spotifyUserPlaylists = await fetchSpotifyUserPlaylists();
// Filter out already-linked playlists
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
if (availablePlaylists.length === 0) {
if (spotifyUserPlaylists.length > 0) {
select.innerHTML = '<option value="">All your playlists are already linked</option>';
} else {
select.innerHTML = '<option value="">No playlists found or Spotify not configured</option>';
}
// Switch to manual mode if no available playlists
switchLinkMode('manual');
} else {
// Populate dropdown with only unlinked playlists
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
availablePlaylists.map(p =>
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
).join('');
}
} else {
// Re-filter in case playlists were linked since last fetch
const select = document.getElementById('link-spotify-select');
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
if (availablePlaylists.length === 0) {
select.innerHTML = '<option value="">All your playlists are already linked</option>';
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('');
}
}
openModal('link-playlist-modal');
}
async function linkPlaylist() {
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();
// Validate sync schedule (basic cron format check)
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;
}
// Get Spotify ID based on current mode
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;
}
}
// 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,
syncSchedule: syncSchedule
})
});
const data = await res.json();
if (res.ok) {
showToast('Playlist linked!', 'success');
showRestartBanner();
closeModal('link-playlist-modal');
// Clear the Spotify playlists cache so it refreshes next time
spotifyUserPlaylists = [];
// 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();
// Clear the Spotify playlists cache so it refreshes next time
spotifyUserPlaylists = [];
// 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(`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 {
// Show warning banner
document.getElementById('matching-warning-banner').style.display = 'block';
showToast(`Rebuilding ${name} from scratch...`, '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}`, '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 rebuild playlist', '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(`Re-matching local tracks for ${name}...`, 'info');
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 re-match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
} catch (error) {
showToast('Failed to re-match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
}
async function matchAllPlaylists() {
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 {
// 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 editPlaylistSchedule(playlistName, currentSchedule) {
const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\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;
// Validate cron format
const cronParts = newSchedule.trim().split(/\s+/);
if (cronParts.length !== 5) {
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
return;
}
try {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ syncSchedule: newSchedule.trim() })
});
if (res.ok) {
showToast('Sync schedule updated!', 'success');
showRestartBanner();
fetchPlaylists();
} else {
const error = await res.json();
showToast(error.error || 'Failed to update schedule', 'error');
}
} catch (error) {
console.error('Failed to update schedule:', error);
showToast('Failed to update schedule', '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');
if (!res.ok) {
console.error('Failed to fetch tracks:', res.status, res.statusText);
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + res.status + ' ' + res.statusText + '</p>';
return;
}
const data = await res.json();
console.log('Tracks data received:', data);
if (!data || !data.tracks) {
console.error('Invalid data structure:', data);
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 => {
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 info" 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:rgba(245, 158, 11, 0.2);color:#f59e0b;"><span class="status-dot" style="background:#f59e0b;"></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) {
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>';
}
}
// 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');
}
}
// Cache setting editor (uses appsettings.json instead of .env)
function openEditCacheSetting(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');
}
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 local Jellyfin mapping modal
function openManualMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('local-map-playlist-name').value = playlistName;
document.getElementById('local-map-position').textContent = position + 1;
document.getElementById('local-map-spotify-title').textContent = title;
document.getElementById('local-map-spotify-artist').textContent = artist;
document.getElementById('local-map-spotify-id').value = spotifyId;
// Pre-fill search with track info
document.getElementById('local-map-search').value = `${title} ${artist}`;
// Reset fields
document.getElementById('local-map-results').innerHTML = '';
document.getElementById('local-map-jellyfin-id').value = '';
document.getElementById('local-map-save-btn').disabled = true;
openModal('local-map-modal');
}
// Open external mapping modal
function openExternalMap(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');
}
// Search Jellyfin tracks for local mapping
async function searchJellyfinTracks() {
const query = document.getElementById('local-map-search').value.trim();
if (!query) {
showToast('Please enter a search query', 'error');
return;
}
const resultsDiv = document.getElementById('local-map-results');
resultsDiv.innerHTML = '<p style="text-align:center;padding:20px;">Searching...</p>';
try {
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
const data = await res.json();
if (!res.ok) {
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
return;
}
if (!data.tracks || data.tracks.length === 0) {
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">No tracks found</p>';
return;
}
resultsDiv.innerHTML = data.tracks.map(track => `
<div style="padding: 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: background 0.2s;"
onclick="selectJellyfinTrack('${escapeJs(track.id)}', '${escapeJs(track.name)}', '${escapeJs(track.artist)}')"
onmouseover="this.style.background='var(--bg-primary)'"
onmouseout="this.style.background='transparent'">
<strong>${escapeHtml(track.name)}</strong><br>
<span style="color: var(--text-secondary); font-size: 0.9em;">${escapeHtml(track.artist)}</span>
${track.album ? '<br><span style="color: var(--text-secondary); font-size: 0.85em;">' + escapeHtml(track.album) + '</span>' : ''}
</div>
`).join('');
} catch (error) {
console.error('Search error:', error);
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
}
}
// Select a Jellyfin track for mapping
function selectJellyfinTrack(jellyfinId, name, artist) {
document.getElementById('local-map-jellyfin-id').value = jellyfinId;
document.getElementById('local-map-save-btn').disabled = false;
// Highlight selected track
document.querySelectorAll('#local-map-results > div').forEach(div => {
div.style.background = 'transparent';
div.style.border = '1px solid var(--border)';
});
event.target.closest('div').style.background = 'var(--primary)';
event.target.closest('div').style.border = '1px solid var(--primary)';
}
// Save local Jellyfin mapping
async function saveLocalMapping() {
const playlistName = document.getElementById('local-map-playlist-name').value;
const spotifyId = document.getElementById('local-map-spotify-id').value;
const jellyfinId = document.getElementById('local-map-jellyfin-id').value;
if (!jellyfinId) {
showToast('Please select a Jellyfin track', 'error');
return;
}
const requestBody = {
spotifyId,
jellyfinId
};
// Show loading state
const saveBtn = document.getElementById('local-map-save-btn');
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
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);
if (res.ok) {
showToast('Track mapped successfully!', 'success');
closeModal('local-map-modal');
// Refresh the tracks view if it's open
const tracksModal = document.getElementById('tracks-modal');
if (tracksModal.style.display === 'flex') {
await viewTracks(playlistName);
}
} else {
const data = await res.json();
showToast(data.error || 'Failed to save mapping', 'error');
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
} catch (error) {
if (error.name === 'AbortError') {
showToast('Request timed out. The mapping may still be processing.', 'warning');
} else {
showToast('Failed to save mapping', 'error');
}
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
// Save manual mapping (external only) - kept for backward compatibility
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>
-329
View File
@@ -1,329 +0,0 @@
// 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();
}
-401
View File
@@ -1,401 +0,0 @@
// 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');
}
}
-890
View File
@@ -1,890 +0,0 @@
// 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');
-17
View File
@@ -1,17 +0,0 @@
// 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);
});
});
}
-375
View File
@@ -1,375 +0,0 @@
// 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;
}
}
}
-63
View File
@@ -1,63 +0,0 @@
// 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;
}
-411
View File
@@ -1,411 +0,0 @@
<!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>
-362
View File
@@ -1,362 +0,0 @@
// 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();
});
-507
View File
@@ -1,507 +0,0 @@
: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); }
}