diff --git a/.env.example b/.env.example index 8f9b826..2489a77 100644 --- a/.env.example +++ b/.env.example @@ -126,29 +126,18 @@ SPOTIFY_IMPORT_SYNC_START_MINUTE=15 # Example: If plugin runs at 4:15 PM and window is 2 hours, checks from 4:00 PM to 6:00 PM SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2 -# Playlists configuration (SIMPLE FORMAT - recommended for .env files) -# Comma-separated lists - all three must have the same number of items +# Playlists configuration (JSON ARRAY FORMAT - managed by web UI) +# Format: [["PlaylistName","SpotifyPlaylistId","first|last"],...] +# - PlaylistName: Name as it appears in Jellyfin +# - SpotifyPlaylistId: Get from Spotify URL (e.g., 37i9dQZF1DXcBWIGoYBM5M) +# Accepts: spotify:playlist:ID, full URL, or just the ID +# - first|last: Where to position local tracks (first=local tracks first, last=external tracks first) # -# 1. Playlist IDs (get from Jellyfin playlist URL: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID) -SPOTIFY_IMPORT_PLAYLIST_IDS= +# Example: +# SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]] # -# 2. Playlist names (as they appear in Jellyfin) -SPOTIFY_IMPORT_PLAYLIST_NAMES= -# -# 3. Local track positions (optional - defaults to "first" if not specified) -# - "first": Local tracks appear first, external tracks at the end -# - "last": External tracks appear first, local tracks at the end -SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS= -# -# Example with 4 playlists: -# SPOTIFY_IMPORT_PLAYLIST_IDS=4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0,8203ce3be9b0053b122190eb23bac7ea,7c2b218bd69b00e24c986363ba71852f -# SPOTIFY_IMPORT_PLAYLIST_NAMES=Discover Weekly,Release Radar,Today's Top Hits,On Repeat -# SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS=first,first,last,first -# -# Advanced: JSON array format (use only if you can't use the simple format above) -# Format: [["PlaylistName","JellyfinPlaylistId","first|last"],...] -# Note: This format may not work in .env files due to Docker Compose limitations -# SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","4383a46d8bcac3be2ef9385053ea18df","first"],["Release Radar","ba50e26c867ec9d57ab2f7bf24cfd6b0","last"]] +# RECOMMENDED: Use the web UI (Link Playlists tab) to manage playlists instead of editing this manually +SPOTIFY_IMPORT_PLAYLISTS=[] # ===== SPOTIFY DIRECT API (RECOMMENDED - ENABLES TRACK ORDERING & LYRICS) ===== # This is the preferred method for Spotify playlist integration. @@ -176,6 +165,11 @@ SPOTIFY_API_CLIENT_SECRET= # 4. Note: This cookie expires periodically (typically every few months) SPOTIFY_API_SESSION_COOKIE= +# Date when the session cookie was set (ISO 8601 format) +# Automatically set by the web UI when you update the cookie +# Used to track cookie age and warn when approaching expiration (~1 year) +SPOTIFY_API_SESSION_COOKIE_SET_DATE= + # Cache duration for playlist data in minutes (default: 60) # Release Radar updates weekly, Discover Weekly updates Mondays SPOTIFY_API_CACHE_DURATION_MINUTES=60 diff --git a/README.md b/README.md index c2f4a31..c6917b7 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,46 @@ docker-compose logs -f The proxy will be available at `http://localhost:5274`. +## Web Dashboard + +Allstarr includes a web-based dashboard for easy configuration and playlist management, accessible at `http://localhost:5275` (internal port, not exposed through reverse proxy). + +### Features + +- **Real-time Status**: Monitor Spotify authentication, cookie age, and playlist sync status +- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with a few clicks +- **Configuration Editor**: Update settings without manually editing .env files +- **Track Viewer**: Browse tracks in your configured playlists +- **Cache Management**: Clear cached data and restart the container + +### Quick Setup with Web UI + +1. **Access the dashboard** at `http://localhost:5275` +2. **Configure Spotify** (Configuration tab): + - Enable Spotify API + - Add your `sp_dc` cookie from Spotify (see instructions in UI) + - The cookie age is automatically tracked +3. **Link playlists** (Link Playlists tab): + - View all your Jellyfin playlists + - Click "Link to Spotify" on any playlist + - Paste the Spotify playlist ID, URL, or `spotify:playlist:` URI + - Accepts formats like: + - `37i9dQZF1DXcBWIGoYBM5M` (just the ID) + - `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI) + - `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL) +4. **Restart** to apply changes (button in Configuration tab) + +### Why Two Playlist Tabs? + +- **Link Playlists**: Shows all Jellyfin playlists and lets you connect them to Spotify +- **Active Playlists**: Shows which Spotify playlists are currently being monitored and filled with tracks + +### Configuration Persistence + +The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`. + +**Recommended workflow**: Use the `sp_dc` cookie method (simpler and more reliable than the Jellyfin Spotify Import plugin). + ### 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! diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 5c8ff36..2082452 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -32,12 +32,14 @@ public class AdminController : ControllerBase private readonly SpotifyPlaylistFetcher _playlistFetcher; private readonly RedisCacheService _cache; private readonly HttpClient _jellyfinHttpClient; - private const string EnvFilePath = "/app/.env"; + private readonly IWebHostEnvironment _environment; + private readonly string _envFilePath; private const string CacheDirectory = "/app/cache/spotify"; public AdminController( ILogger logger, IConfiguration configuration, + IWebHostEnvironment environment, IOptions spotifyApiSettings, IOptions spotifyImportSettings, IOptions jellyfinSettings, @@ -51,6 +53,7 @@ public class AdminController : ControllerBase { _logger = logger; _configuration = configuration; + _environment = environment; _spotifyApiSettings = spotifyApiSettings.Value; _spotifyImportSettings = spotifyImportSettings.Value; _jellyfinSettings = jellyfinSettings.Value; @@ -61,6 +64,14 @@ public class AdminController : ControllerBase _playlistFetcher = playlistFetcher; _cache = cache; _jellyfinHttpClient = httpClientFactory.CreateClient(); + + // .env file path is always /app/.env in Docker (mounted from host) + // In development, it's in the parent directory of ContentRootPath + _envFilePath = _environment.IsDevelopment() + ? Path.Combine(_environment.ContentRootPath, "..", ".env") + : "/app/.env"; + + _logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath); } /// @@ -290,15 +301,21 @@ public class AdminController : ControllerBase try { + // Check if .env file exists + if (!System.IO.File.Exists(_envFilePath)) + { + _logger.LogWarning(".env file not found at {Path}, creating new file", _envFilePath); + } + // Read current .env file or create new one var envContent = new Dictionary(); - if (System.IO.File.Exists(EnvFilePath)) + if (System.IO.File.Exists(_envFilePath)) { - var lines = await System.IO.File.ReadAllLinesAsync(EnvFilePath); + var lines = await System.IO.File.ReadAllLinesAsync(_envFilePath); foreach (var line in lines) { - if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#')) + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) continue; var eqIndex = line.IndexOf('='); @@ -309,6 +326,7 @@ public class AdminController : ControllerBase envContent[key] = value; } } + _logger.LogInformation("Loaded {Count} existing env vars from {Path}", envContent.Count, _envFilePath); } // Apply updates with validation @@ -318,12 +336,16 @@ public class AdminController : ControllerBase // Validate key format if (!IsValidEnvKey(key)) { + _logger.LogWarning("Invalid env key rejected: {Key}", key); return BadRequest(new { error = $"Invalid environment variable key: {key}" }); } envContent[key] = value; appliedUpdates.Add(key); - _logger.LogInformation(" Setting {Key}", key); + _logger.LogInformation(" Setting {Key} = {Value}", key, + key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") + ? "***" + (value.Length > 8 ? value[^8..] : "") + : value); // Auto-set cookie date when Spotify session cookie is updated if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value)) @@ -338,21 +360,35 @@ public class AdminController : ControllerBase // Write back to .env file var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}")); - await System.IO.File.WriteAllTextAsync(EnvFilePath, newContent + "\n"); + await System.IO.File.WriteAllTextAsync(_envFilePath, newContent + "\n"); - _logger.LogInformation("Config file updated successfully"); + _logger.LogInformation("Config file updated successfully at {Path}", _envFilePath); return Ok(new { message = "Configuration updated. Restart container to apply changes.", updatedKeys = appliedUpdates, - requiresRestart = true + requiresRestart = true, + envFilePath = _envFilePath + }); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogError(ex, "Permission denied writing to .env file at {Path}", _envFilePath); + return StatusCode(500, new { + error = "Permission denied", + details = "Cannot write to .env file. Check file permissions and volume mount.", + path = _envFilePath }); } catch (Exception ex) { - _logger.LogError(ex, "Failed to update configuration"); - return StatusCode(500, new { error = "Failed to update configuration", details = ex.Message }); + _logger.LogError(ex, "Failed to update configuration at {Path}", _envFilePath); + return StatusCode(500, new { + error = "Failed to update configuration", + details = ex.Message, + path = _envFilePath + }); } } @@ -793,6 +829,7 @@ public class AdminController : ControllerBase var response = await _jellyfinHttpClient.SendAsync(request); if (!response.IsSuccessStatusCode) { + _logger.LogWarning("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode); return (0, 0, 0); } @@ -807,62 +844,48 @@ public class AdminController : ControllerBase { foreach (var item in items.EnumerateArray()) { - // Check if track has a local path (is in user's library) - var hasPath = item.TryGetProperty("Path", out var path) && - path.ValueKind == JsonValueKind.String && - !string.IsNullOrEmpty(path.GetString()); + // Simpler detection: Check if Path exists and is not empty + // External tracks from allstarr won't have a Path property + var hasPath = item.TryGetProperty("Path", out var pathProp) && + pathProp.ValueKind == JsonValueKind.String && + !string.IsNullOrEmpty(pathProp.GetString()); - // Check MediaSources to see if it's a local file vs external - var isLocal = false; - if (item.TryGetProperty("MediaSources", out var mediaSources) && - mediaSources.ValueKind == JsonValueKind.Array) + if (hasPath) { - foreach (var source in mediaSources.EnumerateArray()) + var pathStr = pathProp.GetString()!; + // Check if it's a real file path (not a URL) + if (pathStr.StartsWith("/") || pathStr.Contains(":\\")) { - if (source.TryGetProperty("Protocol", out var protocol)) - { - var protocolStr = protocol.GetString(); - if (protocolStr == "File") - { - isLocal = true; - break; - } - } - // Also check if Path exists in MediaSource - if (source.TryGetProperty("Path", out var sourcePath) && - sourcePath.ValueKind == JsonValueKind.String && - !string.IsNullOrEmpty(sourcePath.GetString())) - { - isLocal = true; - break; - } + localTracks++; + } + else + { + // It's a URL or external source + externalTracks++; + externalAvailable++; } - } - - // Fallback to checking Path property - if (!isLocal && hasPath) - { - isLocal = true; - } - - if (isLocal) - { - localTracks++; } else { + // No path means it's external externalTracks++; - // For now, if it's in the playlist but not local, it means a provider found it externalAvailable++; } } + + _logger.LogDebug("Playlist {PlaylistId} stats: {Local} local, {External} external", + playlistId, localTracks, externalTracks); + } + else + { + _logger.LogWarning("No Items property in playlist response for {PlaylistId}", playlistId); } return (localTracks, externalTracks, externalAvailable); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to get track stats for playlist {PlaylistId}", playlistId); + _logger.LogError(ex, "Failed to get track stats for playlist {PlaylistId}", playlistId); return (0, 0, 0); } } diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 1651b56..4400dba 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -3123,7 +3123,7 @@ public class JellyfinController : ControllerBase { try { - // Get the song metadata + // Get the song metadata first to check if already in kept folder var song = await _metadataService.GetSongAsync(provider, externalId); if (song == null) { @@ -3131,7 +3131,25 @@ public class JellyfinController : ControllerBase return; } - // Trigger download first + // Build kept folder path: /app/kept/Artist/Album/ + var keptBasePath = "/app/kept"; + var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist)); + var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album)); + + // Check if track already exists in kept folder BEFORE downloading + // Look for any file matching the song title pattern (any extension) + if (Directory.Exists(keptAlbumPath)) + { + var sanitizedTitle = PathHelper.SanitizeFileName(song.Title); + var existingFiles = Directory.GetFiles(keptAlbumPath, $"{sanitizedTitle}.*"); + if (existingFiles.Length > 0) + { + _logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]); + return; + } + } + + // Track not in kept folder - download it _logger.LogInformation("Downloading track for kept folder: {ItemId}", itemId); string downloadPath; @@ -3145,20 +3163,17 @@ public class JellyfinController : ControllerBase return; } - // Create kept folder structure: /app/kept/Artist/Album/ - var keptBasePath = "/app/kept"; - var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist)); - var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album)); - + // Create the kept folder structure Directory.CreateDirectory(keptAlbumPath); // Copy file to kept folder var fileName = Path.GetFileName(downloadPath); var keptFilePath = Path.Combine(keptAlbumPath, fileName); + // Double-check in case of race condition (multiple favorite clicks) if (System.IO.File.Exists(keptFilePath)) { - _logger.LogInformation("Track already exists in kept folder: {Path}", keptFilePath); + _logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath); return; } diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 1750887..8cce911 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -507,6 +507,12 @@ builder.Services.Configure(options options.SessionCookie = sessionCookie; } + var sessionCookieSetDate = builder.Configuration.GetValue("SpotifyApi:SessionCookieSetDate"); + if (!string.IsNullOrEmpty(sessionCookieSetDate)) + { + options.SessionCookieSetDate = sessionCookieSetDate; + } + var cacheDuration = builder.Configuration.GetValue("SpotifyApi:CacheDurationMinutes"); if (cacheDuration.HasValue) { @@ -524,6 +530,7 @@ builder.Services.Configure(options Console.WriteLine($" Enabled: {options.Enabled}"); Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}"); Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}"); + Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}"); Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}"); Console.WriteLine($" PreferIsrcMatching: {options.PreferIsrcMatching}"); }); diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index a479a3f..fb51fec 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -493,8 +493,8 @@
Dashboard
-
Jellyfin Playlists
-
Configured Playlists
+
Link Playlists
+
Active Playlists
Configuration
@@ -554,18 +554,18 @@ - +

- Jellyfin Playlists + Link Jellyfin Playlists to Spotify

- Link Jellyfin playlists to Spotify playlists to fill in missing tracks. - Select a user and/or library to filter playlists. + Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz). +
Tip: Use the sp_dc cookie method for best results - it's simpler and more reliable.

@@ -599,16 +599,18 @@
- +

- Configured Playlists + Active Spotify Playlists
-

+

+ These are the Spotify playlists currently being monitored and filled with tracks from your music service. +

@@ -812,8 +814,7 @@