mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
15 Commits
8fad6d8c4e
...
d9c0b8bb54
| Author | SHA1 | Date | |
|---|---|---|---|
|
d9c0b8bb54
|
|||
|
400ea31477
|
|||
|
b1cab0ddfc
|
|||
|
7cba915c5e
|
|||
|
dfd7d678e7
|
|||
|
4071f6d650
|
|||
|
d045b33afd
|
|||
|
4f74b34b9a
|
|||
|
b7417614b3
|
|||
|
72b1584d51
|
|||
|
4b289e4ddd
|
|||
|
07844cc9c5
|
|||
|
1601b96800
|
|||
|
7db66067f4
|
|||
|
f44d8652b4
|
@@ -8,6 +8,7 @@ using allstarr.Services.Common;
|
|||||||
using allstarr.Filters;
|
using allstarr.Filters;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Runtime;
|
||||||
|
|
||||||
namespace allstarr.Controllers;
|
namespace allstarr.Controllers;
|
||||||
|
|
||||||
@@ -300,9 +301,10 @@ public class AdminController : ControllerBase
|
|||||||
playlistInfo["externalMissing"] = externalMissingCount;
|
playlistInfo["externalMissing"] = externalMissingCount;
|
||||||
playlistInfo["externalTotal"] = externalCount + externalMissingCount;
|
playlistInfo["externalTotal"] = externalCount + externalMissingCount;
|
||||||
playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count;
|
playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count;
|
||||||
|
playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served
|
||||||
|
|
||||||
_logger.LogInformation("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing",
|
_logger.LogInformation("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||||
config.Name, spotifyTracks.Count, localCount, externalCount, externalMissingCount);
|
config.Name, spotifyTracks.Count, localCount, externalCount, externalMissingCount, localCount + externalCount);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -393,9 +395,10 @@ public class AdminController : ControllerBase
|
|||||||
playlistInfo["externalMissing"] = externalMissingCount;
|
playlistInfo["externalMissing"] = externalMissingCount;
|
||||||
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
|
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
|
||||||
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
|
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
|
||||||
|
playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served
|
||||||
|
|
||||||
_logger.LogDebug("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing",
|
_logger.LogDebug("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||||
config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount);
|
config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount, localCount + externalMatchedCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -489,10 +492,18 @@ public class AdminController : ControllerBase
|
|||||||
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
|
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
|
||||||
localTracks.Count, decodedName);
|
localTracks.Count, decodedName);
|
||||||
|
|
||||||
|
// Get matched external tracks cache
|
||||||
|
var matchedTracksKey = $"spotify:matched:ordered:{decodedName}";
|
||||||
|
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||||
|
var matchedSpotifyIds = new HashSet<string>(
|
||||||
|
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||||
|
);
|
||||||
|
|
||||||
// Match Spotify tracks to local tracks by name (fuzzy matching)
|
// Match Spotify tracks to local tracks by name (fuzzy matching)
|
||||||
foreach (var track in spotifyTracks)
|
foreach (var track in spotifyTracks)
|
||||||
{
|
{
|
||||||
var isLocal = false;
|
bool? isLocal = null;
|
||||||
|
string? externalProvider = null;
|
||||||
|
|
||||||
// FIRST: Check for manual mapping (same as SpotifyTrackMatchingService)
|
// FIRST: Check for manual mapping (same as SpotifyTrackMatchingService)
|
||||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||||
@@ -500,35 +511,80 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
{
|
{
|
||||||
// Manual mapping exists - this track is definitely local
|
// Manual Jellyfin mapping exists - this track is definitely local
|
||||||
isLocal = true;
|
isLocal = true;
|
||||||
_logger.LogDebug("✓ Manual mapping found for {Title}: Jellyfin ID {Id}",
|
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
|
||||||
track.Title, manualJellyfinId);
|
track.Title, manualJellyfinId);
|
||||||
}
|
}
|
||||||
else if (localTracks.Count > 0)
|
else
|
||||||
{
|
{
|
||||||
// SECOND: No manual mapping, try fuzzy matching
|
// Check for external manual mapping
|
||||||
var bestMatch = localTracks
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||||
.Select(local => new
|
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
|
||||||
{
|
|
||||||
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 (externalMapping != null)
|
||||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
|
||||||
{
|
{
|
||||||
isLocal = true;
|
try
|
||||||
|
{
|
||||||
|
var provider = externalMapping.provider?.ToString();
|
||||||
|
var externalId = externalMapping.id?.ToString();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||||
|
{
|
||||||
|
// External manual mapping exists
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = provider;
|
||||||
|
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
|
||||||
|
track.Title, (object)provider, (object)externalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (localTracks.Count > 0)
|
||||||
|
{
|
||||||
|
// SECOND: No manual mapping, try fuzzy matching
|
||||||
|
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 not local, check if it's externally matched or missing
|
||||||
|
if (isLocal != true)
|
||||||
|
{
|
||||||
|
if (matchedSpotifyIds.Contains(track.SpotifyId))
|
||||||
|
{
|
||||||
|
// Track is externally matched (search succeeded)
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Track is missing (search failed)
|
||||||
|
isLocal = null;
|
||||||
|
externalProvider = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,9 +599,8 @@ public class AdminController : ControllerBase
|
|||||||
durationMs = track.DurationMs,
|
durationMs = track.DurationMs,
|
||||||
albumArtUrl = track.AlbumArtUrl,
|
albumArtUrl = track.AlbumArtUrl,
|
||||||
isLocal = isLocal,
|
isLocal = isLocal,
|
||||||
// For external tracks, show what will be searched
|
externalProvider = externalProvider,
|
||||||
externalProvider = isLocal ? null : _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
|
searchQuery = isLocal == false ? $"{track.Title} {track.PrimaryArtist}" : null
|
||||||
searchQuery = isLocal ? null : $"{track.Title} {track.PrimaryArtist}"
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,25 +619,84 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: return tracks without local/external status
|
// If we get here, we couldn't get local tracks from Jellyfin
|
||||||
|
// Just return tracks with basic external/missing status based on cache
|
||||||
|
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 mappings
|
||||||
|
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 externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
|
||||||
|
|
||||||
|
if (externalMapping != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var provider = externalMapping.provider?.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(provider))
|
||||||
|
{
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||||
|
{
|
||||||
|
// Track is externally matched (search succeeded)
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Track is missing (search failed)
|
||||||
|
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 == false ? $"{track.Title} {track.PrimaryArtist}" : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
name = decodedName,
|
name = decodedName,
|
||||||
trackCount = spotifyTracks.Count,
|
trackCount = spotifyTracks.Count,
|
||||||
tracks = spotifyTracks.Select(t => new
|
tracks = tracksWithStatus
|
||||||
{
|
|
||||||
position = t.Position,
|
|
||||||
title = t.Title,
|
|
||||||
artists = t.Artists,
|
|
||||||
album = t.Album,
|
|
||||||
isrc = t.Isrc,
|
|
||||||
spotifyId = t.SpotifyId,
|
|
||||||
durationMs = t.DurationMs,
|
|
||||||
albumArtUrl = t.AlbumArtUrl,
|
|
||||||
isLocal = (bool?)null, // Unknown
|
|
||||||
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
|
|
||||||
searchQuery = $"{t.Title} {t.PrimaryArtist}"
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,19 +896,46 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
var decodedName = Uri.UnescapeDataString(name);
|
var decodedName = Uri.UnescapeDataString(name);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(request.SpotifyId) || string.IsNullOrWhiteSpace(request.JellyfinId))
|
if (string.IsNullOrWhiteSpace(request.SpotifyId))
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "SpotifyId and JellyfinId are required" });
|
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
|
try
|
||||||
{
|
{
|
||||||
// Store mapping in cache (you could also persist to a file)
|
if (hasJellyfinMapping)
|
||||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
{
|
||||||
await _cache.SetAsync(mappingKey, request.JellyfinId, TimeSpan.FromDays(365)); // Long TTL
|
// Store Jellyfin mapping in cache
|
||||||
|
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||||
_logger.LogInformation("Manual mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
await _cache.SetAsync(mappingKey, request.JellyfinId!, TimeSpan.FromDays(365));
|
||||||
decodedName, request.SpotifyId, request.JellyfinId);
|
|
||||||
|
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
||||||
|
decodedName, request.SpotifyId, request.JellyfinId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Store external mapping in cache
|
||||||
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||||
|
var externalMapping = new { provider = request.ExternalProvider, id = request.ExternalId };
|
||||||
|
await _cache.SetAsync(externalMappingKey, externalMapping, TimeSpan.FromDays(365));
|
||||||
|
|
||||||
|
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
||||||
|
decodedName, request.SpotifyId, request.ExternalProvider, request.ExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear all related caches to force rebuild
|
// Clear all related caches to force rebuild
|
||||||
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||||
@@ -901,12 +1042,6 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ManualMappingRequest
|
|
||||||
{
|
|
||||||
public string SpotifyId { get; set; } = "";
|
|
||||||
public string JellyfinId { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Trigger track matching for all playlists
|
/// Trigger track matching for all playlists
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1901,6 +2036,405 @@ public class AdminController : ControllerBase
|
|||||||
return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message });
|
return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets detailed memory usage statistics for debugging.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("memory-stats")]
|
||||||
|
public IActionResult GetMemoryStats()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get memory stats BEFORE GC
|
||||||
|
var memoryBeforeGC = GC.GetTotalMemory(false);
|
||||||
|
var gen0Before = GC.CollectionCount(0);
|
||||||
|
var gen1Before = GC.CollectionCount(1);
|
||||||
|
var gen2Before = GC.CollectionCount(2);
|
||||||
|
|
||||||
|
// Force garbage collection to get accurate numbers
|
||||||
|
GC.Collect();
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect();
|
||||||
|
|
||||||
|
var memoryAfterGC = GC.GetTotalMemory(false);
|
||||||
|
var gen0After = GC.CollectionCount(0);
|
||||||
|
var gen1After = GC.CollectionCount(1);
|
||||||
|
var gen2After = GC.CollectionCount(2);
|
||||||
|
|
||||||
|
// Get process memory info
|
||||||
|
var process = System.Diagnostics.Process.GetCurrentProcess();
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
BeforeGC = new {
|
||||||
|
GCMemoryBytes = memoryBeforeGC,
|
||||||
|
GCMemoryMB = Math.Round(memoryBeforeGC / (1024.0 * 1024.0), 2)
|
||||||
|
},
|
||||||
|
AfterGC = new {
|
||||||
|
GCMemoryBytes = memoryAfterGC,
|
||||||
|
GCMemoryMB = Math.Round(memoryAfterGC / (1024.0 * 1024.0), 2)
|
||||||
|
},
|
||||||
|
MemoryFreedMB = Math.Round((memoryBeforeGC - memoryAfterGC) / (1024.0 * 1024.0), 2),
|
||||||
|
ProcessWorkingSetBytes = process.WorkingSet64,
|
||||||
|
ProcessWorkingSetMB = Math.Round(process.WorkingSet64 / (1024.0 * 1024.0), 2),
|
||||||
|
ProcessPrivateMemoryBytes = process.PrivateMemorySize64,
|
||||||
|
ProcessPrivateMemoryMB = Math.Round(process.PrivateMemorySize64 / (1024.0 * 1024.0), 2),
|
||||||
|
ProcessVirtualMemoryBytes = process.VirtualMemorySize64,
|
||||||
|
ProcessVirtualMemoryMB = Math.Round(process.VirtualMemorySize64 / (1024.0 * 1024.0), 2),
|
||||||
|
GCCollections = new {
|
||||||
|
Gen0Before = gen0Before,
|
||||||
|
Gen0After = gen0After,
|
||||||
|
Gen0Triggered = gen0After - gen0Before,
|
||||||
|
Gen1Before = gen1Before,
|
||||||
|
Gen1After = gen1After,
|
||||||
|
Gen1Triggered = gen1After - gen1Before,
|
||||||
|
Gen2Before = gen2Before,
|
||||||
|
Gen2After = gen2After,
|
||||||
|
Gen2Triggered = gen2After - gen2Before
|
||||||
|
},
|
||||||
|
GCMode = GCSettings.IsServerGC ? "Server" : "Workstation",
|
||||||
|
GCLatencyMode = GCSettings.LatencyMode.ToString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forces garbage collection to free up memory (emergency use only).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("force-gc")]
|
||||||
|
public IActionResult ForceGarbageCollection()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var memoryBefore = GC.GetTotalMemory(false);
|
||||||
|
var processBefore = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
|
||||||
|
|
||||||
|
// Force full garbage collection
|
||||||
|
GC.Collect(2, GCCollectionMode.Forced);
|
||||||
|
GC.WaitForPendingFinalizers();
|
||||||
|
GC.Collect(2, GCCollectionMode.Forced);
|
||||||
|
|
||||||
|
var memoryAfter = GC.GetTotalMemory(false);
|
||||||
|
var processAfter = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
Timestamp = DateTime.UtcNow,
|
||||||
|
MemoryFreedMB = Math.Round((memoryBefore - memoryAfter) / (1024.0 * 1024.0), 2),
|
||||||
|
ProcessMemoryFreedMB = Math.Round((processBefore - processAfter) / (1024.0 * 1024.0), 2),
|
||||||
|
BeforeGCMB = Math.Round(memoryBefore / (1024.0 * 1024.0), 2),
|
||||||
|
AfterGCMB = Math.Round(memoryAfter / (1024.0 * 1024.0), 2),
|
||||||
|
BeforeProcessMB = Math.Round(processBefore / (1024.0 * 1024.0), 2),
|
||||||
|
AfterProcessMB = Math.Round(processAfter / (1024.0 * 1024.0), 2)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper method to trigger GC after large file operations to prevent memory leaks.
|
||||||
|
/// </summary>
|
||||||
|
private static void TriggerGCAfterLargeOperation(int sizeInBytes)
|
||||||
|
{
|
||||||
|
// Only trigger GC for files larger than 1MB to avoid performance impact
|
||||||
|
if (sizeInBytes > 1024 * 1024)
|
||||||
|
{
|
||||||
|
// Suggest GC collection for large objects (they go to LOH and aren't collected as frequently)
|
||||||
|
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Spotify Admin Endpoints
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("spotify/sync")]
|
||||||
|
public async Task<IActionResult> TriggerSpotifySync([FromServices] IEnumerable<IHostedService> hostedServices)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_spotifyImportSettings.Enabled)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Spotify Import is not enabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Manual Spotify sync triggered via admin endpoint");
|
||||||
|
|
||||||
|
// Find the SpotifyMissingTracksFetcher service
|
||||||
|
var fetcherService = hostedServices
|
||||||
|
.OfType<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (fetcherService == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "SpotifyMissingTracksFetcher service not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger the sync in background
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Use reflection to call the private ExecuteOnceAsync method
|
||||||
|
var method = fetcherService.GetType().GetMethod("ExecuteOnceAsync",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
|
||||||
|
if (method != null)
|
||||||
|
{
|
||||||
|
await (Task)method.Invoke(fetcherService, new object[] { CancellationToken.None })!;
|
||||||
|
_logger.LogInformation("Manual Spotify sync completed successfully");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyMissingTracksFetcher");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during manual Spotify sync");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
message = "Spotify sync started in background",
|
||||||
|
timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error triggering Spotify sync");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manual trigger endpoint to force Spotify track matching.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("spotify/match")]
|
||||||
|
public async Task<IActionResult> TriggerSpotifyMatch([FromServices] IEnumerable<IHostedService> hostedServices)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_spotifyApiSettings.Enabled)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Spotify API is not enabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Manual Spotify track matching triggered via admin endpoint");
|
||||||
|
|
||||||
|
// Find the SpotifyTrackMatchingService
|
||||||
|
var matchingService = hostedServices
|
||||||
|
.OfType<allstarr.Services.Spotify.SpotifyTrackMatchingService>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (matchingService == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "SpotifyTrackMatchingService not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger matching in background
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Use reflection to call the private ExecuteOnceAsync method
|
||||||
|
var method = matchingService.GetType().GetMethod("ExecuteOnceAsync",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
|
||||||
|
if (method != null)
|
||||||
|
{
|
||||||
|
await (Task)method.Invoke(matchingService, new object[] { CancellationToken.None })!;
|
||||||
|
_logger.LogInformation("Manual Spotify track matching completed successfully");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyTrackMatchingService");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during manual Spotify track matching");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
message = "Spotify track matching started in background",
|
||||||
|
timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error triggering Spotify track matching");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clear Spotify playlist cache to force re-matching.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("spotify/clear-cache")]
|
||||||
|
public async Task<IActionResult> ClearSpotifyCache()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var clearedKeys = new List<string>();
|
||||||
|
|
||||||
|
// Clear Redis cache for all configured playlists
|
||||||
|
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||||
|
{
|
||||||
|
var keys = new[]
|
||||||
|
{
|
||||||
|
$"spotify:playlist:{playlist.Name}",
|
||||||
|
$"spotify:playlist:items:{playlist.Name}",
|
||||||
|
$"spotify:matched:{playlist.Name}"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
await _cache.DeleteAsync(key);
|
||||||
|
clearedKeys.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count);
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
message = "Spotify cache cleared successfully",
|
||||||
|
clearedKeys = clearedKeys,
|
||||||
|
timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error clearing Spotify cache");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Debug Endpoints
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets endpoint usage statistics from the log file.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("debug/endpoint-usage")]
|
||||||
|
public async Task<IActionResult> GetEndpointUsage(
|
||||||
|
[FromQuery] int top = 100,
|
||||||
|
[FromQuery] string? since = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(logFile))
|
||||||
|
{
|
||||||
|
return Ok(new {
|
||||||
|
message = "No endpoint usage data available",
|
||||||
|
endpoints = new object[0]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = await System.IO.File.ReadAllLinesAsync(logFile);
|
||||||
|
var usage = new Dictionary<string, int>();
|
||||||
|
DateTime? sinceDate = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate))
|
||||||
|
{
|
||||||
|
sinceDate = parsedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in lines.Skip(1)) // Skip header
|
||||||
|
{
|
||||||
|
var parts = line.Split(',');
|
||||||
|
if (parts.Length >= 3)
|
||||||
|
{
|
||||||
|
var timestamp = parts[0];
|
||||||
|
var endpoint = parts[1];
|
||||||
|
|
||||||
|
// Filter by date if specified
|
||||||
|
if (sinceDate.HasValue && DateTime.TryParse(timestamp, out var logDate))
|
||||||
|
{
|
||||||
|
if (logDate < sinceDate.Value)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
usage[endpoint] = usage.GetValueOrDefault(endpoint, 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var topEndpoints = usage
|
||||||
|
.OrderByDescending(kv => kv.Value)
|
||||||
|
.Take(top)
|
||||||
|
.Select(kv => new { endpoint = kv.Key, count = kv.Value })
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
totalEndpoints = usage.Count,
|
||||||
|
totalRequests = usage.Values.Sum(),
|
||||||
|
since = since,
|
||||||
|
top = top,
|
||||||
|
endpoints = topEndpoints
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error getting endpoint usage");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the endpoint usage log file.
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("debug/endpoint-usage")]
|
||||||
|
public IActionResult ClearEndpointUsage()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(logFile))
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(logFile);
|
||||||
|
_logger.LogInformation("Cleared endpoint usage log via admin endpoint");
|
||||||
|
|
||||||
|
return Ok(new {
|
||||||
|
message = "Endpoint usage log cleared successfully",
|
||||||
|
timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return Ok(new {
|
||||||
|
message = "No endpoint usage log file found",
|
||||||
|
timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error clearing endpoint usage log");
|
||||||
|
return StatusCode(500, new { error = "Internal server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ConfigUpdateRequest
|
public class ConfigUpdateRequest
|
||||||
|
|||||||
@@ -1455,11 +1455,25 @@ public class JellyfinController : ControllerBase
|
|||||||
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
||||||
userId, itemId, Request.Path);
|
userId, itemId, Request.Path);
|
||||||
|
|
||||||
// External items can't be unfavorited (they're not really favorited in Jellyfin)
|
// External items - remove from kept folder if it exists
|
||||||
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||||
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
|
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Unfavoriting external item {ItemId} - returning success", itemId);
|
_logger.LogInformation("Unfavoriting external item {ItemId} - removing from kept folder", itemId);
|
||||||
|
|
||||||
|
// Remove from kept folder in background
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RemoveExternalTrackFromKeptAsync(itemId, provider!, externalId!);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to remove external track {ItemId} from kept folder", itemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
IsFavorite = false,
|
IsFavorite = false,
|
||||||
@@ -2753,10 +2767,12 @@ public class JellyfinController : ControllerBase
|
|||||||
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
|
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
|
||||||
|
|
||||||
// This is a Spotify playlist - get the actual track count
|
// This is a Spotify playlist - get the actual track count
|
||||||
var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
|
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
|
||||||
|
|
||||||
if (playlistConfig != null)
|
if (playlistConfig != null)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})",
|
||||||
|
playlistId, playlistConfig.Name, playlistConfig.Id);
|
||||||
var playlistName = playlistConfig.Name;
|
var playlistName = playlistConfig.Name;
|
||||||
|
|
||||||
// Get matched external tracks (tracks that were successfully downloaded/matched)
|
// Get matched external tracks (tracks that were successfully downloaded/matched)
|
||||||
@@ -2796,16 +2812,26 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only fetch from Jellyfin if we didn't get count from file cache
|
// Only fetch from Jellyfin if we didn't get count from file cache
|
||||||
if (!itemDict.ContainsKey("ChildCount") || (int)itemDict["ChildCount"]! == 0)
|
if (!itemDict.ContainsKey("ChildCount") ||
|
||||||
|
(itemDict["ChildCount"] is JsonElement childCountElement && childCountElement.GetInt32() == 0) ||
|
||||||
|
(itemDict["ChildCount"] is int childCountInt && childCountInt == 0))
|
||||||
{
|
{
|
||||||
// Get local tracks count from Jellyfin
|
// Get local tracks count from Jellyfin
|
||||||
var localTracksCount = 0;
|
var localTracksCount = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (localTracksResponse, _) = await _proxyService.GetJsonAsync(
|
// Include UserId parameter to avoid 401 Unauthorized
|
||||||
$"Playlists/{playlistId}/Items",
|
var userId = _settings.UserId;
|
||||||
null,
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
|
||||||
Request.Headers);
|
var queryParams = new Dictionary<string, string>();
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
queryParams["UserId"] = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (localTracksResponse, _) = await _proxyService.GetJsonAsyncInternal(
|
||||||
|
playlistItemsUrl,
|
||||||
|
queryParams);
|
||||||
|
|
||||||
if (localTracksResponse != null &&
|
if (localTracksResponse != null &&
|
||||||
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
||||||
@@ -2827,24 +2853,29 @@ public class JellyfinController : ControllerBase
|
|||||||
externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
|
externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total available tracks = what's actually in Jellyfin (local + external matched)
|
// Total available tracks = local tracks in Jellyfin + external matched tracks
|
||||||
// This is what clients should see as the track count
|
// This represents what users will actually hear when playing the playlist
|
||||||
var totalAvailableCount = localTracksCount;
|
var totalAvailableCount = localTracksCount + externalMatchedCount;
|
||||||
|
|
||||||
if (totalAvailableCount > 0)
|
if (totalAvailableCount > 0)
|
||||||
{
|
{
|
||||||
// Update ChildCount to show actual available tracks
|
// Update ChildCount to show actual available tracks
|
||||||
itemDict["ChildCount"] = totalAvailableCount;
|
itemDict["ChildCount"] = totalAvailableCount;
|
||||||
modified = true;
|
modified = true;
|
||||||
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} (actual tracks in Jellyfin)",
|
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
|
||||||
playlistName, totalAvailableCount);
|
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No tracks found in Jellyfin for {Name}", playlistName);
|
_logger.LogWarning("No tracks found for {Name} ({Local} local + {External} external = {Total} total)",
|
||||||
|
playlistName, localTracksCount, externalMatchedCount, totalAvailableCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No playlist config found for Jellyfin ID {JellyfinId} - skipping count update", playlistId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3408,7 +3439,7 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
// Build final track list based on playlist configuration
|
// Build final track list based on playlist configuration
|
||||||
// Local tracks position is configurable per-playlist
|
// Local tracks position is configurable per-playlist
|
||||||
var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
|
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
|
||||||
var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
|
var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
|
||||||
|
|
||||||
var finalTracks = new List<Song>();
|
var finalTracks = new List<Song>();
|
||||||
@@ -3446,7 +3477,14 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get the song metadata first to check if already in kept folder
|
// Check if already favorited (persistent tracking)
|
||||||
|
if (await IsTrackFavoritedAsync(itemId))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Track already favorited (persistent): {ItemId}", itemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the song metadata first to build paths
|
||||||
var song = await _metadataService.GetSongAsync(provider, externalId);
|
var song = await _metadataService.GetSongAsync(provider, externalId);
|
||||||
if (song == null)
|
if (song == null)
|
||||||
{
|
{
|
||||||
@@ -3459,61 +3497,85 @@ public class JellyfinController : ControllerBase
|
|||||||
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));
|
||||||
|
|
||||||
// Check if track already exists in kept folder BEFORE downloading
|
// Check if track already exists in kept folder
|
||||||
// Look for any file matching the song title pattern (any extension)
|
|
||||||
if (Directory.Exists(keptAlbumPath))
|
if (Directory.Exists(keptAlbumPath))
|
||||||
{
|
{
|
||||||
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
||||||
var existingFiles = Directory.GetFiles(keptAlbumPath, $"{sanitizedTitle}.*");
|
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
|
||||||
if (existingFiles.Length > 0)
|
if (existingFiles.Length > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
|
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
|
||||||
|
// Mark as favorited even if we didn't download it
|
||||||
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track not in kept folder - download it
|
// Look for the track in cache folder first
|
||||||
_logger.LogInformation("Downloading track for kept folder: {ItemId}", itemId);
|
var cacheBasePath = "/tmp/allstarr-cache";
|
||||||
string downloadPath;
|
var cacheArtistPath = Path.Combine(cacheBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||||
|
var cacheAlbumPath = Path.Combine(cacheArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||||
|
|
||||||
try
|
string? sourceFilePath = null;
|
||||||
|
|
||||||
|
if (Directory.Exists(cacheAlbumPath))
|
||||||
{
|
{
|
||||||
downloadPath = await _downloadService.DownloadSongAsync(provider, externalId);
|
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
||||||
|
var cacheFiles = Directory.GetFiles(cacheAlbumPath, $"*{sanitizedTitle}*");
|
||||||
|
if (cacheFiles.Length > 0)
|
||||||
|
{
|
||||||
|
sourceFilePath = cacheFiles[0];
|
||||||
|
_logger.LogInformation("Found track in cache folder: {Path}", sourceFilePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
|
// If not in cache, download it first
|
||||||
|
if (sourceFilePath == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
|
_logger.LogInformation("Track not in cache, downloading: {ItemId}", itemId);
|
||||||
return;
|
try
|
||||||
|
{
|
||||||
|
sourceFilePath = await _downloadService.DownloadSongAsync(provider, externalId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the kept folder structure
|
// Create the kept folder structure
|
||||||
Directory.CreateDirectory(keptAlbumPath);
|
Directory.CreateDirectory(keptAlbumPath);
|
||||||
|
|
||||||
// Copy file to kept folder
|
// Copy file to kept folder
|
||||||
var fileName = Path.GetFileName(downloadPath);
|
var fileName = Path.GetFileName(sourceFilePath);
|
||||||
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
|
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
|
||||||
|
|
||||||
// Double-check in case of race condition (multiple favorite clicks)
|
// Double-check in case of race condition (multiple favorite clicks)
|
||||||
if (System.IO.File.Exists(keptFilePath))
|
if (System.IO.File.Exists(keptFilePath))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
|
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
|
||||||
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
System.IO.File.Copy(downloadPath, keptFilePath, overwrite: false);
|
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
|
||||||
_logger.LogInformation("✓ Copied favorited track to kept folder: {Path}", keptFilePath);
|
_logger.LogInformation("✓ Copied track to kept folder: {Path}", keptFilePath);
|
||||||
|
|
||||||
// Also copy cover art if it exists
|
// Also copy cover art if it exists
|
||||||
var coverPath = Path.Combine(Path.GetDirectoryName(downloadPath)!, "cover.jpg");
|
var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
|
||||||
if (System.IO.File.Exists(coverPath))
|
if (System.IO.File.Exists(sourceCoverPath))
|
||||||
{
|
{
|
||||||
var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
|
var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
|
||||||
if (!System.IO.File.Exists(keptCoverPath))
|
if (!System.IO.File.Exists(keptCoverPath))
|
||||||
{
|
{
|
||||||
System.IO.File.Copy(coverPath, keptCoverPath, overwrite: false);
|
System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false);
|
||||||
_logger.LogDebug("Copied cover art to kept folder");
|
_logger.LogDebug("Copied cover art to kept folder");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark as favorited in persistent storage
|
||||||
|
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -3521,6 +3583,248 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes an external track from the kept folder when unfavorited.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RemoveExternalTrackFromKeptAsync(string itemId, string provider, string externalId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Mark for deletion instead of immediate deletion
|
||||||
|
await MarkTrackForDeletionAsync(itemId);
|
||||||
|
_logger.LogInformation("✓ Marked track for deletion: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error marking external track {ItemId} for deletion", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Persistent Favorites Tracking
|
||||||
|
|
||||||
|
private readonly string _favoritesFilePath = "/app/cache/favorites.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks if a track is already favorited (persistent across restarts).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> IsTrackFavoritedAsync(string itemId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!System.IO.File.Exists(_favoritesFilePath))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
||||||
|
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
||||||
|
|
||||||
|
return favorites.ContainsKey(itemId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to check favorite status for {ItemId}", itemId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a track as favorited in persistent storage.
|
||||||
|
/// </summary>
|
||||||
|
private async Task MarkTrackAsFavoritedAsync(string itemId, Song song)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var favorites = new Dictionary<string, FavoriteTrackInfo>();
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(_favoritesFilePath))
|
||||||
|
{
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
||||||
|
favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
||||||
|
}
|
||||||
|
|
||||||
|
favorites[itemId] = new FavoriteTrackInfo
|
||||||
|
{
|
||||||
|
ItemId = itemId,
|
||||||
|
Title = song.Title,
|
||||||
|
Artist = song.Artist,
|
||||||
|
Album = song.Album,
|
||||||
|
FavoritedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure cache directory exists
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_favoritesFilePath)!);
|
||||||
|
|
||||||
|
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
|
||||||
|
|
||||||
|
_logger.LogDebug("Marked track as favorited: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to mark track as favorited: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a track from persistent favorites storage.
|
||||||
|
/// </summary>
|
||||||
|
private async Task UnmarkTrackAsFavoritedAsync(string itemId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!System.IO.File.Exists(_favoritesFilePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(_favoritesFilePath);
|
||||||
|
var favorites = JsonSerializer.Deserialize<Dictionary<string, FavoriteTrackInfo>>(json) ?? new();
|
||||||
|
|
||||||
|
if (favorites.Remove(itemId))
|
||||||
|
{
|
||||||
|
var updatedJson = JsonSerializer.Serialize(favorites, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(_favoritesFilePath, updatedJson);
|
||||||
|
_logger.LogDebug("Removed track from favorites: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to remove track from favorites: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a track for deletion (delayed deletion for safety).
|
||||||
|
/// </summary>
|
||||||
|
private async Task MarkTrackForDeletionAsync(string itemId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deletionFilePath = "/app/cache/pending_deletions.json";
|
||||||
|
var pendingDeletions = new Dictionary<string, DateTime>();
|
||||||
|
|
||||||
|
if (System.IO.File.Exists(deletionFilePath))
|
||||||
|
{
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
|
||||||
|
pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark for deletion 24 hours from now
|
||||||
|
pendingDeletions[itemId] = DateTime.UtcNow.AddHours(24);
|
||||||
|
|
||||||
|
// Ensure cache directory exists
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(deletionFilePath)!);
|
||||||
|
|
||||||
|
var updatedJson = JsonSerializer.Serialize(pendingDeletions, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
||||||
|
|
||||||
|
// Also remove from favorites immediately
|
||||||
|
await UnmarkTrackAsFavoritedAsync(itemId);
|
||||||
|
|
||||||
|
_logger.LogDebug("Marked track for deletion in 24 hours: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to mark track for deletion: {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Information about a favorited track for persistent storage.
|
||||||
|
/// </summary>
|
||||||
|
private class FavoriteTrackInfo
|
||||||
|
{
|
||||||
|
public string ItemId { get; set; } = "";
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string Artist { get; set; } = "";
|
||||||
|
public string Album { get; set; } = "";
|
||||||
|
public DateTime FavoritedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes pending deletions (called by cleanup service).
|
||||||
|
/// </summary>
|
||||||
|
public async Task ProcessPendingDeletionsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deletionFilePath = "/app/cache/pending_deletions.json";
|
||||||
|
if (!System.IO.File.Exists(deletionFilePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(deletionFilePath);
|
||||||
|
var pendingDeletions = JsonSerializer.Deserialize<Dictionary<string, DateTime>>(json) ?? new();
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var toDelete = pendingDeletions.Where(kvp => kvp.Value <= now).ToList();
|
||||||
|
var remaining = pendingDeletions.Where(kvp => kvp.Value > now).ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||||
|
|
||||||
|
foreach (var (itemId, _) in toDelete)
|
||||||
|
{
|
||||||
|
await ActuallyDeleteTrackAsync(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDelete.Count > 0)
|
||||||
|
{
|
||||||
|
// Update pending deletions file
|
||||||
|
var updatedJson = JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
||||||
|
|
||||||
|
_logger.LogInformation("Processed {Count} pending deletions", toDelete.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing pending deletions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Actually deletes a track from the kept folder.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ActuallyDeleteTrackAsync(string itemId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||||
|
if (!isExternal) return;
|
||||||
|
|
||||||
|
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||||
|
if (song == null) return;
|
||||||
|
|
||||||
|
var keptBasePath = "/app/kept";
|
||||||
|
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 = PathHelper.SanitizeFileName(song.Title);
|
||||||
|
var trackFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
|
||||||
|
|
||||||
|
foreach (var trackFile in trackFiles)
|
||||||
|
{
|
||||||
|
System.IO.File.Delete(trackFile);
|
||||||
|
_logger.LogInformation("✓ Deleted track from kept folder: {Path}", trackFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty directories
|
||||||
|
if (Directory.GetFiles(keptAlbumPath).Length == 0 && Directory.GetDirectories(keptAlbumPath).Length == 0)
|
||||||
|
{
|
||||||
|
Directory.Delete(keptAlbumPath);
|
||||||
|
|
||||||
|
if (Directory.Exists(keptArtistPath) &&
|
||||||
|
Directory.GetFiles(keptArtistPath).Length == 0 &&
|
||||||
|
Directory.GetDirectories(keptArtistPath).Length == 0)
|
||||||
|
{
|
||||||
|
Directory.Delete(keptArtistPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to delete track {ItemId}", itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads missing tracks from file cache as fallback when Redis is empty.
|
/// Loads missing tracks from file cache as fallback when Redis is empty.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3692,284 +3996,8 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
|
||||||
/// GET /spotify/sync?api_key=YOUR_KEY
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("spotify/sync", Order = 1)]
|
|
||||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
|
||||||
public async Task<IActionResult> TriggerSpotifySync([FromServices] IEnumerable<IHostedService> hostedServices)
|
|
||||||
{
|
|
||||||
if (!_spotifySettings.Enabled)
|
|
||||||
{
|
|
||||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Manual Spotify sync triggered");
|
|
||||||
|
|
||||||
// Find the SpotifyMissingTracksFetcher service
|
|
||||||
var fetcherService = hostedServices
|
|
||||||
.OfType<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>()
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
if (fetcherService == null)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { error = "SpotifyMissingTracksFetcher not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger fetch manually
|
|
||||||
await fetcherService.TriggerFetchAsync();
|
|
||||||
|
|
||||||
// Check what was cached
|
|
||||||
var results = new Dictionary<string, object>();
|
|
||||||
foreach (var playlist in _spotifySettings.Playlists)
|
|
||||||
{
|
|
||||||
var cacheKey = $"spotify:missing:{playlist.Name}";
|
|
||||||
var tracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(cacheKey);
|
|
||||||
|
|
||||||
if (tracks != null && tracks.Count > 0)
|
|
||||||
{
|
|
||||||
results[playlist.Name] = new {
|
|
||||||
status = "success",
|
|
||||||
tracks = tracks.Count,
|
|
||||||
localTracksPosition = playlist.LocalTracksPosition.ToString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
results[playlist.Name] = new {
|
|
||||||
status = "not_found",
|
|
||||||
message = "No missing tracks found"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Manually trigger track matching for all Spotify playlists.
|
|
||||||
/// GET /spotify/match?api_key=YOUR_KEY
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("spotify/match", Order = 1)]
|
|
||||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
|
||||||
public async Task<IActionResult> TriggerSpotifyMatch([FromServices] IEnumerable<IHostedService> hostedServices)
|
|
||||||
{
|
|
||||||
if (!_spotifySettings.Enabled)
|
|
||||||
{
|
|
||||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Manual Spotify track matching triggered");
|
|
||||||
|
|
||||||
// Find the SpotifyTrackMatchingService
|
|
||||||
var matchingService = hostedServices
|
|
||||||
.OfType<allstarr.Services.Spotify.SpotifyTrackMatchingService>()
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
if (matchingService == null)
|
|
||||||
{
|
|
||||||
return StatusCode(500, new { error = "SpotifyTrackMatchingService not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger matching asynchronously
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await matchingService.TriggerMatchingAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error during manual track matching");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
status = "started",
|
|
||||||
message = "Track matching started in background. Check logs for progress.",
|
|
||||||
playlists = _spotifySettings.Playlists.Select(p => new { p.Name, p.Id, localTracksPosition = p.LocalTracksPosition.ToString() })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<allstarr.Models.Spotify.MissingTrack> ParseMissingTracksJson(string json)
|
|
||||||
{
|
|
||||||
var tracks = new List<allstarr.Models.Spotify.MissingTrack>();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var doc = JsonDocument.Parse(json);
|
|
||||||
|
|
||||||
foreach (var item in doc.RootElement.EnumerateArray())
|
|
||||||
{
|
|
||||||
var track = new allstarr.Models.Spotify.MissingTrack
|
|
||||||
{
|
|
||||||
SpotifyId = item.GetProperty("Id").GetString() ?? "",
|
|
||||||
Title = item.GetProperty("Name").GetString() ?? "",
|
|
||||||
Album = item.GetProperty("AlbumName").GetString() ?? "",
|
|
||||||
Artists = item.GetProperty("ArtistNames")
|
|
||||||
.EnumerateArray()
|
|
||||||
.Select(a => a.GetString() ?? "")
|
|
||||||
.Where(a => !string.IsNullOrEmpty(a))
|
|
||||||
.ToList()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(track.Title))
|
|
||||||
{
|
|
||||||
tracks.Add(track);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to parse missing tracks JSON");
|
|
||||||
}
|
|
||||||
|
|
||||||
return tracks;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Spotify Debug
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clear Spotify playlist cache to force re-matching.
|
|
||||||
/// GET /spotify/clear-cache?api_key=YOUR_KEY
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("spotify/clear-cache")]
|
|
||||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
|
||||||
public async Task<IActionResult> ClearSpotifyCache()
|
|
||||||
{
|
|
||||||
if (!_spotifySettings.Enabled)
|
|
||||||
{
|
|
||||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
|
||||||
}
|
|
||||||
|
|
||||||
var cleared = new List<string>();
|
|
||||||
|
|
||||||
foreach (var playlist in _spotifySettings.Playlists)
|
|
||||||
{
|
|
||||||
var matchedKey = $"spotify:matched:{playlist.Name}";
|
|
||||||
await _cache.DeleteAsync(matchedKey);
|
|
||||||
cleared.Add(playlist.Name);
|
|
||||||
_logger.LogInformation("Cleared cache for {Playlist}", playlist.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(new { status = "success", cleared = cleared });
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Debug & Monitoring
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets endpoint usage statistics from the log file.
|
|
||||||
/// GET /debug/endpoint-usage?api_key=YOUR_KEY
|
|
||||||
/// Optional query params: top=50 (default 100), since=2024-01-01
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("debug/endpoint-usage")]
|
|
||||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
|
||||||
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 collected yet",
|
|
||||||
endpoints = Array.Empty<object>()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var lines = await System.IO.File.ReadAllLinesAsync(logFile);
|
|
||||||
|
|
||||||
// Parse CSV and filter by date if provided
|
|
||||||
DateTime? sinceDate = null;
|
|
||||||
if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate))
|
|
||||||
{
|
|
||||||
sinceDate = parsedDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries = lines
|
|
||||||
.Select(line => line.Split(','))
|
|
||||||
.Where(parts => parts.Length >= 3)
|
|
||||||
.Where(parts => !sinceDate.HasValue ||
|
|
||||||
(DateTime.TryParse(parts[0], out var entryDate) && entryDate >= sinceDate.Value))
|
|
||||||
.Select(parts => new
|
|
||||||
{
|
|
||||||
Timestamp = parts[0],
|
|
||||||
Method = parts.Length > 1 ? parts[1] : "",
|
|
||||||
Path = parts.Length > 2 ? parts[2] : "",
|
|
||||||
Query = parts.Length > 3 ? parts[3] : ""
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// Group by path and count
|
|
||||||
var pathCounts = entries
|
|
||||||
.GroupBy(e => new { e.Method, e.Path })
|
|
||||||
.Select(g => new
|
|
||||||
{
|
|
||||||
Method = g.Key.Method,
|
|
||||||
Path = g.Key.Path,
|
|
||||||
Count = g.Count(),
|
|
||||||
FirstSeen = g.Min(e => e.Timestamp),
|
|
||||||
LastSeen = g.Max(e => e.Timestamp)
|
|
||||||
})
|
|
||||||
.OrderByDescending(x => x.Count)
|
|
||||||
.Take(top)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
totalRequests = entries.Count,
|
|
||||||
uniqueEndpoints = pathCounts.Count,
|
|
||||||
topEndpoints = pathCounts,
|
|
||||||
logFile = logFile,
|
|
||||||
logSize = new FileInfo(logFile).Length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to get endpoint usage");
|
|
||||||
return StatusCode(500, new { error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Clears the endpoint usage log file.
|
|
||||||
/// DELETE /debug/endpoint-usage?api_key=YOUR_KEY
|
|
||||||
/// </summary>
|
|
||||||
[HttpDelete("debug/endpoint-usage")]
|
|
||||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
|
||||||
public IActionResult ClearEndpointUsage()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
|
||||||
|
|
||||||
if (System.IO.File.Exists(logFile))
|
|
||||||
{
|
|
||||||
System.IO.File.Delete(logFile);
|
|
||||||
return Ok(new { status = "success", message = "Endpoint usage log cleared" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(new { status = "success", message = "No log file to clear" });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to clear endpoint usage log");
|
|
||||||
return StatusCode(500, new { error = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates artist match score ensuring ALL artists are present.
|
/// Calculates artist match score ensuring ALL artists are present.
|
||||||
/// Penalizes if artist counts don't match or if any artist is missing.
|
/// Penalizes if artist counts don't match or if any artist is missing.
|
||||||
|
|||||||
@@ -388,6 +388,9 @@ if (backendType == BackendType.Jellyfin)
|
|||||||
builder.Services.AddSingleton<JellyfinSessionManager>();
|
builder.Services.AddSingleton<JellyfinSessionManager>();
|
||||||
builder.Services.AddScoped<JellyfinAuthFilter>();
|
builder.Services.AddScoped<JellyfinAuthFilter>();
|
||||||
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
|
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
|
||||||
|
|
||||||
|
// Register JellyfinController as a service for dependency injection
|
||||||
|
builder.Services.AddScoped<allstarr.Controllers.JellyfinController>();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Controllers;
|
||||||
|
|
||||||
namespace allstarr.Services.Common;
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
@@ -11,16 +12,19 @@ public class CacheCleanupService : BackgroundService
|
|||||||
{
|
{
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly SubsonicSettings _subsonicSettings;
|
private readonly SubsonicSettings _subsonicSettings;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private readonly ILogger<CacheCleanupService> _logger;
|
private readonly ILogger<CacheCleanupService> _logger;
|
||||||
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
|
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
public CacheCleanupService(
|
public CacheCleanupService(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IOptions<SubsonicSettings> subsonicSettings,
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
ILogger<CacheCleanupService> logger)
|
ILogger<CacheCleanupService> logger)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_subsonicSettings = subsonicSettings.Value;
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +45,7 @@ public class CacheCleanupService : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await CleanupOldCachedFilesAsync(stoppingToken);
|
await CleanupOldCachedFilesAsync(stoppingToken);
|
||||||
|
await ProcessPendingDeletionsAsync(stoppingToken);
|
||||||
await Task.Delay(_cleanupInterval, stoppingToken);
|
await Task.Delay(_cleanupInterval, stoppingToken);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -160,4 +165,30 @@ public class CacheCleanupService : BackgroundService
|
|||||||
|
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes pending track deletions from the kept folder.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ProcessPendingDeletionsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Create a scope to get the JellyfinController
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var jellyfinController = scope.ServiceProvider.GetService<JellyfinController>();
|
||||||
|
|
||||||
|
if (jellyfinController != null)
|
||||||
|
{
|
||||||
|
await jellyfinController.ProcessPendingDeletionsAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Could not resolve JellyfinController for pending deletions processing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing pending deletions");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,6 +409,7 @@ public class JellyfinProxyService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends a GET request and returns raw bytes (for images, audio streams).
|
/// Sends a GET request and returns raw bytes (for images, audio streams).
|
||||||
|
/// WARNING: This loads entire response into memory - use StreamAsync for large files!
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<(byte[] Body, string? ContentType)> GetBytesAsync(string endpoint, Dictionary<string, string>? queryParams = null)
|
public async Task<(byte[] Body, string? ContentType)> GetBytesAsync(string endpoint, Dictionary<string, string>? queryParams = null)
|
||||||
{
|
{
|
||||||
@@ -423,9 +424,35 @@ public class JellyfinProxyService
|
|||||||
var body = await response.Content.ReadAsByteArrayAsync();
|
var body = await response.Content.ReadAsByteArrayAsync();
|
||||||
var contentType = response.Content.Headers.ContentType?.ToString();
|
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||||
|
|
||||||
|
// Trigger GC for large files to prevent memory leaks
|
||||||
|
if (body.Length > 1024 * 1024) // 1MB threshold
|
||||||
|
{
|
||||||
|
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
|
||||||
|
}
|
||||||
|
|
||||||
return (body, contentType);
|
return (body, contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams content directly without loading into memory (for large files like audio).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(Stream Stream, string? ContentType, long? ContentLength)> GetStreamAsync(string endpoint, Dictionary<string, string>? queryParams = null)
|
||||||
|
{
|
||||||
|
var url = BuildUrl(endpoint, queryParams);
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Add("Authorization", GetAuthorizationHeader());
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||||
|
var contentLength = response.Content.Headers.ContentLength;
|
||||||
|
|
||||||
|
return (stream, contentType, contentLength);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends a DELETE request to the Jellyfin server.
|
/// Sends a DELETE request to the Jellyfin server.
|
||||||
/// Forwards client headers for authentication passthrough.
|
/// Forwards client headers for authentication passthrough.
|
||||||
@@ -940,4 +967,43 @@ public class JellyfinProxyService
|
|||||||
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a GET request to the Jellyfin server using the server's API key for internal operations.
|
||||||
|
/// This should only be used for server-side operations, not for proxying client requests.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string endpoint, Dictionary<string, string>? queryParams = null)
|
||||||
|
{
|
||||||
|
var url = BuildUrl(endpoint, queryParams);
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
|
||||||
|
// Use server's API key for authentication
|
||||||
|
var authHeader = GetAuthorizationHeader();
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authHeader);
|
||||||
|
|
||||||
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
|
var statusCode = (int)response.StatusCode;
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
|
||||||
|
statusCode, url, content);
|
||||||
|
return (null, statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var jsonDocument = JsonDocument.Parse(content);
|
||||||
|
return (jsonDocument, statusCode);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to parse JSON response from {Url}: {Content}", url, content);
|
||||||
|
return (null, statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,19 +241,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||||
var userId = jellyfinSettings.UserId;
|
var userId = jellyfinSettings.UserId;
|
||||||
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
||||||
|
var queryParams = new Dictionary<string, string>();
|
||||||
if (!string.IsNullOrEmpty(userId))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
playlistItemsUrl += $"?UserId={userId}";
|
queryParams["UserId"] = userId;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
|
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
|
||||||
}
|
}
|
||||||
|
|
||||||
var (existingTracksResponse, _) = await proxyService.GetJsonAsync(
|
var (existingTracksResponse, _) = await proxyService.GetJsonAsyncInternal(
|
||||||
playlistItemsUrl,
|
playlistItemsUrl,
|
||||||
null,
|
queryParams);
|
||||||
null);
|
|
||||||
|
|
||||||
if (existingTracksResponse != null &&
|
if (existingTracksResponse != null &&
|
||||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||||
@@ -314,6 +314,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
hasManualMappings = true;
|
hasManualMappings = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also check for external manual mappings
|
||||||
|
var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}";
|
||||||
|
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
|
||||||
|
if (externalMapping != null)
|
||||||
|
{
|
||||||
|
hasManualMappings = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if cache exists AND no manual mappings need to be applied
|
// Skip if cache exists AND no manual mappings need to be applied
|
||||||
@@ -783,7 +792,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
{
|
{
|
||||||
// Manual mapping exists - fetch the Jellyfin item by ID
|
// Manual Jellyfin mapping exists - fetch the Jellyfin item by ID
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}";
|
var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}";
|
||||||
@@ -792,12 +801,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
if (itemStatusCode == 200 && itemResponse != null)
|
if (itemStatusCode == 200 && itemResponse != null)
|
||||||
{
|
{
|
||||||
matchedJellyfinItem = itemResponse.RootElement;
|
matchedJellyfinItem = itemResponse.RootElement;
|
||||||
_logger.LogDebug("✓ Using manual mapping for {Title}: Jellyfin ID {Id}",
|
_logger.LogDebug("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
|
||||||
spotifyTrack.Title, manualJellyfinId);
|
spotifyTrack.Title, manualJellyfinId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Manual mapping points to invalid Jellyfin ID {Id} for {Title}",
|
_logger.LogWarning("Manual Jellyfin mapping points to invalid Jellyfin ID {Id} for {Title}",
|
||||||
manualJellyfinId, spotifyTrack.Title);
|
manualJellyfinId, spotifyTrack.Title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -807,6 +816,53 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for external manual mapping if no Jellyfin mapping found
|
||||||
|
if (!matchedJellyfinItem.HasValue)
|
||||||
|
{
|
||||||
|
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||||
|
var externalMapping = await _cache.GetAsync<dynamic>(externalMappingKey);
|
||||||
|
|
||||||
|
if (externalMapping != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var provider = externalMapping.provider?.ToString();
|
||||||
|
var externalId = externalMapping.id?.ToString();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||||
|
{
|
||||||
|
// Create a matched track entry for the external mapping
|
||||||
|
var externalSong = new Song
|
||||||
|
{
|
||||||
|
Title = spotifyTrack.Title,
|
||||||
|
Artist = spotifyTrack.PrimaryArtist,
|
||||||
|
Album = spotifyTrack.Album,
|
||||||
|
Duration = spotifyTrack.DurationMs / 1000,
|
||||||
|
Isrc = spotifyTrack.Isrc,
|
||||||
|
IsLocal = false,
|
||||||
|
ExternalProvider = provider,
|
||||||
|
ExternalId = externalId
|
||||||
|
};
|
||||||
|
|
||||||
|
matchedTracks.Add(new MatchedTrack
|
||||||
|
{
|
||||||
|
Position = spotifyTrack.Position,
|
||||||
|
SpotifyId = spotifyTrack.SpotifyId,
|
||||||
|
MatchedSong = externalSong
|
||||||
|
});
|
||||||
|
|
||||||
|
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
|
||||||
|
spotifyTrack.Title, (object)provider, (object)externalId);
|
||||||
|
continue; // Skip to next track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SECOND: If no manual mapping, try fuzzy matching
|
// SECOND: If no manual mapping, try fuzzy matching
|
||||||
if (!matchedJellyfinItem.HasValue)
|
if (!matchedJellyfinItem.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ public class SubsonicProxyService
|
|||||||
var body = await response.Content.ReadAsByteArrayAsync();
|
var body = await response.Content.ReadAsByteArrayAsync();
|
||||||
var contentType = response.Content.Headers.ContentType?.ToString();
|
var contentType = response.Content.Headers.ContentType?.ToString();
|
||||||
|
|
||||||
|
// Trigger GC for large files to prevent memory leaks
|
||||||
|
if (body.Length > 1024 * 1024) // 1MB threshold
|
||||||
|
{
|
||||||
|
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
|
||||||
|
}
|
||||||
|
|
||||||
return (body, contentType);
|
return (body, contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -895,10 +895,12 @@
|
|||||||
<!-- Manual Track Mapping Modal -->
|
<!-- Manual Track Mapping Modal -->
|
||||||
<div class="modal" id="manual-map-modal">
|
<div class="modal" id="manual-map-modal">
|
||||||
<div class="modal-content" style="max-width: 600px;">
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
<h3>Map Track to Local File</h3>
|
<h3>Map Track</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
This track is currently using an external provider. Search for and select the local Jellyfin track to use instead.
|
Map this track to either a local Jellyfin track or provide an external provider ID.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Track Info -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Spotify Track (Position <span id="map-position"></span>)</label>
|
<label>Spotify Track (Position <span id="map-position"></span>)</label>
|
||||||
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||||
@@ -906,26 +908,61 @@
|
|||||||
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
|
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mapping Type Selection -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Search Jellyfin Tracks</label>
|
<label>Mapping Type</label>
|
||||||
<input type="text" id="map-search-query" placeholder="Search by title, artist, or album..." oninput="searchJellyfinTracks()">
|
<select id="map-type-select" onchange="toggleMappingType()" style="width: 100%;">
|
||||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
<option value="jellyfin">Map to Local Jellyfin Track</option>
|
||||||
Tip: Use commas to search multiple terms (e.g., "It Ain't Easy, David Bowie")
|
<option value="external">Map to External Provider ID</option>
|
||||||
</small>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align: center; color: var(--text-secondary); margin: 12px 0;">— OR —</div>
|
|
||||||
<div class="form-group">
|
<!-- Jellyfin Mapping Section -->
|
||||||
<label>Paste Jellyfin Track URL</label>
|
<div id="jellyfin-mapping-section">
|
||||||
<input type="text" id="map-jellyfin-url" placeholder="https://jellyfin.example.com/web/#/details?id=..." oninput="extractJellyfinId()">
|
<div class="form-group">
|
||||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
<label>Search Jellyfin Tracks</label>
|
||||||
Paste the full URL from your Jellyfin web interface
|
<input type="text" id="map-search-query" placeholder="Search by title, artist, or album..." oninput="searchJellyfinTracks()">
|
||||||
</small>
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||||
|
Tip: Use commas to search multiple terms (e.g., "It Ain't Easy, David Bowie")
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; color: var(--text-secondary); margin: 12px 0;">— OR —</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Paste Jellyfin Track URL</label>
|
||||||
|
<input type="text" id="map-jellyfin-url" placeholder="https://jellyfin.example.com/web/#/details?id=..." oninput="extractJellyfinId()">
|
||||||
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||||
|
Paste the full URL from your Jellyfin web interface
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
|
||||||
|
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">
|
||||||
|
Type to search for local tracks or paste a Jellyfin URL...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
|
|
||||||
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">
|
<!-- External Mapping Section -->
|
||||||
Type to search for local tracks or paste a Jellyfin URL...
|
<div id="external-mapping-section" style="display: none;">
|
||||||
</p>
|
<div class="form-group">
|
||||||
|
<label>External Provider</label>
|
||||||
|
<select id="map-external-provider" style="width: 100%;">
|
||||||
|
<option value="SquidWTF">SquidWTF</option>
|
||||||
|
<option value="Deezer">Deezer</option>
|
||||||
|
<option value="Qobuz">Qobuz</option>
|
||||||
|
</select>
|
||||||
|
</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()">
|
||||||
|
<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>
|
||||||
|
For Qobuz: Use the track ID from Qobuz URLs
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" id="map-playlist-name">
|
<input type="hidden" id="map-playlist-name">
|
||||||
<input type="hidden" id="map-spotify-id">
|
<input type="hidden" id="map-spotify-id">
|
||||||
<input type="hidden" id="map-selected-jellyfin-id">
|
<input type="hidden" id="map-selected-jellyfin-id">
|
||||||
@@ -1177,6 +1214,7 @@
|
|||||||
const externalMatched = p.externalMatched || 0;
|
const externalMatched = p.externalMatched || 0;
|
||||||
const externalMissing = p.externalMissing || 0;
|
const externalMissing = p.externalMissing || 0;
|
||||||
const totalInJellyfin = p.totalInJellyfin || 0;
|
const totalInJellyfin = p.totalInJellyfin || 0;
|
||||||
|
const totalPlayable = p.totalPlayable || (localCount + externalMatched); // Total tracks that will be served
|
||||||
|
|
||||||
// Debug: Log the raw data
|
// Debug: Log the raw data
|
||||||
console.log(`Playlist ${p.name}:`, {
|
console.log(`Playlist ${p.name}:`, {
|
||||||
@@ -1185,11 +1223,12 @@
|
|||||||
externalMatched,
|
externalMatched,
|
||||||
externalMissing,
|
externalMissing,
|
||||||
totalInJellyfin,
|
totalInJellyfin,
|
||||||
|
totalPlayable,
|
||||||
rawData: p
|
rawData: p
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build detailed stats string
|
// Build detailed stats string - show total playable tracks prominently
|
||||||
let statsHtml = `<span class="track-count">${spotifyTotal}</span>`;
|
let statsHtml = `<span class="track-count">${totalPlayable}/${spotifyTotal}</span>`;
|
||||||
|
|
||||||
// Show breakdown with color coding
|
// Show breakdown with color coding
|
||||||
let breakdownParts = [];
|
let breakdownParts = [];
|
||||||
@@ -1207,8 +1246,8 @@
|
|||||||
? `<br><small style="color:var(--text-secondary)">${breakdownParts.join(' • ')}</small>`
|
? `<br><small style="color:var(--text-secondary)">${breakdownParts.join(' • ')}</small>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Calculate completion percentage
|
// Calculate completion percentage based on playable tracks
|
||||||
const completionPct = spotifyTotal > 0 ? Math.round((totalInJellyfin / spotifyTotal) * 100) : 0;
|
const completionPct = spotifyTotal > 0 ? Math.round((totalPlayable / spotifyTotal) * 100) : 0;
|
||||||
const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0;
|
const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0;
|
||||||
const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0;
|
const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0;
|
||||||
const missingPct = spotifyTotal > 0 ? Math.round((externalMissing / spotifyTotal) * 100) : 0;
|
const missingPct = spotifyTotal > 0 ? Math.round((externalMissing / spotifyTotal) * 100) : 0;
|
||||||
@@ -1764,7 +1803,7 @@
|
|||||||
} else {
|
} else {
|
||||||
// isLocal is null/undefined - track is missing (not found locally or externally)
|
// isLocal is null/undefined - track is missing (not found locally or externally)
|
||||||
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:var(--bg-tertiary);color:var(--text-secondary);"><span class="status-dot" style="background:var(--text-secondary);"></span>Missing</span>';
|
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:var(--bg-tertiary);color:var(--text-secondary);"><span class="status-dot" style="background:var(--text-secondary);"></span>Missing</span>';
|
||||||
// Add manual map button for missing tracks too
|
// Add both mapping buttons for missing tracks
|
||||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||||
mapButton = `<button class="small map-track-btn"
|
mapButton = `<button class="small map-track-btn"
|
||||||
data-playlist-name="${escapeHtml(name)}"
|
data-playlist-name="${escapeHtml(name)}"
|
||||||
@@ -1772,7 +1811,14 @@
|
|||||||
data-title="${escapeHtml(t.title || '')}"
|
data-title="${escapeHtml(t.title || '')}"
|
||||||
data-artist="${escapeHtml(firstArtist)}"
|
data-artist="${escapeHtml(firstArtist)}"
|
||||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
|
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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -1802,6 +1848,18 @@
|
|||||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
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) {
|
} catch (error) {
|
||||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
||||||
}
|
}
|
||||||
@@ -1890,20 +1948,6 @@
|
|||||||
// Manual track mapping
|
// Manual track mapping
|
||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
|
|
||||||
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
|
||||||
document.getElementById('map-playlist-name').value = playlistName;
|
|
||||||
document.getElementById('map-position').textContent = position + 1;
|
|
||||||
document.getElementById('map-spotify-title').textContent = title;
|
|
||||||
document.getElementById('map-spotify-artist').textContent = artist;
|
|
||||||
document.getElementById('map-spotify-id').value = spotifyId;
|
|
||||||
document.getElementById('map-search-query').value = '';
|
|
||||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
|
||||||
document.getElementById('map-save-btn').disabled = true;
|
|
||||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks...</p>';
|
|
||||||
|
|
||||||
openModal('manual-map-modal');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchJellyfinTracks() {
|
async function searchJellyfinTracks() {
|
||||||
const query = document.getElementById('map-search-query').value.trim();
|
const query = document.getElementById('map-search-query').value.trim();
|
||||||
|
|
||||||
@@ -2033,15 +2077,118 @@
|
|||||||
document.getElementById('map-save-btn').disabled = false;
|
document.getElementById('map-save-btn').disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle between Jellyfin and external mapping modes
|
||||||
|
function toggleMappingType() {
|
||||||
|
const mappingType = document.getElementById('map-type-select').value;
|
||||||
|
const jellyfinSection = document.getElementById('jellyfin-mapping-section');
|
||||||
|
const externalSection = document.getElementById('external-mapping-section');
|
||||||
|
const saveBtn = document.getElementById('map-save-btn');
|
||||||
|
|
||||||
|
if (mappingType === 'jellyfin') {
|
||||||
|
jellyfinSection.style.display = 'block';
|
||||||
|
externalSection.style.display = 'none';
|
||||||
|
// Reset external fields
|
||||||
|
document.getElementById('map-external-id').value = '';
|
||||||
|
// Check if Jellyfin track is selected
|
||||||
|
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
|
||||||
|
saveBtn.disabled = !jellyfinId;
|
||||||
|
} else {
|
||||||
|
jellyfinSection.style.display = 'none';
|
||||||
|
externalSection.style.display = 'block';
|
||||||
|
// Reset Jellyfin fields
|
||||||
|
document.getElementById('map-search-query').value = '';
|
||||||
|
document.getElementById('map-jellyfin-url').value = '';
|
||||||
|
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||||
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Enter an external provider ID above</p>';
|
||||||
|
// Check if external mapping is valid
|
||||||
|
validateExternalMapping();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the openManualMap function to reset the modal state
|
||||||
|
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||||
|
document.getElementById('map-playlist-name').value = playlistName;
|
||||||
|
document.getElementById('map-position').textContent = position + 1;
|
||||||
|
document.getElementById('map-spotify-title').textContent = title;
|
||||||
|
document.getElementById('map-spotify-artist').textContent = artist;
|
||||||
|
document.getElementById('map-spotify-id').value = spotifyId;
|
||||||
|
|
||||||
|
// Reset to Jellyfin mapping mode
|
||||||
|
document.getElementById('map-type-select').value = 'jellyfin';
|
||||||
|
document.getElementById('jellyfin-mapping-section').style.display = 'block';
|
||||||
|
document.getElementById('external-mapping-section').style.display = 'none';
|
||||||
|
|
||||||
|
// Reset all fields
|
||||||
|
document.getElementById('map-search-query').value = '';
|
||||||
|
document.getElementById('map-jellyfin-url').value = '';
|
||||||
|
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||||
|
document.getElementById('map-external-id').value = '';
|
||||||
|
document.getElementById('map-external-provider').value = 'SquidWTF';
|
||||||
|
document.getElementById('map-save-btn').disabled = true;
|
||||||
|
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>';
|
||||||
|
|
||||||
|
openModal('manual-map-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open external mapping modal (pre-set to external mode)
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Set to external mapping mode
|
||||||
|
document.getElementById('map-type-select').value = 'external';
|
||||||
|
document.getElementById('jellyfin-mapping-section').style.display = 'none';
|
||||||
|
document.getElementById('external-mapping-section').style.display = 'block';
|
||||||
|
|
||||||
|
// Reset all fields
|
||||||
|
document.getElementById('map-search-query').value = '';
|
||||||
|
document.getElementById('map-jellyfin-url').value = '';
|
||||||
|
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||||
|
document.getElementById('map-external-id').value = '';
|
||||||
|
document.getElementById('map-external-provider').value = 'SquidWTF';
|
||||||
|
document.getElementById('map-save-btn').disabled = true;
|
||||||
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Enter an external provider ID above</p>';
|
||||||
|
|
||||||
|
openModal('manual-map-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the saveManualMapping function to handle both types
|
||||||
async function saveManualMapping() {
|
async function saveManualMapping() {
|
||||||
const playlistName = document.getElementById('map-playlist-name').value;
|
const playlistName = document.getElementById('map-playlist-name').value;
|
||||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||||
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
|
const mappingType = document.getElementById('map-type-select').value;
|
||||||
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
|
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
|
||||||
|
|
||||||
if (!jellyfinId) {
|
let requestBody = { spotifyId };
|
||||||
showToast('Please select a track', 'error');
|
|
||||||
return;
|
if (mappingType === 'jellyfin') {
|
||||||
|
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
|
||||||
|
if (!jellyfinId) {
|
||||||
|
showToast('Please select a track', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestBody.jellyfinId = jellyfinId;
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
requestBody.externalProvider = externalProvider;
|
||||||
|
requestBody.externalId = externalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
@@ -2057,7 +2204,7 @@
|
|||||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ spotifyId, jellyfinId }),
|
body: JSON.stringify(requestBody),
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2065,7 +2212,8 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast('✓ Track mapped successfully - rebuilding playlist...', 'success');
|
const mappingTypeText = mappingType === 'jellyfin' ? 'local Jellyfin track' : `${requestBody.externalProvider} ID`;
|
||||||
|
showToast(`✓ Track mapped to ${mappingTypeText} - rebuilding playlist...`, 'success');
|
||||||
closeModal('manual-map-modal');
|
closeModal('manual-map-modal');
|
||||||
|
|
||||||
// Show rebuilding indicator
|
// Show rebuilding indicator
|
||||||
@@ -2073,38 +2221,36 @@
|
|||||||
|
|
||||||
// Show detailed info toast after a moment
|
// Show detailed info toast after a moment
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showToast('🔄 Searching external providers to rebuild playlist with your manual mapping...', 'info', 8000);
|
if (mappingType === 'jellyfin') {
|
||||||
|
showToast('🔄 Rebuilding playlist with your local track mapping...', 'info', 8000);
|
||||||
|
} else {
|
||||||
|
showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
|
||||||
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// Update the track in the UI without refreshing
|
// Update the track in the UI without refreshing
|
||||||
if (data.track) {
|
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
|
||||||
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
|
if (trackItem) {
|
||||||
if (trackItem) {
|
const titleEl = trackItem.querySelector('.track-info h4');
|
||||||
// Update the track info
|
if (titleEl && mappingType === 'jellyfin' && data.track) {
|
||||||
const titleEl = trackItem.querySelector('.track-info h4');
|
// For Jellyfin mappings, update with actual track info
|
||||||
|
const titleText = data.track.title;
|
||||||
|
const newStatusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
||||||
|
titleEl.innerHTML = escapeHtml(titleText) + newStatusBadge;
|
||||||
|
|
||||||
const artistEl = trackItem.querySelector('.track-info .artists');
|
const artistEl = trackItem.querySelector('.track-info .artists');
|
||||||
const statusBadge = trackItem.querySelector('.status-badge');
|
|
||||||
const mapButton = trackItem.querySelector('.map-track-btn');
|
|
||||||
const searchLink = trackItem.querySelector('.track-meta a');
|
|
||||||
|
|
||||||
if (titleEl) {
|
|
||||||
// Remove the old status badge and map button, add new content
|
|
||||||
const titleText = data.track.title;
|
|
||||||
const newStatusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
|
||||||
titleEl.innerHTML = escapeHtml(titleText) + newStatusBadge;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (artistEl) artistEl.textContent = data.track.artist;
|
if (artistEl) artistEl.textContent = data.track.artist;
|
||||||
|
} else if (titleEl && mappingType === 'external') {
|
||||||
// Remove the search link since it's now local
|
// For external mappings, update status badge to show provider
|
||||||
if (searchLink) {
|
const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status
|
||||||
const metaEl = trackItem.querySelector('.track-meta');
|
const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(requestBody.externalProvider)}</span>`;
|
||||||
if (metaEl) {
|
titleEl.innerHTML = escapeHtml(currentTitle) + newStatusBadge;
|
||||||
// Keep album and ISRC, remove search link
|
}
|
||||||
const albumText = data.track.album ? escapeHtml(data.track.album) : '';
|
|
||||||
metaEl.innerHTML = albumText;
|
// Remove search link since it's now mapped
|
||||||
}
|
const searchLink = trackItem.querySelector('.track-meta a');
|
||||||
}
|
if (searchLink) {
|
||||||
|
searchLink.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>WebSocket Test</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Jellyfin WebSocket Test</h1>
|
|
||||||
<div id="status">Connecting...</div>
|
|
||||||
<div id="messages"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Replace with your actual token and device ID
|
|
||||||
const token = "4d19e81402394d40a7e787222606b3c2";
|
|
||||||
const deviceId = "test-device-123";
|
|
||||||
|
|
||||||
// Connect to your proxy
|
|
||||||
const wsUrl = `ws://jfm.joshpatra.me/socket?api_key=${token}&deviceId=${deviceId}`;
|
|
||||||
|
|
||||||
console.log("Connecting to:", wsUrl);
|
|
||||||
document.getElementById('status').textContent = `Connecting to: ${wsUrl}`;
|
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
console.log("✓ WebSocket connected!");
|
|
||||||
document.getElementById('status').textContent = "✓ Connected!";
|
|
||||||
document.getElementById('status').style.color = "green";
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
console.log("Message received:", event.data);
|
|
||||||
const msgDiv = document.createElement('div');
|
|
||||||
msgDiv.textContent = `[${new Date().toLocaleTimeString()}] ${event.data}`;
|
|
||||||
document.getElementById('messages').appendChild(msgDiv);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
|
||||||
console.error("WebSocket error:", error);
|
|
||||||
document.getElementById('status').textContent = "✗ Error!";
|
|
||||||
document.getElementById('status').style.color = "red";
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
|
||||||
console.log("WebSocket closed:", event.code, event.reason);
|
|
||||||
document.getElementById('status').textContent = `✗ Closed (${event.code})`;
|
|
||||||
document.getElementById('status').style.color = "orange";
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user