mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-25 03:12:54 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
40338ce25f
|
+1
-1
@@ -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.)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user