mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Merge dev: Spotify playlist injection with missing tracks search and playlist logging improvements
This commit is contained in:
@@ -120,3 +120,9 @@ SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
|
|||||||
# Get IDs from Jellyfin playlist URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
|
# Get IDs from Jellyfin playlist URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
|
||||||
# Example: SPOTIFY_IMPORT_PLAYLIST_IDS=4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0
|
# Example: SPOTIFY_IMPORT_PLAYLIST_IDS=4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0
|
||||||
SPOTIFY_IMPORT_PLAYLIST_IDS=
|
SPOTIFY_IMPORT_PLAYLIST_IDS=
|
||||||
|
|
||||||
|
# Playlist names (comma-separated, must match Spotify Import plugin format)
|
||||||
|
# IMPORTANT: Use the exact playlist names as they appear in Jellyfin
|
||||||
|
# Must be in same order as SPOTIFY_IMPORT_PLAYLIST_IDS
|
||||||
|
# Example: SPOTIFY_IMPORT_PLAYLIST_NAMES=Discover Weekly,Release Radar
|
||||||
|
SPOTIFY_IMPORT_PLAYLIST_NAMES=
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using allstarr.Services.Local;
|
|||||||
using allstarr.Services.Jellyfin;
|
using allstarr.Services.Jellyfin;
|
||||||
using allstarr.Services.Subsonic;
|
using allstarr.Services.Subsonic;
|
||||||
using allstarr.Services.Lyrics;
|
using allstarr.Services.Lyrics;
|
||||||
|
using allstarr.Filters;
|
||||||
|
|
||||||
namespace allstarr.Controllers;
|
namespace allstarr.Controllers;
|
||||||
|
|
||||||
@@ -1283,7 +1284,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var playlistName = nameElement.GetString() ?? "";
|
var playlistName = nameElement.GetString() ?? "";
|
||||||
_logger.LogInformation("✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})",
|
_logger.LogInformation("✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})",
|
||||||
playlistName, playlistId);
|
playlistName, playlistId);
|
||||||
return await GetSpotifyPlaylistTracksAsync(playlistName);
|
return await GetSpotifyPlaylistTracksAsync(playlistName, playlistId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1668,11 +1669,11 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
var playlistId = parts[1];
|
var playlistId = parts[1];
|
||||||
|
|
||||||
_logger.LogWarning("=== PLAYLIST REQUEST ===");
|
_logger.LogInformation("=== PLAYLIST REQUEST ===");
|
||||||
_logger.LogWarning("Playlist ID: {PlaylistId}", playlistId);
|
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||||
_logger.LogWarning("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
||||||
_logger.LogWarning("Configured IDs: {Ids}", string.Join(", ", _spotifySettings.PlaylistIds));
|
_logger.LogInformation("Configured IDs: {Ids}", string.Join(", ", _spotifySettings.PlaylistIds));
|
||||||
_logger.LogWarning("Is configured: {IsConfigured}", _spotifySettings.PlaylistIds.Contains(playlistId, StringComparer.OrdinalIgnoreCase));
|
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.PlaylistIds.Contains(playlistId, StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
// Check if this playlist ID is configured for Spotify injection
|
// Check if this playlist ID is configured for Spotify injection
|
||||||
if (_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
|
if (_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
|
||||||
@@ -1914,9 +1915,10 @@ public class JellyfinController : ControllerBase
|
|||||||
#region Spotify Playlist Injection
|
#region Spotify Playlist Injection
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName)
|
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1930,45 +1932,135 @@ public class JellyfinController : ControllerBase
|
|||||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
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 missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
|
||||||
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
|
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
|
||||||
|
|
||||||
if (missingTracks == null || missingTracks.Count == 0)
|
if (missingTracks == null || missingTracks.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No missing tracks found for {Playlist}", spotifyPlaylistName);
|
_logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks",
|
||||||
return _responseBuilder.CreateItemsResponse(new List<Song>());
|
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);
|
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}";
|
finalTracks.Add(existingTrack);
|
||||||
var results = await _metadataService.SearchSongsAsync(query, limit: 1);
|
|
||||||
return results.FirstOrDefault();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
else if (matchedBySpotifyId.TryGetValue(missingTrack.SpotifyId, out var matchedTrack))
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
finalTracks.Add(matchedTrack);
|
||||||
track.Title, track.PrimaryArtist);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
});
|
// Skip tracks we couldn't match
|
||||||
|
}
|
||||||
|
|
||||||
var matchedTracks = (await Task.WhenAll(matchTasks))
|
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
||||||
.Where(t => t != null)
|
|
||||||
.Cast<Song>()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
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}",
|
return _responseBuilder.CreateItemsResponse(finalTracks);
|
||||||
matchedTracks.Count, missingTracks.Count, spotifyPlaylistName);
|
|
||||||
|
|
||||||
return _responseBuilder.CreateItemsResponse(matchedTracks);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1977,6 +2069,164 @@ 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")]
|
||||||
|
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
||||||
|
public async Task<IActionResult> TriggerSpotifySync()
|
||||||
|
{
|
||||||
|
if (!_spotifySettings.Enabled)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Spotify Import is not enabled" });
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Manual Spotify sync triggered");
|
||||||
|
|
||||||
|
var results = new Dictionary<string, object>();
|
||||||
|
|
||||||
|
for (int i = 0; i < _spotifySettings.PlaylistIds.Count; i++)
|
||||||
|
{
|
||||||
|
var playlistId = _spotifySettings.PlaylistIds[i];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Use configured name if available, otherwise use ID
|
||||||
|
var playlistName = i < _spotifySettings.PlaylistNames.Count
|
||||||
|
? _spotifySettings.PlaylistNames[i]
|
||||||
|
: playlistId;
|
||||||
|
|
||||||
|
_logger.LogInformation("Fetching missing tracks for {Playlist} (ID: {Id})", playlistName, playlistId);
|
||||||
|
|
||||||
|
// Try to fetch the missing tracks file - search last 24 hours
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var searchStart = now.AddHours(-24);
|
||||||
|
|
||||||
|
var httpClient = new HttpClient();
|
||||||
|
var found = false;
|
||||||
|
|
||||||
|
// Search every minute for the last 24 hours (1440 attempts max)
|
||||||
|
for (var time = searchStart; time <= now; time = time.AddMinutes(1))
|
||||||
|
{
|
||||||
|
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
|
||||||
|
var url = $"{_settings.Url}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
|
||||||
|
$"?name={Uri.EscapeDataString(filename)}&api_key={_settings.ApiKey}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Trying {Filename}", filename);
|
||||||
|
var response = await httpClient.GetAsync(url);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var tracks = ParseMissingTracksJson(json);
|
||||||
|
|
||||||
|
if (tracks.Count > 0)
|
||||||
|
{
|
||||||
|
var cacheKey = $"spotify:missing:{playlistName}";
|
||||||
|
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
|
||||||
|
|
||||||
|
results[playlistName] = new {
|
||||||
|
status = "success",
|
||||||
|
tracks = tracks.Count,
|
||||||
|
filename = filename
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.LogInformation("✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
|
||||||
|
tracks.Count, playlistName, filename);
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found)
|
||||||
|
{
|
||||||
|
results[playlistName] = new { status = "not_found", message = "No missing tracks file found" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error syncing playlist {PlaylistId}", playlistId);
|
||||||
|
results[playlistId] = new { status = "error", message = ex.Message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
#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 playlistName in _spotifySettings.PlaylistNames)
|
||||||
|
{
|
||||||
|
var matchedKey = $"spotify:matched:{playlistName}";
|
||||||
|
await _cache.DeleteAsync(matchedKey);
|
||||||
|
cleared.Add(playlistName);
|
||||||
|
_logger.LogInformation("Cleared cache for {Playlist}", playlistName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new { status = "success", cleared = cleared });
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
||||||
|
|||||||
52
allstarr/Filters/ApiKeyAuthFilter.cs
Normal file
52
allstarr/Filters/ApiKeyAuthFilter.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
|
||||||
|
namespace allstarr.Filters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple API key authentication filter for admin endpoints.
|
||||||
|
/// Validates against Jellyfin API key via query parameter or header.
|
||||||
|
/// </summary>
|
||||||
|
public class ApiKeyAuthFilter : IAsyncActionFilter
|
||||||
|
{
|
||||||
|
private readonly JellyfinSettings _settings;
|
||||||
|
private readonly ILogger<ApiKeyAuthFilter> _logger;
|
||||||
|
|
||||||
|
public ApiKeyAuthFilter(
|
||||||
|
IOptions<JellyfinSettings> settings,
|
||||||
|
ILogger<ApiKeyAuthFilter> logger)
|
||||||
|
{
|
||||||
|
_settings = settings.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||||
|
{
|
||||||
|
var request = context.HttpContext.Request;
|
||||||
|
|
||||||
|
// Extract API key from query parameter or header
|
||||||
|
var apiKey = request.Query["api_key"].FirstOrDefault()
|
||||||
|
?? request.Headers["X-Api-Key"].FirstOrDefault()
|
||||||
|
?? request.Headers["X-Emby-Token"].FirstOrDefault();
|
||||||
|
|
||||||
|
// Validate API key
|
||||||
|
if (string.IsNullOrEmpty(apiKey) || !string.Equals(apiKey, _settings.ApiKey, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unauthorized access attempt to {Path} from {IP}",
|
||||||
|
request.Path,
|
||||||
|
context.HttpContext.Connection.RemoteIpAddress);
|
||||||
|
|
||||||
|
context.Result = new UnauthorizedObjectResult(new
|
||||||
|
{
|
||||||
|
error = "Unauthorized",
|
||||||
|
message = "Valid API key required. Provide via ?api_key=YOUR_KEY or X-Api-Key header."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("API key authentication successful for {Path}", request.Path);
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,4 +36,12 @@ public class SpotifyImportSettings
|
|||||||
/// Get IDs from Jellyfin playlist URLs
|
/// Get IDs from Jellyfin playlist URLs
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> PlaylistIds { get; set; } = new();
|
public List<string> PlaylistIds { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Comma-separated list of playlist names (must match Spotify Import plugin format)
|
||||||
|
/// Example: "Discover_Weekly,Release_Radar"
|
||||||
|
/// Must be in same order as PlaylistIds
|
||||||
|
/// Plugin replaces spaces with underscores in filenames
|
||||||
|
/// </summary>
|
||||||
|
public List<string> PlaylistNames { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,12 +124,24 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse SPOTIFY_IMPORT_PLAYLIST_NAMES env var (comma-separated) into PlaylistNames array
|
||||||
|
var playlistNamesEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistNames");
|
||||||
|
if (!string.IsNullOrWhiteSpace(playlistNamesEnv) && options.PlaylistNames.Count == 0)
|
||||||
|
{
|
||||||
|
options.PlaylistNames = playlistNamesEnv
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(name => name.Trim())
|
||||||
|
.Where(name => !string.IsNullOrEmpty(name))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
// Log configuration at startup
|
// Log configuration at startup
|
||||||
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}");
|
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}");
|
||||||
Console.WriteLine($"Spotify Import Playlist IDs: {options.PlaylistIds.Count} configured");
|
Console.WriteLine($"Spotify Import Playlist IDs: {options.PlaylistIds.Count} configured");
|
||||||
foreach (var id in options.PlaylistIds)
|
for (int i = 0; i < options.PlaylistIds.Count; i++)
|
||||||
{
|
{
|
||||||
Console.WriteLine($" - {id}");
|
var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i];
|
||||||
|
Console.WriteLine($" - {name} (ID: {options.PlaylistIds[i]})");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,6 +174,7 @@ if (backendType == BackendType.Jellyfin)
|
|||||||
builder.Services.AddSingleton<JellyfinModelMapper>();
|
builder.Services.AddSingleton<JellyfinModelMapper>();
|
||||||
builder.Services.AddScoped<JellyfinProxyService>();
|
builder.Services.AddScoped<JellyfinProxyService>();
|
||||||
builder.Services.AddScoped<JellyfinAuthFilter>();
|
builder.Services.AddScoped<JellyfinAuthFilter>();
|
||||||
|
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -289,6 +289,24 @@ public class JellyfinResponseBuilder
|
|||||||
var providerIds = (Dictionary<string, string>)item["ProviderIds"]!;
|
var providerIds = (Dictionary<string, string>)item["ProviderIds"]!;
|
||||||
providerIds["ISRC"] = song.Isrc;
|
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))
|
if (!string.IsNullOrEmpty(song.Genre))
|
||||||
|
|||||||
@@ -111,27 +111,15 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
{
|
{
|
||||||
_playlistIdToName.Clear();
|
_playlistIdToName.Clear();
|
||||||
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
// Use configured playlist names instead of fetching from API
|
||||||
var proxyService = scope.ServiceProvider.GetRequiredService<JellyfinProxyService>();
|
for (int i = 0; i < _spotifySettings.Value.PlaylistIds.Count; i++)
|
||||||
|
|
||||||
foreach (var playlistId in _spotifySettings.Value.PlaylistIds)
|
|
||||||
{
|
{
|
||||||
try
|
var playlistId = _spotifySettings.Value.PlaylistIds[i];
|
||||||
{
|
var playlistName = i < _spotifySettings.Value.PlaylistNames.Count
|
||||||
var playlistInfo = await proxyService.GetJsonAsync($"Items/{playlistId}", null, null);
|
? _spotifySettings.Value.PlaylistNames[i]
|
||||||
if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement))
|
: playlistId; // Fallback to ID if name not configured
|
||||||
{
|
|
||||||
var name = nameElement.GetString() ?? "";
|
_playlistIdToName[playlistId] = playlistName;
|
||||||
if (!string.IsNullOrEmpty(name))
|
|
||||||
{
|
|
||||||
_playlistIdToName[playlistId] = name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to get name for playlist {PlaylistId}", playlistId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,12 +146,13 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
.AddMinutes(settings.SyncStartMinute);
|
.AddMinutes(settings.SyncStartMinute);
|
||||||
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
|
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
|
||||||
|
|
||||||
if (now < syncStart || now > syncEnd)
|
// Only run after the sync window has passed
|
||||||
|
if (now < syncEnd)
|
||||||
{
|
{
|
||||||
return;
|
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)
|
foreach (var kvp in _playlistIdToName)
|
||||||
{
|
{
|
||||||
@@ -187,46 +176,106 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
var jellyfinUrl = _jellyfinSettings.Value.Url;
|
var jellyfinUrl = _jellyfinSettings.Value.Url;
|
||||||
var apiKey = _jellyfinSettings.Value.ApiKey;
|
var apiKey = _jellyfinSettings.Value.ApiKey;
|
||||||
var httpClient = _httpClientFactory.CreateClient();
|
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)
|
.AddHours(settings.SyncStartHour)
|
||||||
.AddMinutes(settings.SyncStartMinute);
|
.AddMinutes(settings.SyncStartMinute);
|
||||||
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
|
|
||||||
|
// If we haven't reached today's sync time yet, start from yesterday's sync time
|
||||||
|
var syncTime = now >= todaySync ? todaySync : todaySync.AddDays(-1);
|
||||||
|
|
||||||
_logger.LogInformation("Searching for missing tracks file for {Playlist}", playlistName);
|
_logger.LogInformation("Searching ±12 hours around {SyncTime} for {Playlist}",
|
||||||
|
syncTime, playlistName);
|
||||||
|
|
||||||
for (var time = syncStart; time <= syncEnd; time = time.AddMinutes(5))
|
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;
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
|
var time = syncTime.AddMinutes(minutesAhead);
|
||||||
var url = $"{jellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
|
if (await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken))
|
||||||
$"?name={Uri.EscapeDataString(filename)}&api_key={apiKey}";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Trying {Filename}", filename);
|
found = true;
|
||||||
var response = await httpClient.GetAsync(url, cancellationToken);
|
break;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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)
|
private List<MissingTrack> ParseMissingTracks(string json)
|
||||||
|
|||||||
@@ -78,11 +78,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
return new List<Song>();
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
// Check for error in response body
|
||||||
var result = JsonDocument.Parse(json);
|
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>();
|
var songs = new List<Song>();
|
||||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ services:
|
|||||||
- SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15}
|
- SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15}
|
||||||
- SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2}
|
- SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2}
|
||||||
- SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-}
|
- SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-}
|
||||||
|
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
|
||||||
|
|
||||||
# ===== SHARED =====
|
# ===== SHARED =====
|
||||||
- Library__DownloadPath=/app/downloads
|
- Library__DownloadPath=/app/downloads
|
||||||
|
|||||||
Reference in New Issue
Block a user