5 Commits
dev ... main

Author SHA1 Message Date
f68706f300 Release v1.1.1 - Download Structure Fix
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
Fixed cache and permanent files to use unified downloads/ structure instead of separate paths.
2026-02-08 01:51:18 -05:00
9f362b4920 Release v1.1.0 - Configuration Simplification
Configuration Changes:
- Removed sync window logic from Spotify Import (no more SYNC_START_HOUR, SYNC_START_MINUTE, SYNC_WINDOW_HOURS)
- Simplified to: fetch on startup if cache missing, check every 5 minutes for stale cache
- Unified download folder structure: downloads/{permanent,cache,kept}/ instead of separate paths
- Removed Library:KeptPath config, now uses downloads/kept/

Documentation:
- Updated README with clearer Spotify Import configuration
- Updated .env.example to reflect simplified settings
- Removed MIGRATION.md from repository (local-only file)

Bug Fixes:
- Web UI now correctly displays kept tracks in Active Playlists tab
- Fixed path handling for favorited tracks
2026-02-08 01:33:09 -05:00
2b09484c0b Release v1.0.0 - Production Ready
Major Features:
- Spotify playlist injection with missing tracks search
- Transparent proxy authentication system
- WebSocket session management for external tracks
- Manual track mapping and favorites system
- Lyrics support (Spotify + LRCLib) with prefetching
- Admin dashboard with analytics and configuration
- Performance optimizations with health checks and endpoint racing
- Comprehensive caching and memory management

Performance Improvements:
- Quick health checks (3s timeout) before trying endpoints
- Health check results cached for 30 seconds
- 5 minute timeout for large artist responses
- Background Odesli conversion after streaming starts
- Parallel lyrics prefetching
- Endpoint benchmarking and racing
- 16 SquidWTF endpoints with load balancing

Reliability:
- Automatic endpoint fallback and failover
- Token expiration handling
- Concurrent request optimization
- Memory leak fixes
- Proper session cleanup

User Experience:
- Web UI for configuration and playlist management
- Real-time progress tracking
- API analytics dashboard
- Manual track mapping interface
- Playlist statistics and health monitoring
2026-02-08 00:43:47 -05:00
fa9739bfaa docs: update README
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 11:16:00 -05:00
0ba51e2b30 fix: improve auth, search, and stability
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 01:14:53 -05:00
23 changed files with 189 additions and 1332 deletions

View File

@@ -40,7 +40,7 @@ MUSIC_SERVICE=SquidWTF
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
# - downloads/kept/ - Favorited external tracks (always permanent)
Library__DownloadPath=./downloads
DOWNLOAD_PATH=./downloads
# ===== SQUIDWTF CONFIGURATION =====
# Different quality options for SquidWTF. Only FLAC supported right now

View File

@@ -37,8 +37,6 @@ The proxy will be available at `http://localhost:5274`.
## Web Dashboard
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
<img width="1664" height="1101" alt="image" src="https://github.com/user-attachments/assets/9159100b-7e11-449e-8530-517d336d6bd2" />
### Features
@@ -76,6 +74,8 @@ There's an environment variable to modify this.
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
### Nginx Proxy Setup (Required)
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
@@ -139,14 +139,8 @@ This project brings together all the music streaming providers into one unified
**Compatible Jellyfin clients:**
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
<img width="1691" height="1128" alt="image" src="https://github.com/user-attachments/assets/c602f71c-c4dd-49a9-b533-1558e24a9f45" />
- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android)
<img width="523" height="1025" alt="image" src="https://github.com/user-attachments/assets/135e2721-5fd7-482f-bb06-b0736003cfe7" />
- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS)
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
- [Finamp](https://github.com/jmshrv/finamp) ()
_Working on getting more currently_
@@ -342,9 +336,6 @@ Subsonic__EnableExternalPlaylists=false
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
<img width="1649" height="3764" alt="image" src="https://github.com/user-attachments/assets/a4d3d79c-7741-427f-8c01-ffc90f3a579b" />
#### Prerequisites
1. **Install the Jellyfin Spotify Import Plugin**
@@ -923,4 +914,4 @@ GPL-3.0
- [Deezer](https://www.deezer.com/) - Music streaming service
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of

View File

@@ -259,7 +259,6 @@ public class AdminController : ControllerBase
["id"] = config.Id,
["jellyfinId"] = config.JellyfinId,
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
["syncSchedule"] = config.SyncSchedule ?? "0 8 * * 1",
["trackCount"] = 0,
["localTracks"] = 0,
["externalTracks"] = 0,
@@ -1380,12 +1379,6 @@ public class AdminController : ControllerBase
{
return Ok(new
{
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
musicService = _configuration.GetValue<string>("MusicService") ?? "SquidWTF",
explicitFilter = _configuration.GetValue<string>("ExplicitFilter") ?? "All",
enableExternalPlaylists = _configuration.GetValue<bool>("EnableExternalPlaylists", false),
playlistsDirectory = _configuration.GetValue<string>("PlaylistsDirectory") ?? "(not set)",
redisEnabled = _configuration.GetValue<bool>("Redis:Enabled", false),
spotifyApi = new
{
enabled = _spotifyApiSettings.Enabled,
@@ -1528,12 +1521,6 @@ public class AdminController : ControllerBase
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
// Invalidate playlist summary cache if playlists were updated
if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS"))
{
InvalidatePlaylistSummaryCache();
}
return Ok(new
{
message = "Configuration updated. Restart container to apply changes.",
@@ -1932,53 +1919,6 @@ public class AdminController : ControllerBase
}
}
/// <summary>
/// Get all playlists from the user's Spotify account
/// </summary>
[HttpGet("spotify/user-playlists")]
public async Task<IActionResult> GetSpotifyUserPlaylists()
{
if (!_spotifyApiSettings.Enabled || string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
{
return BadRequest(new { error = "Spotify API not configured. Please set sp_dc session cookie." });
}
try
{
// Get list of already-configured Spotify playlist IDs
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
var linkedSpotifyIds = new HashSet<string>(
configuredPlaylists.Select(p => p.Id),
StringComparer.OrdinalIgnoreCase
);
// Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
{
return Ok(new { playlists = new List<object>() });
}
var playlists = spotifyPlaylists.Select(p => new
{
id = p.SpotifyId,
name = p.Name,
trackCount = p.TotalTracks,
owner = p.OwnerName ?? "",
isPublic = p.Public,
isLinked = linkedSpotifyIds.Contains(p.SpotifyId)
}).ToList();
return Ok(new { playlists });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching Spotify user playlists");
return StatusCode(500, new { error = "Failed to fetch Spotify playlists", details = ex.Message });
}
}
/// <summary>
/// Get all playlists from Jellyfin
/// </summary>
@@ -2050,16 +1990,11 @@ public class AdminController : ControllerBase
trackStats = await GetPlaylistTrackStats(id!);
}
// Use actual track stats for configured playlists, otherwise use Jellyfin's count
var actualTrackCount = isConfigured
? trackStats.LocalTracks + trackStats.ExternalTracks
: childCount;
playlists.Add(new
{
id,
name,
trackCount = actualTrackCount,
trackCount = childCount,
linkedSpotifyId,
isConfigured,
localTracks = trackStats.LocalTracks,
@@ -2228,19 +2163,12 @@ public class AdminController : ControllerBase
Name = request.Name,
Id = request.SpotifyPlaylistId,
JellyfinId = jellyfinPlaylistId,
LocalTracksPosition = LocalTracksPosition.First, // Use Spotify order
SyncSchedule = request.SyncSchedule ?? "0 8 * * 1" // Default to Monday 8 AM
LocalTracksPosition = LocalTracksPosition.First // Use Spotify order
});
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
var playlistsJson = JsonSerializer.Serialize(
currentPlaylists.Select(p => new[] {
p.Name,
p.Id,
p.JellyfinId,
p.LocalTracksPosition.ToString().ToLower(),
p.SyncSchedule ?? "0 8 * * 1"
}).ToArray()
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
);
// Update .env file
@@ -2265,60 +2193,6 @@ public class AdminController : ControllerBase
return await RemovePlaylist(decodedName);
}
/// <summary>
/// Update playlist sync schedule
/// </summary>
[HttpPut("playlists/{name}/schedule")]
public async Task<IActionResult> UpdatePlaylistSchedule(string name, [FromBody] UpdateScheduleRequest request)
{
var decodedName = Uri.UnescapeDataString(name);
if (string.IsNullOrWhiteSpace(request.SyncSchedule))
{
return BadRequest(new { error = "SyncSchedule is required" });
}
// Basic cron validation
var cronParts = request.SyncSchedule.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (cronParts.Length != 5)
{
return BadRequest(new { error = "Invalid cron format. Expected: minute hour day month dayofweek" });
}
// Read current playlists
var currentPlaylists = await ReadPlaylistsFromEnvFile();
var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
if (playlist == null)
{
return NotFound(new { error = $"Playlist '{decodedName}' not found" });
}
// Update the schedule
playlist.SyncSchedule = request.SyncSchedule.Trim();
// Save back to .env
var playlistsJson = JsonSerializer.Serialize(
currentPlaylists.Select(p => new[] {
p.Name,
p.Id,
p.JellyfinId,
p.LocalTracksPosition.ToString().ToLower(),
p.SyncSchedule ?? "0 8 * * 1"
}).ToArray()
);
var updateRequest = new ConfigUpdateRequest
{
Updates = new Dictionary<string, string>
{
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
}
};
return await UpdateConfig(updateRequest);
}
private string GetJellyfinAuthHeader()
{
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
@@ -2350,7 +2224,7 @@ public class AdminController : ControllerBase
return playlists;
}
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last"],...]
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
if (playlistArrays != null)
{
@@ -2366,8 +2240,7 @@ public class AdminController : ControllerBase
LocalTracksPosition = arr.Length >= 4 &&
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
? LocalTracksPosition.Last
: LocalTracksPosition.First,
SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * 1"
: LocalTracksPosition.First
});
}
}
@@ -3422,12 +3295,6 @@ public class LinkPlaylistRequest
{
public string Name { get; set; } = string.Empty;
public string SpotifyPlaylistId { get; set; } = string.Empty;
public string SyncSchedule { get; set; } = "0 8 * * 1"; // Default: 8 AM every Monday
}
public class UpdateScheduleRequest
{
public string SyncSchedule { get; set; } = string.Empty;
}
/// <summary>

View File

@@ -19,12 +19,6 @@ public class Song
/// All artists for this track (main + featured). For display in Jellyfin clients.
/// </summary>
public List<string> Artists { get; set; } = new();
/// <summary>
/// All artist IDs corresponding to the Artists list. Index-matched with Artists.
/// </summary>
public List<string> ArtistIds { get; set; } = new();
public string Album { get; set; } = string.Empty;
public string? AlbumId { get; set; }
public int? Duration { get; set; } // In seconds

View File

@@ -45,14 +45,6 @@ public class SpotifyPlaylistConfig
/// Where to position local tracks: "first" or "last"
/// </summary>
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
/// <summary>
/// Cron schedule for syncing this playlist with Spotify
/// Format: minute hour day month dayofweek
/// Example: "0 8 * * 1" = 8 AM every Monday
/// Default: "0 8 * * 1" (weekly on Monday at 8 AM)
/// </summary>
public string SyncSchedule { get; set; } = "0 8 * * 1";
}
/// <summary>

View File

@@ -13,28 +13,9 @@ using allstarr.Middleware;
using allstarr.Filters;
using Microsoft.Extensions.Http;
using System.Text;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
// Configure forwarded headers for reverse proxy support (nginx, etc.)
// This allows ASP.NET Core to read X-Forwarded-For, X-Real-IP, etc.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedHost;
// Clear known networks and proxies to accept headers from any proxy
// This is safe when running behind a trusted reverse proxy (nginx)
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
// Trust X-Forwarded-* headers from any source
// Only do this if your reverse proxy is properly configured and trusted
options.ForwardLimit = null;
});
// Decode SquidWTF API base URLs once at startup
var squidWtfApiUrls = DecodeSquidWtfUrls();
static List<string> DecodeSquidWtfUrls()
@@ -645,23 +626,7 @@ builder.Services.AddCors(options =>
var app = builder.Build();
// Migrate old .env file format on startup
try
{
var migrationService = new EnvMigrationService(app.Services.GetRequiredService<ILogger<EnvMigrationService>>());
migrationService.MigrateEnvFile();
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Failed to run .env migration");
}
// Configure the HTTP request pipeline.
// IMPORTANT: UseForwardedHeaders must be called BEFORE other middleware
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
app.UseForwardedHeaders();
app.UseExceptionHandler(_ => { }); // Global exception handler
// Enable response compression EARLY in the pipeline

View File

@@ -264,7 +264,6 @@ public abstract class BaseDownloadService : IDownloadService
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
await DownloadLock.WaitAsync(cancellationToken);
var lockHeld = true;
try
{
@@ -289,7 +288,6 @@ public abstract class BaseDownloadService : IDownloadService
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
// Release lock while waiting
DownloadLock.Release();
lockHeld = false;
// Wait for download to complete, checking every 100ms (faster than 500ms)
// Also respect cancellation token so client timeouts are handled immediately
@@ -446,10 +444,7 @@ public abstract class BaseDownloadService : IDownloadService
}
finally
{
if (lockHeld)
{
DownloadLock.Release();
}
DownloadLock.Release();
}
}

View File

@@ -66,9 +66,7 @@ public class CacheCleanupService : BackgroundService
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
{
// Get the actual cache path used by download services
var downloadPath = _configuration["Library:DownloadPath"] ?? "downloads";
var cachePath = Path.Combine(downloadPath, "cache");
var cachePath = PathHelper.GetCachePath();
if (!Directory.Exists(cachePath))
{
@@ -80,7 +78,7 @@ public class CacheCleanupService : BackgroundService
var deletedCount = 0;
var totalSize = 0L;
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime} from {Path}", cutoffTime, cachePath);
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime}", cutoffTime);
try
{

View File

@@ -20,9 +20,6 @@ public class EndpointBenchmarkService
/// <summary>
/// Benchmarks a list of endpoints by making test requests.
/// Returns endpoints sorted by average response time (fastest first).
///
/// IMPORTANT: The testFunc should implement its own timeout to prevent slow endpoints
/// from blocking startup. Recommended: 5-10 second timeout per ping.
/// </summary>
public async Task<List<string>> BenchmarkEndpointsAsync(
List<string> endpoints,

View File

@@ -1,59 +0,0 @@
namespace allstarr.Services.Common;
/// <summary>
/// Service that runs on startup to migrate old .env file format to new format
/// </summary>
public class EnvMigrationService
{
private readonly ILogger<EnvMigrationService> _logger;
private readonly string _envFilePath;
public EnvMigrationService(ILogger<EnvMigrationService> logger)
{
_logger = logger;
_envFilePath = Path.Combine(Directory.GetCurrentDirectory(), ".env");
}
public void MigrateEnvFile()
{
if (!File.Exists(_envFilePath))
{
_logger.LogDebug("No .env file found, skipping migration");
return;
}
try
{
var lines = File.ReadAllLines(_envFilePath);
var modified = false;
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i].Trim();
// Skip comments and empty lines
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
continue;
// Migrate DOWNLOAD_PATH to Library__DownloadPath
if (line.StartsWith("DOWNLOAD_PATH="))
{
var value = line.Substring("DOWNLOAD_PATH=".Length);
lines[i] = $"Library__DownloadPath={value}";
modified = true;
_logger.LogInformation("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file");
}
}
if (modified)
{
File.WriteAllLines(_envFilePath, lines);
_logger.LogInformation("✅ .env file migration completed successfully");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to migrate .env file - please manually update DOWNLOAD_PATH to Library__DownloadPath");
}
}
}

View File

@@ -384,23 +384,17 @@ public class DeezerMetadataService : IMusicMetadataService
}
}
// Contributors (all artists including features)
// Contributors
var contributors = new List<string>();
var contributorIds = new List<string>();
if (track.TryGetProperty("contributors", out var contribs))
{
foreach (var contrib in contribs.EnumerateArray())
{
if (contrib.TryGetProperty("name", out var contribName) &&
contrib.TryGetProperty("id", out var contribId))
if (contrib.TryGetProperty("name", out var contribName))
{
var name = contribName.GetString();
var id = contribId.GetInt64();
if (!string.IsNullOrEmpty(name))
{
contributors.Add(name);
contributorIds.Add($"ext-deezer-artist-{id}");
}
}
}
}
@@ -443,8 +437,6 @@ public class DeezerMetadataService : IMusicMetadataService
ArtistId = track.TryGetProperty("artist", out var artistForId)
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
: null,
Artists = contributors.Count > 0 ? contributors : new List<string>(),
ArtistIds = contributorIds.Count > 0 ? contributorIds : new List<string>(),
Album = track.TryGetProperty("album", out var album)
? album.GetProperty("title").GetString() ?? ""
: "",

View File

@@ -299,11 +299,13 @@ public class JellyfinResponseBuilder
["ItemId"] = song.Id
},
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
["ArtistItems"] = artistNames.Count > 0 && song.ArtistIds.Count == artistNames.Count
["ArtistItems"] = artistNames.Count > 0
? artistNames.Select((name, index) => new Dictionary<string, object?>
{
["Name"] = name,
["Id"] = song.ArtistIds[index]
["Id"] = index == 0 && song.ArtistId != null
? song.ArtistId
: $"{song.Id}-artist-{index}"
}).ToArray()
: new[]
{

View File

@@ -85,10 +85,6 @@ public class JellyfinSessionManager : IDisposable
_logger.LogDebug("Session created for {DeviceId}", deviceId);
// Track this session
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
?? headers["X-Real-IP"].FirstOrDefault()
?? "Unknown";
_sessions[deviceId] = new SessionInfo
{
DeviceId = deviceId,
@@ -96,8 +92,7 @@ public class JellyfinSessionManager : IDisposable
Device = device,
Version = version,
LastActivity = DateTime.UtcNow,
Headers = CloneHeaders(headers),
ClientIp = clientIp
Headers = CloneHeaders(headers)
};
// Start a WebSocket connection to Jellyfin on behalf of this client
@@ -227,7 +222,6 @@ public class JellyfinSessionManager : IDisposable
Client = s.Client,
Device = s.Device,
Version = s.Version,
ClientIp = s.ClientIp,
LastActivity = s.LastActivity,
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
HasWebSocket = s.WebSocket != null,
@@ -571,7 +565,6 @@ public class JellyfinSessionManager : IDisposable
public ClientWebSocket? WebSocket { get; set; }
public string? LastPlayingItemId { get; set; }
public long? LastPlayingPositionTicks { get; set; }
public string? ClientIp { get; set; }
}
public void Dispose()

View File

@@ -1,19 +1,19 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services;
namespace allstarr.Services.Local;
/// <summary>
/// Local library service implementation
/// Uses a simple JSON file to store mappings (can be replaced with a database)
/// </summary>
public class LocalLibraryService : ILocalLibraryService
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services;
namespace allstarr.Services.Local;
/// <summary>
/// Local library service implementation
/// Uses a simple JSON file to store mappings (can be replaced with a database)
/// </summary>
public class LocalLibraryService : ILocalLibraryService
{
private readonly string _mappingFilePath;
private readonly string _downloadDirectory;

View File

@@ -349,17 +349,6 @@ public class SpotifyApiClient : IDisposable
var response = await _webApiClient.SendAsync(request, cancellationToken);
// Handle 429 rate limiting with exponential backoff
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds);
await Task.Delay(retryAfter, cancellationToken);
// Retry the request
response = await _webApiClient.SendAsync(request, cancellationToken);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
@@ -746,18 +735,6 @@ public class SpotifyApiClient : IDisposable
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
string searchName,
CancellationToken cancellationToken = default)
{
return await GetUserPlaylistsAsync(searchName, cancellationToken);
}
/// <summary>
/// Gets all playlists from the user's library, optionally filtered by name.
/// Uses GraphQL API which is less rate-limited than REST API.
/// </summary>
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
public async Task<List<SpotifyPlaylist>> GetUserPlaylistsAsync(
string? searchName = null,
CancellationToken cancellationToken = default)
{
var token = await GetWebAccessTokenAsync(cancellationToken);
if (string.IsNullOrEmpty(token))
@@ -767,204 +744,61 @@ public class SpotifyApiClient : IDisposable
try
{
// Use GraphQL endpoint instead of REST API to avoid rate limiting
// GraphQL is less aggressive with rate limits
var playlists = new List<SpotifyPlaylist>();
var offset = 0;
const int limit = 50;
while (true)
{
// GraphQL query to fetch user playlists - using libraryV3 operation
var queryParams = new Dictionary<string, string>
{
{ "operationName", "libraryV3" },
{ "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
};
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
var url = $"{WebApiBase}/query?{queryString}";
var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _webApiClient.SendAsync(request, cancellationToken);
// Handle 429 rate limiting with exponential backoff
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
_logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds);
await Task.Delay(retryAfter, cancellationToken);
// Retry the request
response = await _httpClient.SendAsync(request, cancellationToken);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
break;
}
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode) break;
var json = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("data", out var data) ||
!data.TryGetProperty("me", out var me) ||
!me.TryGetProperty("libraryV3", out var library) ||
!library.TryGetProperty("items", out var items))
{
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
break;
}
// Get total count
if (library.TryGetProperty("totalCount", out var totalCount))
{
var total = totalCount.GetInt32();
if (total == 0) break;
}
var itemCount = 0;
foreach (var item in items.EnumerateArray())
{
itemCount++;
var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
if (!item.TryGetProperty("item", out var playlistItem) ||
!playlistItem.TryGetProperty("data", out var playlist))
// Check if name matches (case-insensitive)
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Check __typename to filter out folders and only include playlists
if (playlistItem.TryGetProperty("__typename", out var typename))
{
var typeStr = typename.GetString();
// Skip folders - only process Playlist types
if (typeStr != null && typeStr.Contains("Folder", StringComparison.OrdinalIgnoreCase))
playlists.Add(new SpotifyPlaylist
{
continue;
}
SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "",
Name = itemName,
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null,
TotalTracks = item.TryGetProperty("tracks", out var tracks) &&
tracks.TryGetProperty("total", out var total)
? total.GetInt32() : 0,
SnapshotId = item.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null
});
}
// Get playlist URI/ID
string? uri = null;
if (playlistItem.TryGetProperty("uri", out var uriProp))
{
uri = uriProp.GetString();
}
else if (playlistItem.TryGetProperty("_uri", out var uriProp2))
{
uri = uriProp2.GetString();
}
if (string.IsNullOrEmpty(uri)) continue;
// Skip if not a playlist URI (e.g., folders have different URI format)
if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
// Check if name matches (case-insensitive) - if searchName is provided
if (!string.IsNullOrEmpty(searchName) &&
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Get track count if available - try multiple possible paths
var trackCount = 0;
if (playlist.TryGetProperty("content", out var content))
{
if (content.TryGetProperty("totalCount", out var totalTrackCount))
{
trackCount = totalTrackCount.GetInt32();
}
}
// Fallback: try attributes.itemCount
else if (playlist.TryGetProperty("attributes", out var attributes) &&
attributes.TryGetProperty("itemCount", out var itemCountProp))
{
trackCount = itemCountProp.GetInt32();
}
// Fallback: try totalCount directly
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
{
trackCount = directTotalCount.GetInt32();
}
// Log if we couldn't find track count for debugging
if (trackCount == 0)
{
_logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}",
itemName, spotifyId, playlist.GetRawText());
}
// Get owner name
string? ownerName = null;
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
ownerV2.TryGetProperty("data", out var ownerData) &&
ownerData.TryGetProperty("username", out var ownerNameProp))
{
ownerName = ownerNameProp.GetString();
}
// Get image URL
string? imageUrl = null;
if (playlist.TryGetProperty("images", out var images) &&
images.TryGetProperty("items", out var imageItems) &&
imageItems.GetArrayLength() > 0)
{
var firstImage = imageItems[0];
if (firstImage.TryGetProperty("sources", out var sources) &&
sources.GetArrayLength() > 0)
{
var firstSource = sources[0];
if (firstSource.TryGetProperty("url", out var urlProp))
{
imageUrl = urlProp.GetString();
}
}
}
playlists.Add(new SpotifyPlaylist
{
SpotifyId = spotifyId,
Name = itemName,
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
TotalTracks = trackCount,
OwnerName = ownerName,
ImageUrl = imageUrl,
SnapshotId = null
});
}
if (itemCount < limit) break;
if (items.GetArrayLength() < limit) break;
offset += limit;
// Add delay between pages to avoid rate limiting
// Library fetching can be aggressive, so use a longer delay
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
_logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs);
await Task.Delay(delayMs, cancellationToken);
if (_settings.RateLimitDelayMs > 0)
{
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
}
}
_logger.LogInformation("Found {Count} playlists{Filter} via GraphQL",
playlists.Count,
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
return playlists;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL",
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
_logger.LogError(ex, "Error searching user playlists for '{SearchName}'", searchName);
return new List<SpotifyPlaylist>();
}
}

View File

@@ -3,7 +3,6 @@ using allstarr.Models.Spotify;
using allstarr.Services.Common;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Cronos;
namespace allstarr.Services.Spotify;
@@ -15,9 +14,6 @@ namespace allstarr.Services.Spotify;
/// - ISRC codes available for exact matching
/// - Real-time data without waiting for plugin sync schedules
/// - Full track metadata (duration, release date, etc.)
///
/// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval.
/// Cache persists until next cron run to prevent excess Spotify API calls.
/// </summary>
public class SpotifyPlaylistFetcher : BackgroundService
{
@@ -49,7 +45,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
/// <summary>
/// Gets the Spotify playlist tracks in order, using cache if available.
/// Cache persists until next cron run to prevent excess API calls.
/// </summary>
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
@@ -62,38 +57,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (cached != null && cached.Tracks.Count > 0)
{
var age = DateTime.UtcNow - cached.FetchedAt;
// Calculate if cache should still be valid based on cron schedule
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
var shouldRefresh = false;
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.SyncSchedule))
{
try
{
var cron = CronExpression.Parse(playlistConfig.SyncSchedule);
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
if (nextRun.HasValue && DateTime.UtcNow >= nextRun.Value)
{
shouldRefresh = true;
_logger.LogInformation("Cache expired for '{Name}' - next cron run was at {NextRun} UTC",
playlistName, nextRun.Value);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not parse cron schedule for '{Name}', falling back to cache duration", playlistName);
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
}
}
else
{
// No cron schedule, use cache duration from settings
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
}
if (!shouldRefresh)
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
{
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
playlistName, cached.Tracks.Count, age.TotalMinutes);
@@ -130,11 +94,11 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
{
// Check if we have a configured Spotify ID for this playlist
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
if (config != null && !string.IsNullOrEmpty(config.Id))
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
{
// Use the configured Spotify playlist ID directly
spotifyId = config.Id;
spotifyId = playlistConfig.Id;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
}
@@ -180,39 +144,12 @@ public class SpotifyPlaylistFetcher : BackgroundService
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
}
// Calculate cache expiration based on cron schedule
var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
{
try
{
var cron = CronExpression.Parse(playlistCfg.SyncSchedule);
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
// Add 5 minutes buffer
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
_logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)",
playlistName, nextRun.Value, timeUntilNextRun.TotalHours);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
}
}
// Update cache with cron-based expiration
await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
// Update cache
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
await SaveToFileCacheAsync(playlistName, playlist);
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
playlistName, playlist.Tracks.Count);
return playlist.Tracks;
}
@@ -298,102 +235,32 @@ public class SpotifyPlaylistFetcher : BackgroundService
_logger.LogInformation("Spotify API ENABLED");
_logger.LogInformation("Authenticated via sp_dc session cookie");
_logger.LogInformation("Cache duration: {Minutes} minutes", _spotifyApiSettings.CacheDurationMinutes);
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
foreach (var playlist in _spotifyImportSettings.Playlists)
{
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
_logger.LogInformation(" - {Name}", playlist.Name);
}
_logger.LogInformation("========================================");
// Initial fetch of all playlists on startup
// Initial fetch of all playlists
await FetchAllPlaylistsAsync(stoppingToken);
// Cron-based refresh loop - only fetch when cron schedule triggers
// This prevents excess Spotify API calls
// Periodic refresh loop
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
try
{
// Check each playlist to see if it needs refreshing based on cron schedule
var now = DateTime.UtcNow;
var needsRefresh = new List<string>();
foreach (var config in _spotifyImportSettings.Playlists)
{
var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * 1" : config.SyncSchedule;
try
{
var cron = CronExpression.Parse(schedule);
// Check if we have cached data
var cacheKey = $"{CacheKeyPrefix}{config.Name}";
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
if (cached != null)
{
// Calculate when the next run should be after the last fetch
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
if (nextRun.HasValue && now >= nextRun.Value)
{
needsRefresh.Add(config.Name);
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
}
}
else
{
// No cache, fetch it
needsRefresh.Add(config.Name);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}", config.Name, schedule);
}
}
// Fetch playlists that need refreshing
if (needsRefresh.Count > 0)
{
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
foreach (var playlistName in needsRefresh)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
await GetPlaylistTracksAsync(playlistName);
// Rate limiting between playlists
if (playlistName != needsRefresh.Last())
{
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
}
}
_logger.LogInformation("=== FINISHED FETCHING PLAYLISTS ===");
}
// Sleep for 1 hour before checking again
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
await FetchAllPlaylistsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in playlist fetcher loop");
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
_logger.LogError(ex, "Error during periodic playlist refresh");
}
}
}

View File

@@ -6,7 +6,6 @@ using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using System.Text.Json;
using Cronos;
namespace allstarr.Services.Spotify;
@@ -18,9 +17,6 @@ namespace allstarr.Services.Spotify;
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
///
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
///
/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers.
/// Manual refresh is always allowed. Cache persists until next cron run.
/// </summary>
public class SpotifyTrackMatchingService : BackgroundService
{
@@ -31,10 +27,8 @@ public class SpotifyTrackMatchingService : BackgroundService
private readonly IServiceProvider _serviceProvider;
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
// Track last run time per playlist to prevent duplicate runs
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
private readonly TimeSpan _minimumRunInterval = TimeSpan.FromMinutes(5); // Cooldown between runs
private DateTime _lastMatchingRun = DateTime.MinValue;
private readonly TimeSpan _minimumMatchingInterval = TimeSpan.FromMinutes(5); // Don't run more than once per 5 minutes
public SpotifyTrackMatchingService(
IOptions<SpotifyImportSettings> spotifySettings,
@@ -63,29 +57,17 @@ public class SpotifyTrackMatchingService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("========================================");
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
if (!_spotifySettings.Enabled)
{
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
_logger.LogInformation("========================================");
return;
}
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
? "ISRC-preferred" : "fuzzy";
_logger.LogInformation("Matching mode: {Mode}", matchMode);
_logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule");
// Log all playlist schedules
foreach (var playlist in _spotifySettings.Playlists)
{
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
}
_logger.LogInformation("========================================");
// Wait a bit for the fetcher to run first
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
@@ -93,7 +75,7 @@ public class SpotifyTrackMatchingService : BackgroundService
// Run once on startup to match any existing missing tracks
try
{
_logger.LogInformation("Running initial track matching on startup (one-time)");
_logger.LogInformation("Running initial track matching on startup");
await MatchAllPlaylistsAsync(stoppingToken);
}
catch (Exception ex)
@@ -101,100 +83,46 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogError(ex, "Error during startup track matching");
}
// Now start the cron-based scheduling loop
// Now start the periodic matching loop
while (!stoppingToken.IsCancellationRequested)
{
// Wait for configured interval before next run (default 24 hours)
var intervalHours = _spotifySettings.MatchingIntervalHours;
if (intervalHours <= 0)
{
_logger.LogInformation("Periodic matching disabled (MatchingIntervalHours = {Hours}), only startup run will execute", intervalHours);
break; // Exit loop - only run once on startup
}
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
try
{
// Calculate next run time for each playlist
var now = DateTime.UtcNow;
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
foreach (var playlist in _spotifySettings.Playlists)
{
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
try
{
var cron = CronExpression.Parse(schedule);
var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
nextRuns.Add((playlist.Name, nextRun.Value, cron));
}
else
{
_logger.LogWarning("Could not calculate next run for playlist {Name} with schedule {Schedule}",
playlist.Name, schedule);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}",
playlist.Name, schedule);
}
}
if (nextRuns.Count == 0)
{
_logger.LogWarning("No valid cron schedules found, sleeping for 1 hour");
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
continue;
}
// Find the next playlist that needs to run
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
var waitTime = nextPlaylist.NextRun - now;
if (waitTime.TotalSeconds > 0)
{
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
// Wait until next run (or max 1 hour to re-check schedules)
var maxWait = TimeSpan.FromHours(1);
var actualWait = waitTime > maxWait ? maxWait : waitTime;
await Task.Delay(actualWait, stoppingToken);
continue;
}
// Time to run this playlist
_logger.LogInformation("=== CRON TRIGGER: Running scheduled match for {Playlist} ===", nextPlaylist.PlaylistName);
// Check cooldown to prevent duplicate runs
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
{
var timeSinceLastRun = now - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
{
_logger.LogInformation("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
continue;
}
}
// Run matching for this playlist
await MatchSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
_lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
_logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
await MatchAllPlaylistsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in cron scheduling loop");
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
_logger.LogError(ex, "Error in track matching service");
}
}
}
/// <summary>
/// Public method to trigger matching manually for all playlists (called from controller).
/// </summary>
public async Task TriggerMatchingAsync()
{
_logger.LogInformation("Manual track matching triggered for all playlists");
await MatchAllPlaylistsAsync(CancellationToken.None);
}
/// <summary>
/// Matches tracks for a single playlist (called by cron scheduler or manual trigger).
/// Public method to trigger matching for a specific playlist (called from controller).
/// </summary>
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
@@ -220,13 +148,13 @@ public class SpotifyTrackMatchingService : BackgroundService
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken);
playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
playlist.Name, metadataService, CancellationToken.None);
}
}
catch (Exception ex)
@@ -236,43 +164,19 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
/// <summary>
/// Public method to trigger matching manually for all playlists (called from controller).
/// This bypasses cron schedules and runs immediately.
/// </summary>
public async Task TriggerMatchingAsync()
{
_logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
await MatchAllPlaylistsAsync(CancellationToken.None);
}
/// <summary>
/// Public method to trigger matching for a specific playlist (called from controller).
/// This bypasses cron schedules and runs immediately.
/// </summary>
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (bypassing cron schedule)", playlistName);
// Check cooldown to prevent abuse
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
{
var timeSinceLastRun = DateTime.UtcNow - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
{
_logger.LogWarning("Skipping manual refresh for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before refreshing again");
}
}
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
_lastRunTimes[playlistName] = DateTime.UtcNow;
}
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
// Check if we've run too recently (cooldown period)
var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
if (timeSinceLastRun < _minimumMatchingInterval)
{
_logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)",
(int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
return;
}
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
_lastMatchingRun = DateTime.UtcNow;
var playlists = _spotifySettings.Playlists;
if (playlists.Count == 0)
@@ -281,13 +185,34 @@ public class SpotifyTrackMatchingService : BackgroundService
return;
}
using var scope = _serviceProvider.CreateScope();
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
// Check if we should use the new SpotifyPlaylistFetcher
SpotifyPlaylistFetcher? playlistFetcher = null;
if (_spotifyApiSettings.Enabled)
{
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
}
foreach (var playlist in playlists)
{
if (cancellationToken.IsCancellationRequested) break;
try
{
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
if (playlistFetcher != null)
{
// Use new direct API mode with ISRC support
await MatchPlaylistTracksWithIsrcAsync(
playlist.Name, playlistFetcher, metadataService, cancellationToken);
}
else
{
// Fall back to legacy mode
await MatchPlaylistTracksLegacyAsync(
playlist.Name, metadataService, cancellationToken);
}
}
catch (Exception ex)
{
@@ -295,7 +220,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
_logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ===");
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
}
/// <summary>
@@ -572,37 +497,8 @@ public class SpotifyTrackMatchingService : BackgroundService
if (matchedTracks.Count > 0)
{
// Calculate cache expiration: until next cron run (not just cache duration from settings)
var playlist = _spotifySettings.Playlists
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
{
try
{
var cron = CronExpression.Parse(playlist.SyncSchedule);
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
// Add 5 minutes buffer to ensure cache doesn't expire before next run
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
_logger.LogInformation("Cache will persist until next cron run: {NextRun} UTC (in {Hours:F1} hours)",
nextRun.Value, timeUntilNextRun.TotalHours);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not calculate next cron run for {Playlist}, using default cache duration", playlistName);
}
}
// Cache matched tracks with position data until next cron run
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
// Cache matched tracks with position data
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
// Save matched tracks to file for persistence across restarts
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
@@ -610,15 +506,15 @@ public class SpotifyTrackMatchingService : BackgroundService
// Also update legacy cache for backward compatibility
var legacyKey = $"spotify:matched:{playlistName}";
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
_logger.LogInformation(
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - cache expires in {Hours:F1}h",
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours);
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
// Pre-build playlist items cache for instant serving
// This is what makes the UI show all matched tracks at once
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
}
else
{
@@ -953,7 +849,6 @@ public class SpotifyTrackMatchingService : BackgroundService
string? jellyfinPlaylistId,
List<SpotifyPlaylistTrack> spotifyTracks,
List<MatchedTrack> matchedTracks,
TimeSpan cacheExpiration,
CancellationToken cancellationToken)
{
try
@@ -1301,9 +1196,9 @@ public class SpotifyTrackMatchingService : BackgroundService
if (finalItems.Count > 0)
{
// Save to Redis cache with same expiration as matched tracks (until next cron run)
// Save to Redis cache
var cacheKey = $"spotify:playlist:items:{playlistName}";
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
// Save to file cache for persistence
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
@@ -1315,8 +1210,8 @@ public class SpotifyTrackMatchingService : BackgroundService
}
_logger.LogInformation(
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
}
else
{

View File

@@ -595,7 +595,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
var allArtists = new List<string>();
var allArtistIds = new List<string>();
string artistName = "";
string? artistId = null;
@@ -605,11 +604,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
foreach (var artistEl in artists.EnumerateArray())
{
var name = artistEl.GetProperty("name").GetString();
var id = artistEl.GetProperty("id").GetInt64();
if (!string.IsNullOrEmpty(name))
{
allArtists.Add(name);
allArtistIds.Add($"ext-squidwtf-artist-{id}");
}
}
@@ -617,7 +614,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (allArtists.Count > 0)
{
artistName = allArtists[0];
artistId = allArtistIds[0];
artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}";
}
}
// Fallback to singular "artist" field
@@ -626,7 +623,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
artistName = artist.GetProperty("name").GetString() ?? "";
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
allArtists.Add(artistName);
allArtistIds.Add(artistId);
}
// Get album info
@@ -653,7 +649,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
Artist = artistName,
ArtistId = artistId,
Artists = allArtists,
ArtistIds = allArtistIds,
Album = albumTitle,
AlbumId = albumId,
Duration = track.TryGetProperty("duration", out var duration)
@@ -716,7 +711,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Get all artists - prefer "artists" array for collaborations
var allArtists = new List<string>();
var allArtistIds = new List<string>();
string artistName = "";
long artistIdNum = 0;
@@ -725,11 +719,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
foreach (var artistEl in artists.EnumerateArray())
{
var name = artistEl.GetProperty("name").GetString();
var id = artistEl.GetProperty("id").GetInt64();
if (!string.IsNullOrEmpty(name))
{
allArtists.Add(name);
allArtistIds.Add($"ext-squidwtf-artist-{id}");
}
}
@@ -744,7 +736,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
artistName = artist.GetProperty("name").GetString() ?? "";
artistIdNum = artist.GetProperty("id").GetInt64();
allArtists.Add(artistName);
allArtistIds.Add($"ext-squidwtf-artist-{artistIdNum}");
}
// Album artist - same as main artist for Tidal tracks
@@ -780,7 +771,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
Artist = artistName,
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
Artists = allArtists,
ArtistIds = allArtistIds,
Album = albumTitle,
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
AlbumArtist = albumArtist,

View File

@@ -73,11 +73,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
{
try
{
// 5 second timeout per ping - mark slow endpoints as failed
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
var response = await _httpClient.GetAsync(endpoint, ct);
return response.IsSuccessStatusCode;
}
catch

View File

@@ -12,7 +12,6 @@
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Cronos" Version="0.11.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
<PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />

View File

@@ -32,7 +32,8 @@
"EnableExternalPlaylists": true
},
"Library": {
"DownloadPath": "./downloads"
"DownloadPath": "./downloads",
"KeptPath": "/app/kept"
},
"Qobuz": {
"UserAuthToken": "your-qobuz-token",

View File

@@ -537,7 +537,7 @@
<div class="tabs">
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
<div class="tab" data-tab="playlists">Injected Playlists</div>
<div class="tab" data-tab="playlists">Active Playlists</div>
<div class="tab" data-tab="config">Configuration</div>
<div class="tab" data-tab="endpoints">API Analytics</div>
</div>
@@ -652,7 +652,7 @@
<div class="card">
<h2>
Injected Spotify Playlists
Active Spotify Playlists
<div class="actions">
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
@@ -660,14 +660,13 @@
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music service.
These are the Spotify playlists currently being monitored and filled with tracks from your music service.
</p>
<table class="playlist-table">
<thead>
<tr>
<th>Name</th>
<th>Spotify ID</th>
<th>Sync Schedule</th>
<th>Tracks</th>
<th>Completion</th>
<th>Cache Age</th>
@@ -676,7 +675,7 @@
</thead>
<tbody id="playlist-table-body">
<tr>
<td colspan="7" class="loading">
<td colspan="6" class="loading">
<span class="spinner"></span> Loading playlists...
</td>
</tr>
@@ -807,62 +806,8 @@
<!-- Configuration Tab -->
<div class="tab-content" id="tab-config">
<div class="card">
<h2>Core Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Backend Type <span style="color: var(--error);">*</span></span>
<span class="value" id="config-backend-type">-</span>
<button onclick="openEditSetting('BACKEND_TYPE', 'Backend Type', 'select', 'Choose your media server backend', ['Jellyfin', 'Subsonic'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Music Service <span style="color: var(--error);">*</span></span>
<span class="value" id="config-music-service">-</span>
<button onclick="openEditSetting('MUSIC_SERVICE', 'Music Service', 'select', 'Choose your music download provider', ['SquidWTF', 'Deezer', 'Qobuz'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Storage Mode</span>
<span class="value" id="config-storage-mode">-</span>
<button onclick="openEditSetting('STORAGE_MODE', 'Storage Mode', 'select', 'Permanent keeps files forever, Cache auto-deletes after duration', ['Permanent', 'Cache'])">Edit</button>
</div>
<div class="config-item" id="cache-duration-row" style="display: none;">
<span class="label">Cache Duration (hours)</span>
<span class="value" id="config-cache-duration-hours">-</span>
<button onclick="openEditSetting('CACHE_DURATION_HOURS', 'Cache Duration (hours)', 'number', 'How long to keep cached files before deletion')">Edit</button>
</div>
<div class="config-item">
<span class="label">Download Mode</span>
<span class="value" id="config-download-mode">-</span>
<button onclick="openEditSetting('DOWNLOAD_MODE', 'Download Mode', 'select', 'Download individual tracks or full albums', ['Track', 'Album'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Explicit Filter</span>
<span class="value" id="config-explicit-filter">-</span>
<button onclick="openEditSetting('EXPLICIT_FILTER', 'Explicit Filter', 'select', 'Filter explicit content', ['All', 'Explicit', 'Clean'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Enable External Playlists</span>
<span class="value" id="config-enable-external-playlists">-</span>
<button onclick="openEditSetting('ENABLE_EXTERNAL_PLAYLISTS', 'Enable External Playlists', 'toggle')">Edit</button>
</div>
<div class="config-item">
<span class="label">Playlists Directory</span>
<span class="value" id="config-playlists-directory">-</span>
<button onclick="openEditSetting('PLAYLISTS_DIRECTORY', 'Playlists Directory', 'text', 'Directory path for external playlists')">Edit</button>
</div>
<div class="config-item">
<span class="label">Redis Enabled</span>
<span class="value" id="config-redis-enabled">-</span>
<button onclick="openEditSetting('REDIS_ENABLED', 'Redis Enabled', 'toggle')">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>Spotify API Settings</h2>
<div style="background: rgba(248, 81, 73, 0.15); border: 1px solid var(--error); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
⚠️ For active playlists and link functionality to work, sp_dc session cookie must be set!
</div>
<div class="config-section">
<div class="config-item">
<span class="label">API Enabled</span>
@@ -870,7 +815,7 @@
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
</div>
<div class="config-item">
<span class="label">Session Cookie (sp_dc) <span style="color: var(--error);">*</span></span>
<span class="label">Session Cookie (sp_dc)</span>
<span class="value" id="config-spotify-cookie">-</span>
<button onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
</div>
@@ -959,17 +904,17 @@
<h2>Jellyfin Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">URL <span style="color: var(--error);">*</span></span>
<span class="label">URL</span>
<span class="value" id="config-jellyfin-url">-</span>
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
</div>
<div class="config-item">
<span class="label">API Key <span style="color: var(--error);">*</span></span>
<span class="label">API Key</span>
<span class="value" id="config-jellyfin-api-key">-</span>
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
</div>
<div class="config-item">
<span class="label">User ID <span style="color: var(--error);">*</span></span>
<span class="label">User ID</span>
<span class="value" id="config-jellyfin-user-id">-</span>
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
</div>
@@ -998,17 +943,17 @@
</div>
<div class="card">
<h2>Spotify Import Settings</h2>
<h2>Sync Schedule</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Spotify Import Enabled</span>
<span class="value" id="config-spotify-import-enabled">-</span>
<button onclick="openEditSetting('SPOTIFY_IMPORT_ENABLED', 'Spotify Import Enabled', 'toggle')">Edit</button>
<span class="label">Sync Start Time</span>
<span class="value" id="config-sync-time">-</span>
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_START_HOUR', 'Sync Start Hour (0-23)', 'number')">Edit</button>
</div>
<div class="config-item">
<span class="label">Matching Interval (hours)</span>
<span class="value" id="config-matching-interval">-</span>
<button onclick="openEditSetting('SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS', 'Matching Interval (hours)', 'number', 'How often to check for playlist updates')">Edit</button>
<span class="label">Sync Window</span>
<span class="value" id="config-sync-window">-</span>
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_WINDOW_HOURS', 'Sync Window (hours)', 'number')">Edit</button>
</div>
</div>
</div>
@@ -1174,7 +1119,7 @@
<div class="modal-content" style="max-width: 600px;">
<h3>Map Track to External Provider</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Jellyfin mapping modal instead.
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
</p>
<!-- Track Info -->
@@ -1216,94 +1161,25 @@
</div>
</div>
<!-- Local Jellyfin Track Mapping Modal -->
<div class="modal" id="local-map-modal">
<div class="modal-content" style="max-width: 700px;">
<h3>Map Track to Local Jellyfin Track</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Search your Jellyfin library and select a local track to map to this Spotify track.
</p>
<!-- Track Info -->
<div class="form-group">
<label>Spotify Track (Position <span id="local-map-position"></span>)</label>
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
<strong id="local-map-spotify-title"></strong><br>
<span style="color: var(--text-secondary);" id="local-map-spotify-artist"></span>
</div>
</div>
<!-- Search Section -->
<div class="form-group">
<label>Search Jellyfin Library</label>
<input type="text" id="local-map-search" placeholder="Search for track name or artist...">
<button onclick="searchJellyfinTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
</div>
<!-- Search Results -->
<div id="local-map-results" style="max-height: 300px; overflow-y: auto; margin-top: 16px;"></div>
<input type="hidden" id="local-map-playlist-name">
<input type="hidden" id="local-map-spotify-id">
<input type="hidden" id="local-map-jellyfin-id">
<div class="modal-actions">
<button onclick="closeModal('local-map-modal')">Cancel</button>
<button class="primary" onclick="saveLocalMapping()" id="local-map-save-btn" disabled>Save Mapping</button>
</div>
</div>
</div>
<!-- Link Playlist Modal -->
<div class="modal" id="link-playlist-modal">
<div class="modal-content">
<h3>Link to Spotify Playlist</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Select a playlist from your Spotify library or enter a playlist ID/URL manually. Allstarr will automatically download missing tracks from your configured music service.
Enter the Spotify playlist ID or URL. Allstarr will automatically download missing tracks from your configured music service.
</p>
<div class="form-group">
<label>Jellyfin Playlist</label>
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
<input type="hidden" id="link-jellyfin-id">
</div>
<!-- Toggle between select and manual input -->
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<button type="button" id="select-mode-btn" class="primary" onclick="switchLinkMode('select')" style="flex: 1;">Select from My Playlists</button>
<button type="button" id="manual-mode-btn" onclick="switchLinkMode('manual')" style="flex: 1;">Enter Manually</button>
</div>
<!-- Select from user playlists -->
<div class="form-group" id="link-select-group">
<label>Your Spotify Playlists</label>
<select id="link-spotify-select" style="width: 100%;">
<option value="">Loading playlists...</option>
</select>
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Select a playlist from your Spotify library
</small>
</div>
<!-- Manual input -->
<div class="form-group" id="link-manual-group" style="display: none;">
<div class="form-group">
<label>Spotify Playlist ID or URL</label>
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
</small>
</div>
<!-- Sync Schedule -->
<div class="form-group">
<label>Sync Schedule (Cron)</label>
<input type="text" id="link-sync-schedule" placeholder="0 8 * * 1" value="0 8 * * 1" style="font-family: monospace;">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
Cron format: <code>minute hour day month dayofweek</code><br>
Default: <code>0 8 * * 1</code> = 8 AM every Monday<br>
Examples: <code>0 6 * * *</code> = daily at 6 AM, <code>0 20 * * 5</code> = Fridays at 8 PM<br>
<a href="https://crontab.guru/" target="_blank" style="color: var(--primary);">Use crontab.guru to build your schedule</a>
</small>
</div>
<div class="modal-actions">
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
@@ -1584,7 +1460,7 @@
if (data.playlists.length === 0) {
if (!silent) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
}
return;
}
@@ -1638,16 +1514,10 @@
// Debug logging
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
const syncSchedule = p.syncSchedule || '0 8 * * 1';
return `
<tr>
<td><strong>${escapeHtml(p.name)}</strong></td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
<td style="font-family:monospace;font-size:0.85rem;">
${escapeHtml(syncSchedule)}
<button onclick="editPlaylistSchedule('${escapeJs(p.name)}', '${escapeJs(syncSchedule)}')" style="margin-left:4px;font-size:0.75rem;padding:2px 6px;">Edit</button>
</td>
<td>${statsHtml}${breakdown}</td>
<td>
<div style="display:flex;align-items:center;gap:8px;">
@@ -1906,23 +1776,6 @@
const res = await fetch('/api/admin/config');
const data = await res.json();
// Core settings
document.getElementById('config-backend-type').textContent = data.backendType || 'Jellyfin';
document.getElementById('config-music-service').textContent = data.musicService || 'SquidWTF';
document.getElementById('config-storage-mode').textContent = data.library?.storageMode || 'Cache';
document.getElementById('config-cache-duration-hours').textContent = data.library?.cacheDurationHours || '24';
document.getElementById('config-download-mode').textContent = data.library?.downloadMode || 'Track';
document.getElementById('config-explicit-filter').textContent = data.explicitFilter || 'All';
document.getElementById('config-enable-external-playlists').textContent = data.enableExternalPlaylists ? 'Yes' : 'No';
document.getElementById('config-playlists-directory').textContent = data.playlistsDirectory || '(not set)';
document.getElementById('config-redis-enabled').textContent = data.redisEnabled ? 'Yes' : 'No';
// Show/hide cache duration based on storage mode
const cacheDurationRow = document.getElementById('cache-duration-row');
if (cacheDurationRow) {
cacheDurationRow.style.display = data.library?.storageMode === 'Cache' ? 'grid' : 'none';
}
// Spotify API settings
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
@@ -1964,8 +1817,10 @@
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
// Sync settings
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' hours';
const syncHour = data.spotifyImport.syncStartHour;
const syncMin = data.spotifyImport.syncStartMinute;
document.getElementById('config-sync-time').textContent = `${String(syncHour).padStart(2, '0')}:${String(syncMin).padStart(2, '0')}`;
document.getElementById('config-sync-window').textContent = data.spotifyImport.syncWindowHours + ' hours';
} catch (error) {
console.error('Failed to fetch config:', error);
}
@@ -2041,138 +1896,23 @@
}
}
let currentLinkMode = 'select'; // 'select' or 'manual'
let spotifyUserPlaylists = []; // Cache of user playlists
function switchLinkMode(mode) {
currentLinkMode = mode;
const selectGroup = document.getElementById('link-select-group');
const manualGroup = document.getElementById('link-manual-group');
const selectBtn = document.getElementById('select-mode-btn');
const manualBtn = document.getElementById('manual-mode-btn');
if (mode === 'select') {
selectGroup.style.display = 'block';
manualGroup.style.display = 'none';
selectBtn.classList.add('primary');
manualBtn.classList.remove('primary');
} else {
selectGroup.style.display = 'none';
manualGroup.style.display = 'block';
selectBtn.classList.remove('primary');
manualBtn.classList.add('primary');
}
}
async function fetchSpotifyUserPlaylists() {
try {
const res = await fetch('/api/admin/spotify/user-playlists');
if (!res.ok) {
const error = await res.json();
console.error('Failed to fetch Spotify playlists:', res.status, error);
// Show user-friendly error message
if (res.status === 429) {
showToast('Spotify rate limit reached. Please wait a moment and try again.', 'warning', 5000);
} else if (res.status === 401) {
showToast('Spotify authentication failed. Check your sp_dc cookie.', 'error', 5000);
}
return [];
}
const data = await res.json();
return data.playlists || [];
} catch (error) {
console.error('Failed to fetch Spotify playlists:', error);
return [];
}
}
async function openLinkPlaylist(jellyfinId, name) {
function openLinkPlaylist(jellyfinId, name) {
document.getElementById('link-jellyfin-id').value = jellyfinId;
document.getElementById('link-jellyfin-name').value = name;
document.getElementById('link-spotify-id').value = '';
// Reset to select mode
switchLinkMode('select');
// Fetch user playlists if not already cached
if (spotifyUserPlaylists.length === 0) {
const select = document.getElementById('link-spotify-select');
select.innerHTML = '<option value="">Loading playlists...</option>';
spotifyUserPlaylists = await fetchSpotifyUserPlaylists();
// Filter out already-linked playlists
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
if (availablePlaylists.length === 0) {
if (spotifyUserPlaylists.length > 0) {
select.innerHTML = '<option value="">All your playlists are already linked</option>';
} else {
select.innerHTML = '<option value="">No playlists found or Spotify not configured</option>';
}
// Switch to manual mode if no available playlists
switchLinkMode('manual');
} else {
// Populate dropdown with only unlinked playlists
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
availablePlaylists.map(p =>
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
).join('');
}
} else {
// Re-filter in case playlists were linked since last fetch
const select = document.getElementById('link-spotify-select');
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
if (availablePlaylists.length === 0) {
select.innerHTML = '<option value="">All your playlists are already linked</option>';
switchLinkMode('manual');
} else {
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
availablePlaylists.map(p =>
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
).join('');
}
}
openModal('link-playlist-modal');
}
async function linkPlaylist() {
const jellyfinId = document.getElementById('link-jellyfin-id').value;
const name = document.getElementById('link-jellyfin-name').value;
const syncSchedule = document.getElementById('link-sync-schedule').value.trim();
const spotifyId = document.getElementById('link-spotify-id').value.trim();
// Validate sync schedule (basic cron format check)
if (!syncSchedule) {
showToast('Sync schedule is required', 'error');
if (!spotifyId) {
showToast('Spotify Playlist ID is required', 'error');
return;
}
const cronParts = syncSchedule.split(/\s+/);
if (cronParts.length !== 5) {
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
return;
}
// Get Spotify ID based on current mode
let spotifyId = '';
if (currentLinkMode === 'select') {
spotifyId = document.getElementById('link-spotify-select').value;
if (!spotifyId) {
showToast('Please select a Spotify playlist', 'error');
return;
}
} else {
spotifyId = document.getElementById('link-spotify-id').value.trim();
if (!spotifyId) {
showToast('Spotify Playlist ID is required', 'error');
return;
}
}
// Extract ID from various Spotify formats:
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
@@ -2195,11 +1935,7 @@
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
spotifyPlaylistId: cleanSpotifyId,
syncSchedule: syncSchedule
})
body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
});
const data = await res.json();
@@ -2209,9 +1945,6 @@
showRestartBanner();
closeModal('link-playlist-modal');
// Clear the Spotify playlists cache so it refreshes next time
spotifyUserPlaylists = [];
// Update UI state without refetching all playlists
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
if (playlistsTable) {
@@ -2249,9 +1982,6 @@
showToast('Playlist unlinked.', 'success');
showRestartBanner();
// Clear the Spotify playlists cache so it refreshes next time
spotifyUserPlaylists = [];
// Update UI state without refetching all playlists
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
if (playlistsTable) {
@@ -2615,39 +2345,6 @@
}
}
async function editPlaylistSchedule(playlistName, currentSchedule) {
const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM\n\nUse https://crontab.guru/ to build your schedule`, currentSchedule);
if (!newSchedule || newSchedule === currentSchedule) return;
// Validate cron format
const cronParts = newSchedule.trim().split(/\s+/);
if (cronParts.length !== 5) {
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
return;
}
try {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ syncSchedule: newSchedule.trim() })
});
if (res.ok) {
showToast('Sync schedule updated!', 'success');
showRestartBanner();
fetchPlaylists();
} else {
const error = await res.json();
showToast(error.error || 'Failed to update schedule', 'error');
}
} catch (error) {
console.error('Failed to update schedule:', error);
showToast('Failed to update schedule', 'error');
}
}
async function removePlaylist(name) {
if (!confirm(`Remove playlist "${name}"?`)) return;
@@ -2677,23 +2374,8 @@
try {
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
if (!res.ok) {
console.error('Failed to fetch tracks:', res.status, res.statusText);
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + res.status + ' ' + res.statusText + '</p>';
return;
}
const data = await res.json();
console.log('Tracks data received:', data);
if (!data || !data.tracks) {
console.error('Invalid data structure:', data);
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Invalid data received from server</p>';
return;
}
if (data.tracks.length === 0) {
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
return;
@@ -2808,8 +2490,7 @@
});
});
} catch (error) {
console.error('Error in viewTracks:', error);
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + error.message + '</p>';
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
}
}
@@ -3034,27 +2715,8 @@
saveBtn.disabled = !externalId;
}
// Open local Jellyfin mapping modal
// Open manual mapping modal (external only)
function openManualMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('local-map-playlist-name').value = playlistName;
document.getElementById('local-map-position').textContent = position + 1;
document.getElementById('local-map-spotify-title').textContent = title;
document.getElementById('local-map-spotify-artist').textContent = artist;
document.getElementById('local-map-spotify-id').value = spotifyId;
// Pre-fill search with track info
document.getElementById('local-map-search').value = `${title} ${artist}`;
// Reset fields
document.getElementById('local-map-results').innerHTML = '';
document.getElementById('local-map-jellyfin-id').value = '';
document.getElementById('local-map-save-btn').disabled = true;
openModal('local-map-modal');
}
// Open external mapping modal
function openExternalMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('map-playlist-name').value = playlistName;
document.getElementById('map-position').textContent = position + 1;
document.getElementById('map-spotify-title').textContent = title;
@@ -3069,123 +2731,12 @@
openModal('manual-map-modal');
}
// Search Jellyfin tracks for local mapping
async function searchJellyfinTracks() {
const query = document.getElementById('local-map-search').value.trim();
if (!query) {
showToast('Please enter a search query', 'error');
return;
}
const resultsDiv = document.getElementById('local-map-results');
resultsDiv.innerHTML = '<p style="text-align:center;padding:20px;">Searching...</p>';
try {
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
const data = await res.json();
if (!res.ok) {
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
return;
}
if (!data.tracks || data.tracks.length === 0) {
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">No tracks found</p>';
return;
}
resultsDiv.innerHTML = data.tracks.map(track => `
<div style="padding: 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: background 0.2s;"
onclick="selectJellyfinTrack('${escapeJs(track.id)}', '${escapeJs(track.name)}', '${escapeJs(track.artist)}')"
onmouseover="this.style.background='var(--bg-primary)'"
onmouseout="this.style.background='transparent'">
<strong>${escapeHtml(track.name)}</strong><br>
<span style="color: var(--text-secondary); font-size: 0.9em;">${escapeHtml(track.artist)}</span>
${track.album ? '<br><span style="color: var(--text-secondary); font-size: 0.85em;">' + escapeHtml(track.album) + '</span>' : ''}
</div>
`).join('');
} catch (error) {
console.error('Search error:', error);
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
}
// Alias for backward compatibility
function openExternalMap(playlistName, position, title, artist, spotifyId) {
openManualMap(playlistName, position, title, artist, spotifyId);
}
// Select a Jellyfin track for mapping
function selectJellyfinTrack(jellyfinId, name, artist) {
document.getElementById('local-map-jellyfin-id').value = jellyfinId;
document.getElementById('local-map-save-btn').disabled = false;
// Highlight selected track
document.querySelectorAll('#local-map-results > div').forEach(div => {
div.style.background = 'transparent';
div.style.border = '1px solid var(--border)';
});
event.target.closest('div').style.background = 'var(--primary)';
event.target.closest('div').style.border = '1px solid var(--primary)';
}
// Save local Jellyfin mapping
async function saveLocalMapping() {
const playlistName = document.getElementById('local-map-playlist-name').value;
const spotifyId = document.getElementById('local-map-spotify-id').value;
const jellyfinId = document.getElementById('local-map-jellyfin-id').value;
if (!jellyfinId) {
showToast('Please select a Jellyfin track', 'error');
return;
}
const requestBody = {
spotifyId,
jellyfinId
};
// Show loading state
const saveBtn = document.getElementById('local-map-save-btn');
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/map`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: controller.signal
});
clearTimeout(timeoutId);
if (res.ok) {
showToast('Track mapped successfully!', 'success');
closeModal('local-map-modal');
// Refresh the tracks view if it's open
const tracksModal = document.getElementById('tracks-modal');
if (tracksModal.style.display === 'flex') {
await viewTracks(playlistName);
}
} else {
const data = await res.json();
showToast(data.error || 'Failed to save mapping', 'error');
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
} catch (error) {
if (error.name === 'AbortError') {
showToast('Request timed out. The mapping may still be processing.', 'warning');
} else {
showToast('Failed to save mapping', 'error');
}
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
// Save manual mapping (external only) - kept for backward compatibility
// Save manual mapping (external only)
async function saveManualMapping() {
const playlistName = document.getElementById('map-playlist-name').value;
const spotifyId = document.getElementById('map-spotify-id').value;

View File

@@ -17,11 +17,8 @@ services:
networks:
- allstarr-network
# Spotify Lyrics API sidecar service
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
spotify-lyrics:
image: akashrchandran/spotify-lyrics-api:latest
platform: linux/amd64
container_name: allstarr-spotify-lyrics
restart: unless-stopped
ports: