mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Fix web UI config persistence and cookie age tracking
- Add SPOTIFY_API_SESSION_COOKIE_SET_DATE to docker-compose.yml env mapping - Mount .env file in container for web UI to update - Add SessionCookieSetDate loading in Program.cs - Improve .env update logic with better error handling and logging - Auto-initialize cookie date when cookie exists but date not set - Simplify local vs external track detection in Jellyfin playlists - Enhanced Spotify playlist ID parsing (supports ID, URI, and URL formats) - Better UI clarity: renamed tabs to 'Link Playlists' and 'Active Playlists'
This commit is contained in:
36
.env.example
36
.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
|
||||
|
||||
40
README.md
40
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!
|
||||
|
||||
@@ -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<AdminController> logger,
|
||||
IConfiguration configuration,
|
||||
IWebHostEnvironment environment,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||
IOptions<JellyfinSettings> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<string, string>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -507,6 +507,12 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
|
||||
options.SessionCookie = sessionCookie;
|
||||
}
|
||||
|
||||
var sessionCookieSetDate = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookieSetDate");
|
||||
if (!string.IsNullOrEmpty(sessionCookieSetDate))
|
||||
{
|
||||
options.SessionCookieSetDate = sessionCookieSetDate;
|
||||
}
|
||||
|
||||
var cacheDuration = builder.Configuration.GetValue<int?>("SpotifyApi:CacheDurationMinutes");
|
||||
if (cacheDuration.HasValue)
|
||||
{
|
||||
@@ -524,6 +530,7 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(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}");
|
||||
});
|
||||
|
||||
@@ -493,8 +493,8 @@
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||
<div class="tab" data-tab="jellyfin-playlists">Jellyfin Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Configured Playlists</div>
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Active Playlists</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
</div>
|
||||
|
||||
@@ -554,18 +554,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jellyfin Playlists Tab -->
|
||||
<!-- Link Playlists Tab -->
|
||||
<div class="tab-content" id="tab-jellyfin-playlists">
|
||||
<div class="card">
|
||||
<h2>
|
||||
Jellyfin Playlists
|
||||
Link Jellyfin Playlists to Spotify
|
||||
<div class="actions">
|
||||
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
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).
|
||||
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more reliable.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
|
||||
@@ -599,16 +599,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlists Tab -->
|
||||
<!-- Active Playlists Tab -->
|
||||
<div class="tab-content" id="tab-playlists">
|
||||
<div class="card">
|
||||
<h2>
|
||||
Configured Playlists
|
||||
Active Spotify Playlists
|
||||
<div class="actions">
|
||||
<button onclick="refreshPlaylists()">Refresh All</button>
|
||||
<button class="primary" onclick="openAddPlaylist()">Add Playlist</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
These are the Spotify playlists currently being monitored and filled with tracks from your music service.
|
||||
</p>
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -812,8 +814,7 @@
|
||||
<div class="modal-content">
|
||||
<h3>Link to Spotify Playlist</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Enter the Spotify playlist ID to link with this Jellyfin playlist.
|
||||
Allstarr will fill in missing tracks from Spotify.
|
||||
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>
|
||||
@@ -821,9 +822,11 @@
|
||||
<input type="hidden" id="link-jellyfin-id">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Spotify Playlist ID</label>
|
||||
<input type="text" id="link-spotify-id" placeholder="e.g., 37i9dQZF1DX0XUsuxWHRQd">
|
||||
<small style="color: var(--text-secondary);">Get this from Spotify URL or Spotify Import plugin</small>
|
||||
<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>
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
||||
@@ -884,9 +887,9 @@
|
||||
function formatCookieAge(setDateStr, hasCookie = false) {
|
||||
if (!setDateStr) {
|
||||
if (hasCookie) {
|
||||
return { text: 'Unknown age', class: 'warning', detail: 'Initializing tracking...', needsInit: true };
|
||||
return { text: 'Unknown age', class: 'warning', detail: 'Cookie date not tracked', needsInit: true };
|
||||
}
|
||||
return { text: 'No cookie', class: '', detail: '' };
|
||||
return { text: 'No cookie', class: '', detail: '', needsInit: false };
|
||||
}
|
||||
|
||||
const setDate = new Date(setDateStr);
|
||||
@@ -913,7 +916,7 @@
|
||||
else if (remaining > 0) detail = 'Cookie may expire soon!';
|
||||
else detail = 'Cookie may have expired - update if having issues';
|
||||
|
||||
return { text, class: status, detail };
|
||||
return { text, class: status, detail, needsInit: false };
|
||||
}
|
||||
|
||||
// Initialize cookie date if cookie exists but date is not set
|
||||
@@ -921,9 +924,15 @@
|
||||
try {
|
||||
const res = await fetch('/api/admin/config/init-cookie-date', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
console.log('Cookie date initialized successfully');
|
||||
// Refresh status after initialization
|
||||
fetchStatus();
|
||||
fetchConfig();
|
||||
setTimeout(() => {
|
||||
fetchStatus();
|
||||
fetchConfig();
|
||||
}, 500);
|
||||
} else {
|
||||
const data = await res.json();
|
||||
console.log('Cookie date init response:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to init cookie date:', error);
|
||||
@@ -974,6 +983,7 @@
|
||||
|
||||
// Auto-init cookie date if cookie exists but date is not set
|
||||
if (age.needsInit) {
|
||||
console.log('Cookie exists but date not set, initializing...');
|
||||
initCookieDate();
|
||||
}
|
||||
}
|
||||
@@ -1145,12 +1155,23 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract ID from URL if pasted
|
||||
// Extract ID from various Spotify formats:
|
||||
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
||||
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
|
||||
// - 37i9dQZF1DXcBWIGoYBM5M
|
||||
let cleanSpotifyId = spotifyId;
|
||||
if (spotifyId.includes('spotify.com/playlist/')) {
|
||||
|
||||
// Handle spotify: URI format
|
||||
if (spotifyId.startsWith('spotify:playlist:')) {
|
||||
cleanSpotifyId = spotifyId.replace('spotify:playlist:', '');
|
||||
}
|
||||
// Handle URL format
|
||||
else if (spotifyId.includes('spotify.com/playlist/')) {
|
||||
const match = spotifyId.match(/playlist\/([a-zA-Z0-9]+)/);
|
||||
if (match) cleanSpotifyId = match[1];
|
||||
}
|
||||
// Remove any query parameters or trailing slashes
|
||||
cleanSpotifyId = cleanSpotifyId.split('?')[0].split('#')[0].replace(/\/$/, '');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
||||
|
||||
@@ -94,6 +94,7 @@ services:
|
||||
- SpotifyApi__ClientId=${SPOTIFY_API_CLIENT_ID:-}
|
||||
- SpotifyApi__ClientSecret=${SPOTIFY_API_CLIENT_SECRET:-}
|
||||
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
|
||||
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
|
||||
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
||||
- SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100}
|
||||
- SpotifyApi__PreferIsrcMatching=${SPOTIFY_API_PREFER_ISRC_MATCHING:-true}
|
||||
@@ -111,6 +112,8 @@ services:
|
||||
- ${DOWNLOAD_PATH:-./downloads}:/app/downloads
|
||||
- ${KEPT_PATH:-./kept}:/app/kept
|
||||
- ${CACHE_PATH:-./cache}:/app/cache
|
||||
# Mount .env file for runtime configuration updates from admin UI
|
||||
- ./.env:/app/.env
|
||||
# Docker socket for self-restart capability (admin UI only)
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
|
||||
Reference in New Issue
Block a user