mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
8 Commits
74bd64c949
...
da1d28d292
| Author | SHA1 | Date | |
|---|---|---|---|
|
da1d28d292
|
|||
|
7e0ea501fc
|
|||
|
bb976fed4f
|
|||
|
df77b16640
|
|||
|
74ae85338c
|
|||
|
72b7198f1d
|
|||
|
b24dfb5b6a
|
|||
|
85f8e1cc5f
|
@@ -1284,7 +1284,7 @@ public class JellyfinController : ControllerBase
|
||||
var playlistName = nameElement.GetString() ?? "";
|
||||
_logger.LogInformation("✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})",
|
||||
playlistName, playlistId);
|
||||
return await GetSpotifyPlaylistTracksAsync(playlistName);
|
||||
return await GetSpotifyPlaylistTracksAsync(playlistName, playlistId);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1669,11 +1669,11 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
var playlistId = parts[1];
|
||||
|
||||
_logger.LogWarning("=== PLAYLIST REQUEST ===");
|
||||
_logger.LogWarning("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogWarning("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
||||
_logger.LogWarning("Configured IDs: {Ids}", string.Join(", ", _spotifySettings.PlaylistIds));
|
||||
_logger.LogWarning("Is configured: {IsConfigured}", _spotifySettings.PlaylistIds.Contains(playlistId, StringComparer.OrdinalIgnoreCase));
|
||||
_logger.LogInformation("=== PLAYLIST REQUEST ===");
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
||||
_logger.LogInformation("Configured IDs: {Ids}", string.Join(", ", _spotifySettings.PlaylistIds));
|
||||
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.PlaylistIds.Contains(playlistId, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
// Check if this playlist ID is configured for Spotify injection
|
||||
if (_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
|
||||
@@ -1915,9 +1915,10 @@ public class JellyfinController : ControllerBase
|
||||
#region Spotify Playlist Injection
|
||||
|
||||
/// <summary>
|
||||
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers.
|
||||
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers
|
||||
/// and merging with existing local tracks from Jellyfin.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName)
|
||||
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -1931,45 +1932,135 @@ public class JellyfinController : ControllerBase
|
||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||
}
|
||||
|
||||
// Get existing Jellyfin playlist items (tracks the plugin already found)
|
||||
var existingTracksResponse = await _proxyService.GetJsonAsync(
|
||||
$"Playlists/{playlistId}/Items",
|
||||
null,
|
||||
Request.Headers);
|
||||
|
||||
var existingTracks = new List<Song>();
|
||||
var existingSpotifyIds = new HashSet<string>();
|
||||
|
||||
if (existingTracksResponse != null &&
|
||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var song = _modelMapper.ParseSong(item);
|
||||
existingTracks.Add(song);
|
||||
|
||||
// Track Spotify IDs to avoid duplicates
|
||||
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
|
||||
providerIds.TryGetProperty("Spotify", out var spotifyId))
|
||||
{
|
||||
existingSpotifyIds.Add(spotifyId.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count);
|
||||
}
|
||||
|
||||
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
|
||||
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
|
||||
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No missing tracks found for {Playlist}", spotifyPlaylistName);
|
||||
return _responseBuilder.CreateItemsResponse(new List<Song>());
|
||||
_logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks",
|
||||
spotifyPlaylistName, existingTracks.Count);
|
||||
return _responseBuilder.CreateItemsResponse(existingTracks);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {Count} tracks for {Playlist}",
|
||||
_logger.LogInformation("Matching {Count} missing tracks for {Playlist}",
|
||||
missingTracks.Count, spotifyPlaylistName);
|
||||
|
||||
var matchTasks = missingTracks.Select(async track =>
|
||||
// Match missing tracks (excluding ones we already have locally)
|
||||
var matchTasks = missingTracks
|
||||
.Where(track => !existingSpotifyIds.Contains(track.SpotifyId))
|
||||
.Select(async track =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Search with just title and artist for better matching
|
||||
var query = $"{track.Title} {track.PrimaryArtist}";
|
||||
var results = await _metadataService.SearchSongsAsync(query, limit: 5);
|
||||
|
||||
if (results.Count == 0)
|
||||
return (track.SpotifyId, (Song?)null);
|
||||
|
||||
// Fuzzy match to find best result
|
||||
var bestMatch = results
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, song.Artist),
|
||||
TotalScore = 0.0
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) // Weight title more
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Only return if match is good enough (>60% combined score)
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 60)
|
||||
{
|
||||
_logger.LogDebug("Matched '{Title}' by {Artist} -> '{MatchTitle}' by {MatchArtist} (score: {Score:F1})",
|
||||
track.Title, track.PrimaryArtist,
|
||||
bestMatch.Song.Title, bestMatch.Song.Artist,
|
||||
bestMatch.TotalScore);
|
||||
return (track.SpotifyId, (Song?)bestMatch.Song);
|
||||
}
|
||||
|
||||
_logger.LogDebug("No good match for '{Title}' by {Artist} (best score: {Score:F1})",
|
||||
track.Title, track.PrimaryArtist, bestMatch?.TotalScore ?? 0);
|
||||
return (track.SpotifyId, (Song?)null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
track.Title, track.PrimaryArtist);
|
||||
return (track.SpotifyId, (Song?)null);
|
||||
}
|
||||
});
|
||||
|
||||
var matchResults = await Task.WhenAll(matchTasks);
|
||||
var matchedBySpotifyId = matchResults
|
||||
.Where(x => x.Item2 != null)
|
||||
.ToDictionary(x => x.SpotifyId, x => x.Item2!);
|
||||
|
||||
// Build final track list in Spotify playlist order
|
||||
var finalTracks = new List<Song>();
|
||||
foreach (var missingTrack in missingTracks)
|
||||
{
|
||||
try
|
||||
// Check if we have it locally first
|
||||
var existingTrack = existingTracks.FirstOrDefault(t =>
|
||||
t.Title.Equals(missingTrack.Title, StringComparison.OrdinalIgnoreCase) &&
|
||||
t.Artist.Equals(missingTrack.PrimaryArtist, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingTrack != null)
|
||||
{
|
||||
var query = $"{track.Title} {track.AllArtists} {track.Album}";
|
||||
var results = await _metadataService.SearchSongsAsync(query, limit: 1);
|
||||
return results.FirstOrDefault();
|
||||
finalTracks.Add(existingTrack);
|
||||
}
|
||||
catch (Exception ex)
|
||||
else if (matchedBySpotifyId.TryGetValue(missingTrack.SpotifyId, out var matchedTrack))
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
track.Title, track.PrimaryArtist);
|
||||
return null;
|
||||
finalTracks.Add(matchedTrack);
|
||||
}
|
||||
});
|
||||
// Skip tracks we couldn't match
|
||||
}
|
||||
|
||||
var matchedTracks = (await Task.WhenAll(matchTasks))
|
||||
.Where(t => t != null)
|
||||
.Cast<Song>()
|
||||
.ToList();
|
||||
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
||||
|
||||
await _cache.SetAsync(cacheKey, matchedTracks, TimeSpan.FromHours(1));
|
||||
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local, {Matched} matched, {Missing} missing)",
|
||||
finalTracks.Count,
|
||||
existingTracks.Count,
|
||||
matchedBySpotifyId.Count,
|
||||
missingTracks.Count - existingTracks.Count - matchedBySpotifyId.Count);
|
||||
|
||||
_logger.LogInformation("Matched {Matched}/{Total} tracks for {Playlist}",
|
||||
matchedTracks.Count, missingTracks.Count, spotifyPlaylistName);
|
||||
|
||||
return _responseBuilder.CreateItemsResponse(matchedTracks);
|
||||
return _responseBuilder.CreateItemsResponse(finalTracks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -289,6 +289,24 @@ public class JellyfinResponseBuilder
|
||||
var providerIds = (Dictionary<string, string>)item["ProviderIds"]!;
|
||||
providerIds["ISRC"] = song.Isrc;
|
||||
}
|
||||
|
||||
// Add MediaSources with bitrate for external tracks
|
||||
item["MediaSources"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = song.Id,
|
||||
["Type"] = "Default",
|
||||
["Container"] = "flac",
|
||||
["Size"] = (song.Duration ?? 180) * 1337 * 128, // Approximate file size
|
||||
["Bitrate"] = 1337000, // 1337 kbps in bps
|
||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||
["Protocol"] = "File",
|
||||
["SupportsDirectStream"] = true,
|
||||
["SupportsTranscoding"] = true,
|
||||
["SupportsDirectPlay"] = true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(song.Genre))
|
||||
|
||||
@@ -146,12 +146,13 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
.AddMinutes(settings.SyncStartMinute);
|
||||
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
|
||||
|
||||
if (now < syncStart || now > syncEnd)
|
||||
// Only run after the sync window has passed
|
||||
if (now < syncEnd)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Within sync window, fetching missing tracks...");
|
||||
_logger.LogInformation("Sync window passed, searching last 24 hours for missing tracks...");
|
||||
|
||||
foreach (var kvp in _playlistIdToName)
|
||||
{
|
||||
@@ -175,46 +176,106 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
var jellyfinUrl = _jellyfinSettings.Value.Url;
|
||||
var apiKey = _jellyfinSettings.Value.ApiKey;
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var syncStart = today
|
||||
|
||||
// Start from the configured sync time (most likely time)
|
||||
var now = DateTime.UtcNow;
|
||||
var todaySync = now.Date
|
||||
.AddHours(settings.SyncStartHour)
|
||||
.AddMinutes(settings.SyncStartMinute);
|
||||
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
|
||||
|
||||
_logger.LogInformation("Searching for missing tracks file for {Playlist}", playlistName);
|
||||
// If we haven't reached today's sync time yet, start from yesterday's sync time
|
||||
var syncTime = now >= todaySync ? todaySync : todaySync.AddDays(-1);
|
||||
|
||||
for (var time = syncStart; time <= syncEnd; time = time.AddMinutes(5))
|
||||
_logger.LogInformation("Searching ±12 hours around {SyncTime} for {Playlist}",
|
||||
syncTime, playlistName);
|
||||
|
||||
var found = false;
|
||||
|
||||
// Search forward 12 hours from sync time
|
||||
for (var minutesAhead = 0; minutesAhead <= 720; minutesAhead++) // 720 minutes = 12 hours
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
|
||||
var url = $"{jellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
|
||||
$"?name={Uri.EscapeDataString(filename)}&api_key={apiKey}";
|
||||
|
||||
try
|
||||
var time = syncTime.AddMinutes(minutesAhead);
|
||||
if (await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken))
|
||||
{
|
||||
_logger.LogDebug("Trying {Filename}", filename);
|
||||
var response = await httpClient.GetAsync(url, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var tracks = ParseMissingTracks(json);
|
||||
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
|
||||
_logger.LogInformation(
|
||||
"✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
|
||||
tracks.Count, playlistName, filename);
|
||||
break;
|
||||
}
|
||||
}
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
// Small delay every 60 requests
|
||||
if (minutesAhead > 0 && minutesAhead % 60 == 0)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Then search backwards 12 hours from sync time
|
||||
if (!found)
|
||||
{
|
||||
for (var minutesBehind = 1; minutesBehind <= 720; minutesBehind++)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
var time = syncTime.AddMinutes(-minutesBehind);
|
||||
if (await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken))
|
||||
{
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Small delay every 60 requests
|
||||
if (minutesBehind % 60 == 0)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
_logger.LogWarning("Could not find missing tracks file for {Playlist} in ±12 hour window", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TryFetchMissingTracksFile(
|
||||
string playlistName,
|
||||
DateTime time,
|
||||
string jellyfinUrl,
|
||||
string apiKey,
|
||||
HttpClient httpClient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
|
||||
var url = $"{jellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
|
||||
$"?name={Uri.EscapeDataString(filename)}&api_key={apiKey}";
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying {Filename}", filename);
|
||||
var response = await httpClient.GetAsync(url, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var tracks = ParseMissingTracks(json);
|
||||
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
|
||||
_logger.LogInformation(
|
||||
"✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
|
||||
tracks.Count, playlistName, filename);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private List<MissingTrack> ParseMissingTracks(string json)
|
||||
|
||||
@@ -78,11 +78,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new List<Song>();
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Check for error in response body
|
||||
var result = JsonDocument.Parse(json);
|
||||
if (result.RootElement.TryGetProperty("detail", out _) ||
|
||||
result.RootElement.TryGetProperty("error", out _))
|
||||
{
|
||||
throw new HttpRequestException("API returned error response");
|
||||
}
|
||||
|
||||
var songs = new List<Song>();
|
||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||
|
||||
Reference in New Issue
Block a user