mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
13 Commits
7cee0911b6
...
35c125d042
| Author | SHA1 | Date | |
|---|---|---|---|
|
35c125d042
|
|||
|
b12c971968
|
|||
|
8f051ad413
|
|||
|
6c1a578b35
|
|||
|
8ab2923493
|
|||
|
42b4e0e399
|
|||
|
f03aa0be35
|
|||
|
440ef9850f
|
|||
|
c9b44dea43
|
|||
|
3a9d00dcdb
|
|||
|
2389b80733
|
|||
|
b99a199ef3
|
|||
|
64e2004bdc
|
@@ -88,8 +88,6 @@ public class AdminController : ControllerBase
|
|||||||
_envFilePath = _environment.IsDevelopment()
|
_envFilePath = _environment.IsDevelopment()
|
||||||
? Path.Combine(_environment.ContentRootPath, "..", ".env")
|
? Path.Combine(_environment.ContentRootPath, "..", ".env")
|
||||||
: "/app/.env";
|
: "/app/.env";
|
||||||
|
|
||||||
_logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<string> DecodeSquidWtfUrls()
|
private static List<string> DecodeSquidWtfUrls()
|
||||||
@@ -469,30 +467,53 @@ public class AdminController : ControllerBase
|
|||||||
foreach (var track in spotifyTracks)
|
foreach (var track in spotifyTracks)
|
||||||
{
|
{
|
||||||
var isLocal = false;
|
var isLocal = false;
|
||||||
|
var hasExternalMapping = false;
|
||||||
|
|
||||||
if (localTracks.Count > 0)
|
// 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))
|
||||||
{
|
{
|
||||||
var bestMatch = localTracks
|
// Manual Jellyfin mapping exists - this track is definitely local
|
||||||
.Select(local => new
|
isLocal = true;
|
||||||
{
|
}
|
||||||
Local = local,
|
else
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
{
|
||||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
// Check for external manual mapping
|
||||||
})
|
var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}";
|
||||||
.Select(x => new
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
{
|
|
||||||
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 (!string.IsNullOrEmpty(externalMappingJson))
|
||||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
|
||||||
{
|
{
|
||||||
isLocal = true;
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,8 +523,8 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Check if external track is matched
|
// Check if external track is matched (either manual mapping or auto-matched)
|
||||||
if (matchedSpotifyIds.Contains(track.SpotifyId))
|
if (hasExternalMapping || matchedSpotifyIds.Contains(track.SpotifyId))
|
||||||
{
|
{
|
||||||
externalMatchedCount++;
|
externalMatchedCount++;
|
||||||
}
|
}
|
||||||
@@ -547,54 +568,6 @@ public class AdminController : ControllerBase
|
|||||||
_logger.LogWarning("Playlist {Name} has no JellyfinId configured", config.Name);
|
_logger.LogWarning("Playlist {Name} has no JellyfinId configured", config.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get lyrics completion status
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
|
||||||
if (tracks.Count > 0)
|
|
||||||
{
|
|
||||||
var lyricsWithCount = 0;
|
|
||||||
var lyricsWithoutCount = 0;
|
|
||||||
|
|
||||||
foreach (var track in tracks)
|
|
||||||
{
|
|
||||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
|
||||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(existingLyrics))
|
|
||||||
{
|
|
||||||
lyricsWithCount++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
lyricsWithoutCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
playlistInfo["lyricsTotal"] = tracks.Count;
|
|
||||||
playlistInfo["lyricsCached"] = lyricsWithCount;
|
|
||||||
playlistInfo["lyricsMissing"] = lyricsWithoutCount;
|
|
||||||
playlistInfo["lyricsPercentage"] = tracks.Count > 0
|
|
||||||
? (int)Math.Round((double)lyricsWithCount / tracks.Count * 100)
|
|
||||||
: 0;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
playlistInfo["lyricsTotal"] = 0;
|
|
||||||
playlistInfo["lyricsCached"] = 0;
|
|
||||||
playlistInfo["lyricsMissing"] = 0;
|
|
||||||
playlistInfo["lyricsPercentage"] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to get lyrics completion for playlist {Name}", config.Name);
|
|
||||||
playlistInfo["lyricsTotal"] = 0;
|
|
||||||
playlistInfo["lyricsCached"] = 0;
|
|
||||||
playlistInfo["lyricsMissing"] = 0;
|
|
||||||
playlistInfo["lyricsPercentage"] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
playlists.Add(playlistInfo);
|
playlists.Add(playlistInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1450,6 +1423,11 @@ public class AdminController : ControllerBase
|
|||||||
userId = _jellyfinSettings.UserId ?? "(not set)",
|
userId = _jellyfinSettings.UserId ?? "(not set)",
|
||||||
libraryId = _jellyfinSettings.LibraryId
|
libraryId = _jellyfinSettings.LibraryId
|
||||||
},
|
},
|
||||||
|
library = new
|
||||||
|
{
|
||||||
|
downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads",
|
||||||
|
keptPath = _configuration["Library:KeptPath"] ?? "/app/kept"
|
||||||
|
},
|
||||||
deezer = new
|
deezer = new
|
||||||
{
|
{
|
||||||
arl = MaskValue(_deezerSettings.Arl, showLast: 8),
|
arl = MaskValue(_deezerSettings.Arl, showLast: 8),
|
||||||
@@ -2016,8 +1994,13 @@ public class AdminController : ControllerBase
|
|||||||
var isConfigured = configuredPlaylist != null;
|
var isConfigured = configuredPlaylist != null;
|
||||||
var linkedSpotifyId = configuredPlaylist?.Id;
|
var linkedSpotifyId = configuredPlaylist?.Id;
|
||||||
|
|
||||||
// Fetch track details to categorize local vs external
|
// Only fetch detailed track stats for configured Spotify playlists
|
||||||
var trackStats = await GetPlaylistTrackStats(id!);
|
// This avoids expensive queries for large non-Spotify playlists
|
||||||
|
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
||||||
|
if (isConfigured)
|
||||||
|
{
|
||||||
|
trackStats = await GetPlaylistTrackStats(id!);
|
||||||
|
}
|
||||||
|
|
||||||
playlists.Add(new
|
playlists.Add(new
|
||||||
{
|
{
|
||||||
@@ -3267,7 +3250,6 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
|
||||||
|
|
||||||
public class ManualMappingRequest
|
public class ManualMappingRequest
|
||||||
{
|
{
|
||||||
@@ -3323,36 +3305,44 @@ public class LinkPlaylistRequest
|
|||||||
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// GET /api/admin/downloads
|
/// GET /api/admin/downloads
|
||||||
/// Lists all downloaded files in the downloads directory
|
/// Lists all downloaded files in the KEPT folder only (favorited tracks)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("downloads")]
|
[HttpGet("downloads")]
|
||||||
public IActionResult GetDownloads()
|
public IActionResult GetDownloads()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads";
|
var keptPath = _configuration["Library:KeptPath"] ?? "/app/kept";
|
||||||
|
|
||||||
if (!Directory.Exists(downloadPath))
|
_logger.LogInformation("📂 Checking kept folder: {Path}", keptPath);
|
||||||
|
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
|
||||||
|
|
||||||
|
if (!Directory.Exists(keptPath))
|
||||||
{
|
{
|
||||||
return Ok(new { files = new List<object>(), totalSize = 0 });
|
_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>();
|
var files = new List<object>();
|
||||||
long totalSize = 0;
|
long totalSize = 0;
|
||||||
|
|
||||||
// Recursively get all audio files
|
// Recursively get all audio files from kept folder
|
||||||
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
||||||
var allFiles = Directory.GetFiles(downloadPath, "*.*", SearchOption.AllDirectories)
|
|
||||||
|
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
||||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
_logger.LogInformation("📂 Found {Count} audio files in kept folder", allFiles.Count);
|
||||||
|
|
||||||
foreach (var filePath in allFiles)
|
foreach (var filePath in allFiles)
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("📂 Processing file: {Path}", filePath);
|
||||||
|
|
||||||
var fileInfo = new FileInfo(filePath);
|
var fileInfo = new FileInfo(filePath);
|
||||||
var relativePath = Path.GetRelativePath(downloadPath, filePath);
|
var relativePath = Path.GetRelativePath(keptPath, filePath);
|
||||||
|
|
||||||
// Parse artist/album/track from path structure
|
// Parse artist/album/track from path structure
|
||||||
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||||
@@ -3376,6 +3366,8 @@ public class LinkPlaylistRequest
|
|||||||
totalSize += fileInfo.Length;
|
totalSize += fileInfo.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("📂 Returning {Count} kept files, total size: {Size}", files.Count, FormatFileSize(totalSize));
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName),
|
files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName),
|
||||||
@@ -3386,14 +3378,14 @@ public class LinkPlaylistRequest
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to list downloads");
|
_logger.LogError(ex, "Failed to list kept downloads");
|
||||||
return StatusCode(500, new { error = "Failed to list downloads" });
|
return StatusCode(500, new { error = "Failed to list kept downloads" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// DELETE /api/admin/downloads
|
/// DELETE /api/admin/downloads
|
||||||
/// Deletes a specific downloaded file
|
/// Deletes a specific kept file and cleans up empty folders
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpDelete("downloads")]
|
[HttpDelete("downloads")]
|
||||||
public IActionResult DeleteDownload([FromQuery] string path)
|
public IActionResult DeleteDownload([FromQuery] string path)
|
||||||
@@ -3405,54 +3397,59 @@ public class LinkPlaylistRequest
|
|||||||
return BadRequest(new { error = "Path is required" });
|
return BadRequest(new { error = "Path is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads";
|
var keptPath = _configuration["Library:KeptPath"] ?? "/app/kept";
|
||||||
var fullPath = Path.Combine(downloadPath, path);
|
var fullPath = Path.Combine(keptPath, path);
|
||||||
|
|
||||||
// Security: Ensure the path is within the download directory
|
_logger.LogInformation("🗑️ Delete request for: {Path}", fullPath);
|
||||||
|
|
||||||
|
// Security: Ensure the path is within the kept directory
|
||||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||||
var normalizedDownloadPath = Path.GetFullPath(downloadPath);
|
var normalizedKeptPath = Path.GetFullPath(keptPath);
|
||||||
|
|
||||||
if (!normalizedFullPath.StartsWith(normalizedDownloadPath))
|
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning("🗑️ Invalid path (outside kept folder): {Path}", normalizedFullPath);
|
||||||
return BadRequest(new { error = "Invalid path" });
|
return BadRequest(new { error = "Invalid path" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!System.IO.File.Exists(fullPath))
|
if (!System.IO.File.Exists(fullPath))
|
||||||
{
|
{
|
||||||
|
_logger.LogWarning("🗑️ File not found: {Path}", fullPath);
|
||||||
return NotFound(new { error = "File not found" });
|
return NotFound(new { error = "File not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
System.IO.File.Delete(fullPath);
|
System.IO.File.Delete(fullPath);
|
||||||
_logger.LogInformation("Deleted download: {Path}", path);
|
_logger.LogInformation("🗑️ Deleted file: {Path}", fullPath);
|
||||||
|
|
||||||
// Clean up empty directories
|
// Clean up empty directories (Album folder, then Artist folder if empty)
|
||||||
var directory = Path.GetDirectoryName(fullPath);
|
var directory = Path.GetDirectoryName(fullPath);
|
||||||
while (directory != null && directory != downloadPath)
|
while (directory != null && directory != keptPath && directory.StartsWith(keptPath))
|
||||||
{
|
{
|
||||||
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
|
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
|
||||||
{
|
{
|
||||||
Directory.Delete(directory);
|
Directory.Delete(directory);
|
||||||
_logger.LogDebug("Deleted empty directory: {Dir}", directory);
|
_logger.LogInformation("🗑️ Deleted empty directory: {Dir}", directory);
|
||||||
|
directory = Path.GetDirectoryName(directory);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("🗑️ Directory not empty or doesn't exist, stopping cleanup: {Dir}", directory);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
directory = Path.GetDirectoryName(directory);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new { success = true, message = "File deleted successfully" });
|
return Ok(new { success = true, message = "File deleted successfully" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to delete download: {Path}", path);
|
_logger.LogError(ex, "Failed to delete file: {Path}", path);
|
||||||
return StatusCode(500, new { error = "Failed to delete file" });
|
return StatusCode(500, new { error = "Failed to delete file" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// GET /api/admin/downloads/file
|
/// GET /api/admin/downloads/file
|
||||||
/// Downloads a specific file
|
/// Downloads a specific file from the kept folder
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("downloads/file")]
|
[HttpGet("downloads/file")]
|
||||||
public IActionResult DownloadFile([FromQuery] string path)
|
public IActionResult DownloadFile([FromQuery] string path)
|
||||||
@@ -3464,14 +3461,14 @@ public class LinkPlaylistRequest
|
|||||||
return BadRequest(new { error = "Path is required" });
|
return BadRequest(new { error = "Path is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "./downloads";
|
var keptPath = _configuration["Library:KeptPath"] ?? "/app/kept";
|
||||||
var fullPath = Path.Combine(downloadPath, path);
|
var fullPath = Path.Combine(keptPath, path);
|
||||||
|
|
||||||
// Security: Ensure the path is within the download directory
|
// Security: Ensure the path is within the kept directory
|
||||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||||
var normalizedDownloadPath = Path.GetFullPath(downloadPath);
|
var normalizedKeptPath = Path.GetFullPath(keptPath);
|
||||||
|
|
||||||
if (!normalizedFullPath.StartsWith(normalizedDownloadPath))
|
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Invalid path" });
|
return BadRequest(new { error = "Invalid path" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||||
private readonly LrclibService? _lrclibService;
|
private readonly LrclibService? _lrclibService;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<JellyfinController> _logger;
|
private readonly ILogger<JellyfinController> _logger;
|
||||||
|
|
||||||
public JellyfinController(
|
public JellyfinController(
|
||||||
@@ -55,6 +56,7 @@ public class JellyfinController : ControllerBase
|
|||||||
JellyfinProxyService proxyService,
|
JellyfinProxyService proxyService,
|
||||||
JellyfinSessionManager sessionManager,
|
JellyfinSessionManager sessionManager,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
|
IConfiguration configuration,
|
||||||
ILogger<JellyfinController> logger,
|
ILogger<JellyfinController> logger,
|
||||||
ParallelMetadataService? parallelMetadataService = null,
|
ParallelMetadataService? parallelMetadataService = null,
|
||||||
PlaylistSyncService? playlistSyncService = null,
|
PlaylistSyncService? playlistSyncService = null,
|
||||||
@@ -78,6 +80,7 @@ public class JellyfinController : ControllerBase
|
|||||||
_spotifyLyricsService = spotifyLyricsService;
|
_spotifyLyricsService = spotifyLyricsService;
|
||||||
_lrclibService = lrclibService;
|
_lrclibService = lrclibService;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
_configuration = configuration;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_settings.Url))
|
if (string.IsNullOrWhiteSpace(_settings.Url))
|
||||||
@@ -3787,8 +3790,8 @@ public class JellyfinController : ControllerBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build kept folder path: /app/kept/Artist/Album/
|
// Build kept folder path: Artist/Album/
|
||||||
var keptBasePath = "/app/kept";
|
var keptBasePath = _configuration["Library:KeptPath"] ?? "/app/kept";
|
||||||
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||||
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||||
|
|
||||||
@@ -4084,7 +4087,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||||
if (song == null) return;
|
if (song == null) return;
|
||||||
|
|
||||||
var keptBasePath = "/app/kept";
|
var keptBasePath = _configuration["Library:KeptPath"] ?? "/app/kept";
|
||||||
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||||
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||||
|
|
||||||
|
|||||||
@@ -568,8 +568,9 @@ builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingServ
|
|||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
|
||||||
|
|
||||||
// Register lyrics prefetch service (prefetches lyrics for all playlist tracks)
|
// Register lyrics prefetch service (prefetches lyrics for all playlist tracks)
|
||||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
// DISABLED - No need to prefetch since Jellyfin and Spotify lyrics are fast
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Lyrics.LyricsPrefetchService>());
|
// builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||||
|
// builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Lyrics.LyricsPrefetchService>());
|
||||||
|
|
||||||
// Register MusicBrainz service for metadata enrichment
|
// Register MusicBrainz service for metadata enrichment
|
||||||
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
|
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
|
||||||
|
|||||||
@@ -406,9 +406,9 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Directly check if this track has lyrics using the item ID
|
// Directly check if this track has lyrics using the item ID
|
||||||
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsync(
|
// Use internal method with server API key since this is a background operation
|
||||||
|
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal(
|
||||||
$"Audio/{jellyfinItemId}/Lyrics",
|
$"Audio/{jellyfinItemId}/Lyrics",
|
||||||
null,
|
|
||||||
null);
|
null);
|
||||||
|
|
||||||
if (lyricsResult != null && lyricsStatusCode == 200)
|
if (lyricsResult != null && lyricsStatusCode == 200)
|
||||||
@@ -455,7 +455,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
["limit"] = "5" // Get a few results to find best match
|
["limit"] = "5" // Get a few results to find best match
|
||||||
};
|
};
|
||||||
|
|
||||||
var (searchResult, statusCode) = await proxyService.GetJsonAsync("Items", searchParams, null);
|
var (searchResult, statusCode) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
|
||||||
|
|
||||||
if (searchResult == null || statusCode != 200)
|
if (searchResult == null || statusCode != 200)
|
||||||
{
|
{
|
||||||
@@ -511,9 +511,9 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if this track has lyrics
|
// Check if this track has lyrics
|
||||||
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsync(
|
// Use internal method with server API key since this is a background operation
|
||||||
|
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal(
|
||||||
$"Audio/{bestMatchId}/Lyrics",
|
$"Audio/{bestMatchId}/Lyrics",
|
||||||
null,
|
|
||||||
null);
|
null);
|
||||||
|
|
||||||
if (lyricsResult != null && lyricsStatusCode == 200)
|
if (lyricsResult != null && lyricsStatusCode == 200)
|
||||||
|
|||||||
@@ -63,15 +63,7 @@ public class SpotifyLyricsService
|
|||||||
// Normalize track ID (remove URI prefix if present)
|
// Normalize track ID (remove URI prefix if present)
|
||||||
spotifyTrackId = ExtractTrackId(spotifyTrackId);
|
spotifyTrackId = ExtractTrackId(spotifyTrackId);
|
||||||
|
|
||||||
// Check cache
|
// NO CACHING - Spotify lyrics come from local Docker container (fast)
|
||||||
var cacheKey = $"spotify:lyrics:{spotifyTrackId}";
|
|
||||||
var cached = await _cache.GetAsync<SpotifyLyricsResult>(cacheKey);
|
|
||||||
if (cached != null)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Returning cached Spotify lyrics for track {TrackId}", spotifyTrackId);
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"{_settings.LyricsApiUrl}/?trackid={spotifyTrackId}&format=id3";
|
var url = $"{_settings.LyricsApiUrl}/?trackid={spotifyTrackId}&format=id3";
|
||||||
@@ -92,8 +84,6 @@ public class SpotifyLyricsService
|
|||||||
|
|
||||||
if (result != null)
|
if (result != null)
|
||||||
{
|
{
|
||||||
// Cache for 30 days (lyrics don't change)
|
|
||||||
await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30));
|
|
||||||
_logger.LogInformation("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
|
_logger.LogInformation("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
|
||||||
spotifyTrackId, result.Lines.Count);
|
spotifyTrackId, result.Lines.Count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -513,6 +513,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||||
|
|
||||||
// Pre-build playlist items cache for instant serving
|
// Pre-build playlist items cache for instant serving
|
||||||
|
// This is what makes the UI show all matched tracks at once
|
||||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
|
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
"EnableExternalPlaylists": true
|
"EnableExternalPlaylists": true
|
||||||
},
|
},
|
||||||
"Library": {
|
"Library": {
|
||||||
"DownloadPath": "./downloads"
|
"DownloadPath": "./downloads",
|
||||||
|
"KeptPath": "/app/kept"
|
||||||
},
|
},
|
||||||
"Qobuz": {
|
"Qobuz": {
|
||||||
"UserAuthToken": "your-qobuz-token",
|
"UserAuthToken": "your-qobuz-token",
|
||||||
|
|||||||
@@ -644,12 +644,18 @@
|
|||||||
|
|
||||||
<!-- Active Playlists Tab -->
|
<!-- Active Playlists Tab -->
|
||||||
<div class="tab-content" id="tab-playlists">
|
<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>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>
|
<h2>
|
||||||
Active Spotify Playlists
|
Active Spotify Playlists
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="matchAllPlaylists()">Match All Tracks</button>
|
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
|
||||||
<button onclick="refreshPlaylists()">Refresh All</button>
|
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
|
||||||
|
<button onclick="refreshAndMatchAll()" title="Clear caches, fetch fresh data from Spotify, and match all tracks. This is a full rebuild and may take several minutes." style="background:var(--accent);border-color:var(--accent);">Refresh & Match All</button>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||||
@@ -920,6 +926,22 @@
|
|||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Kept Path (Favorited)</span>
|
||||||
|
<span class="value" id="config-kept-path">-</span>
|
||||||
|
<button onclick="openEditSetting('LIBRARY_KEPT_PATH', 'Kept Path', 'text')">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Sync Schedule</h2>
|
<h2>Sync Schedule</h2>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
@@ -1180,8 +1202,37 @@
|
|||||||
if (hash) {
|
if (hash) {
|
||||||
switchTab(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
|
// Toast notification
|
||||||
function showToast(message, type = 'success', duration = 3000) {
|
function showToast(message, type = 'success', duration = 3000) {
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
@@ -1321,7 +1372,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPlaylists() {
|
async function fetchPlaylists(silent = false) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/playlists');
|
const res = await fetch('/api/admin/playlists');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -1329,7 +1380,9 @@
|
|||||||
const tbody = document.getElementById('playlist-table-body');
|
const tbody = document.getElementById('playlist-table-body');
|
||||||
|
|
||||||
if (data.playlists.length === 0) {
|
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>';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1398,20 +1451,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
${p.lyricsTotal > 0 ? `
|
<span style="color:var(--text-secondary);font-size:0.85rem;">-</span>
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
|
||||||
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;">
|
|
||||||
<div style="width:${p.lyricsPercentage}%;height:100%;background:${p.lyricsPercentage === 100 ? '#10b981' : '#3b82f6'};transition:width 0.3s;" title="${p.lyricsCached} lyrics cached"></div>
|
|
||||||
</div>
|
|
||||||
<span style="font-size:0.85rem;color:var(--text-secondary);font-weight:500;min-width:40px;">${p.lyricsPercentage}%</span>
|
|
||||||
</div>
|
|
||||||
` : '<span style="color:var(--text-secondary);font-size:0.85rem;">-</span>'}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
||||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
||||||
<button onclick="prefetchLyrics('${escapeJs(p.name)}')">Prefetch Lyrics</button>
|
|
||||||
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
||||||
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -1546,16 +1591,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = missingTracks.map(t => {
|
tbody.innerHTML = missingTracks.map(t => {
|
||||||
|
const artist = (t.artists && t.artists.length > 0) ? t.artists.join(', ') : '';
|
||||||
|
const searchQuery = `${t.title} ${artist}`;
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${escapeHtml(t.playlist)}</strong></td>
|
<td><strong>${escapeHtml(t.playlist)}</strong></td>
|
||||||
<td>${escapeHtml(t.title)}</td>
|
<td>${escapeHtml(t.title)}</td>
|
||||||
<td>${escapeHtml(t.artist)}</td>
|
<td>${escapeHtml(artist)}</td>
|
||||||
<td style="color:var(--text-secondary);">${t.album ? escapeHtml(t.album) : '-'}</td>
|
<td style="color:var(--text-secondary);">${t.album ? escapeHtml(t.album) : '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<button onclick="openMapToLocal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(t.artist)}')"
|
<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>
|
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(t.artist)}')"
|
<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>
|
style="font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1687,6 +1736,10 @@
|
|||||||
document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)';
|
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-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
|
// Sync settings
|
||||||
const syncHour = data.spotifyImport.syncStartHour;
|
const syncHour = data.spotifyImport.syncStartHour;
|
||||||
const syncMin = data.spotifyImport.syncStartMinute;
|
const syncMin = data.spotifyImport.syncStartMinute;
|
||||||
@@ -1894,6 +1947,9 @@
|
|||||||
if (!confirm(`Clear cache and rebuild for "${name}"?\n\nThis will:\n• Clear Redis cache\n• Delete file caches\n• Rebuild with latest Spotify IDs\n\nThis may take a minute.`)) return;
|
if (!confirm(`Clear cache and rebuild for "${name}"?\n\nThis will:\n• Clear Redis cache\n• Delete file caches\n• Rebuild with latest Spotify IDs\n\nThis may take a minute.`)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Show warning banner
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||||
|
|
||||||
showToast(`Clearing cache for ${name}...`, 'info');
|
showToast(`Clearing cache for ${name}...`, 'info');
|
||||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
|
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -1901,17 +1957,26 @@
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast(`✓ ${data.message} (Cleared ${data.clearedKeys} cache keys, ${data.clearedFiles} files)`, 'success', 5000);
|
showToast(`✓ ${data.message} (Cleared ${data.clearedKeys} cache keys, ${data.clearedFiles} files)`, 'success', 5000);
|
||||||
// Refresh the playlists table after a delay to show updated counts
|
// Refresh the playlists table after a delay to show updated counts
|
||||||
setTimeout(fetchPlaylists, 3000);
|
setTimeout(() => {
|
||||||
|
fetchPlaylists();
|
||||||
|
// Hide warning banner after refresh
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to clear cache', 'error');
|
showToast(data.error || 'Failed to clear cache', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('Failed to clear cache', 'error');
|
showToast('Failed to clear cache', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function matchPlaylistTracks(name) {
|
async function matchPlaylistTracks(name) {
|
||||||
try {
|
try {
|
||||||
|
// Show warning banner
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||||
|
|
||||||
showToast(`Matching tracks for ${name}...`, 'success');
|
showToast(`Matching tracks for ${name}...`, 'success');
|
||||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -1919,12 +1984,18 @@
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast(`✓ ${data.message}`, 'success');
|
showToast(`✓ ${data.message}`, 'success');
|
||||||
// Refresh the playlists table after a delay to show updated counts
|
// Refresh the playlists table after a delay to show updated counts
|
||||||
setTimeout(fetchPlaylists, 2000);
|
setTimeout(() => {
|
||||||
|
fetchPlaylists();
|
||||||
|
// Hide warning banner after refresh
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to match tracks', 'error');
|
showToast(data.error || 'Failed to match tracks', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('Failed to match tracks', 'error');
|
showToast('Failed to match tracks', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1932,6 +2003,9 @@
|
|||||||
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Show warning banner
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||||
|
|
||||||
showToast('Matching tracks for all playlists...', 'success');
|
showToast('Matching tracks for all playlists...', 'success');
|
||||||
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -1939,32 +2013,68 @@
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast(`✓ ${data.message}`, 'success');
|
showToast(`✓ ${data.message}`, 'success');
|
||||||
// Refresh the playlists table after a delay to show updated counts
|
// Refresh the playlists table after a delay to show updated counts
|
||||||
setTimeout(fetchPlaylists, 2000);
|
setTimeout(() => {
|
||||||
|
fetchPlaylists();
|
||||||
|
// Hide warning banner after refresh
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to match tracks', 'error');
|
showToast(data.error || 'Failed to match tracks', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('Failed to match tracks', 'error');
|
showToast('Failed to match tracks', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prefetchLyrics(name) {
|
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 {
|
try {
|
||||||
showToast(`Prefetching lyrics for ${name}...`, 'info', 5000);
|
// Show warning banner
|
||||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/prefetch-lyrics`, { method: 'POST' });
|
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();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const summary = `Fetched: ${data.fetched}, Cached: ${data.cached}, Missing: ${data.missing}`;
|
showToast(`✓ Full refresh and match complete!`, 'success', 5000);
|
||||||
showToast(`✓ Lyrics prefetch complete for ${name}. ${summary}`, 'success', 8000);
|
// Refresh the playlists table after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchPlaylists();
|
||||||
|
// Hide warning banner after refresh
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to prefetch lyrics', 'error');
|
showToast(data.error || 'Failed to match tracks', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('Failed to prefetch lyrics', 'error');
|
showToast('Failed to complete refresh and match', 'error');
|
||||||
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function searchProvider(query, provider) {
|
async function searchProvider(query, provider) {
|
||||||
// Use SquidWTF HiFi API with round-robin base URLs for all searches
|
// Use SquidWTF HiFi API with round-robin base URLs for all searches
|
||||||
// Get a random base URL from the backend
|
// Get a random base URL from the backend
|
||||||
|
|||||||
Reference in New Issue
Block a user