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:
2026-02-03 16:50:13 -05:00
parent 79a9e4063d
commit 5606706dc8
7 changed files with 202 additions and 99 deletions

View File

@@ -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

View File

@@ -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!

View File

@@ -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())
{
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;
}
}
}
// Fallback to checking Path property
if (!isLocal && hasPath)
{
isLocal = true;
}
if (isLocal)
var pathStr = pathProp.GetString()!;
// Check if it's a real file path (not a URL)
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
{
localTracks++;
}
else
{
// It's a URL or external source
externalTracks++;
// For now, if it's in the playlist but not local, it means a provider found it
externalAvailable++;
}
}
else
{
// No path means it's external
externalTracks++;
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);
}
}

View File

@@ -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;
}

View File

@@ -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}");
});

View File

@@ -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
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`, {

View File

@@ -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