diff --git a/.env.example b/.env.example index ae8d0e0..8b27751 100644 --- a/.env.example +++ b/.env.example @@ -100,12 +100,10 @@ JELLYFIN_LIBRARY_ID= # Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF) MUSIC_SERVICE=SquidWTF -# Base directory for all downloads (default: ./downloads) -# This creates three subdirectories: -# - 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 +# Base directory for permanently downloaded tracks (default: ./downloads) +# Note: Temporarily cached tracks are stored in {DOWNLOAD_PATH}/cache. Favorited +# tracks are stored separately in KEPT_PATH (default: ./kept) +DOWNLOAD_PATH=./downloads # ===== SQUIDWTF CONFIGURATION ===== # Preferred audio quality (optional, default: LOSSLESS) diff --git a/Dockerfile b/Dockerfile index 5afefe9..a0077fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ COPY allstarr/ allstarr/ COPY allstarr.Tests/ allstarr.Tests/ RUN dotnet publish allstarr/allstarr.csproj -c Release -o /app/publish +COPY .env.example /app/publish/ # Runtime stage FROM mcr.microsoft.com/dotnet/aspnet:10.0 diff --git a/allstarr.Tests/JellyfinSessionManagerTests.cs b/allstarr.Tests/JellyfinSessionManagerTests.cs index 1e4599b..0f4a9b3 100644 --- a/allstarr.Tests/JellyfinSessionManagerTests.cs +++ b/allstarr.Tests/JellyfinSessionManagerTests.cs @@ -91,6 +91,47 @@ public class JellyfinSessionManagerTests Assert.DoesNotContain("/Sessions/Logout", requestedPaths); } + [Fact] + public async Task GetActivePlaybackStates_ReturnsTrackedPlayingItems() + { + var handler = new DelegateHttpMessageHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent))); + + var settings = new JellyfinSettings + { + Url = "http://127.0.0.1:1", + ApiKey = "server-api-key", + ClientName = "Allstarr", + DeviceName = "Allstarr", + DeviceId = "allstarr", + ClientVersion = "1.0" + }; + + var proxyService = CreateProxyService(handler, settings); + using var manager = new JellyfinSessionManager( + proxyService, + Options.Create(settings), + NullLogger.Instance); + + var headers = new HeaderDictionary + { + ["X-Emby-Authorization"] = + "MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\"" + }; + + var ensured = await manager.EnsureSessionAsync("dev-123", "Feishin", "Desktop", "1.0", headers); + Assert.True(ensured); + + manager.UpdatePlayingItem("dev-123", "ext-squidwtf-song-35734823", 45 * TimeSpan.TicksPerSecond); + + var states = manager.GetActivePlaybackStates(TimeSpan.FromMinutes(1)); + + var state = Assert.Single(states); + Assert.Equal("dev-123", state.DeviceId); + Assert.Equal("ext-squidwtf-song-35734823", state.ItemId); + Assert.Equal(45 * TimeSpan.TicksPerSecond, state.PositionTicks); + } + private static JellyfinProxyService CreateProxyService(HttpMessageHandler handler, JellyfinSettings settings) { var httpClientFactory = new TestHttpClientFactory(handler); diff --git a/allstarr/Controllers/ConfigController.cs b/allstarr/Controllers/ConfigController.cs index 494b04a..43dcf5c 100644 --- a/allstarr/Controllers/ConfigController.cs +++ b/allstarr/Controllers/ConfigController.cs @@ -474,70 +474,101 @@ public class ConfigController : ControllerBase _logger.LogWarning(".env file not found at {Path}, creating new file", _helperService.GetEnvFilePath()); } - // Read current .env file or create new one - var envContent = new Dictionary(); + var envFilePath = _helperService.GetEnvFilePath(); + var envLines = new List(); - if (System.IO.File.Exists(_helperService.GetEnvFilePath())) + if (System.IO.File.Exists(envFilePath)) { - var lines = await System.IO.File.ReadAllLinesAsync(_helperService.GetEnvFilePath()); - foreach (var line in lines) + envLines = (await System.IO.File.ReadAllLinesAsync(envFilePath)).ToList(); + } + else + { + // Fallback to reading .env.example if .env doesn't exist to preserve structure + var examplePath = Path.Combine(Directory.GetCurrentDirectory(), ".env.example"); + if (!System.IO.File.Exists(examplePath)) { - if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) - continue; - - var eqIndex = line.IndexOf('='); - if (eqIndex > 0) - { - var key = line[..eqIndex].Trim(); - var value = line[(eqIndex + 1)..].Trim(); - - // Remove surrounding quotes if present (for proper re-quoting) - if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2) - { - value = value[1..^1]; - } - - envContent[key] = value; - } + examplePath = Path.Combine(Directory.GetParent(Directory.GetCurrentDirectory())?.FullName ?? "", ".env.example"); + } + + if (System.IO.File.Exists(examplePath)) + { + _logger.LogInformation("Creating new .env from .env.example to preserve formatting"); + envLines = (await System.IO.File.ReadAllLinesAsync(examplePath)).ToList(); } - _logger.LogDebug("Loaded {Count} existing env vars from {Path}", envContent.Count, _helperService.GetEnvFilePath()); } // Apply updates with validation var appliedUpdates = new List(); - foreach (var (key, value) in request.Updates) + var updatesToProcess = new Dictionary(request.Updates); + + // Auto-set cookie date when Spotify session cookie is updated + if (updatesToProcess.TryGetValue("SPOTIFY_API_SESSION_COOKIE", out var cookieVal) && !string.IsNullOrEmpty(cookieVal)) + { + updatesToProcess["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = DateTime.UtcNow.ToString("o"); + _logger.LogInformation("Auto-setting SPOTIFY_API_SESSION_COOKIE_SET_DATE"); + } + + foreach (var (key, value) in updatesToProcess) { - // Validate key format if (!AdminHelperService.IsValidEnvKey(key)) { _logger.LogWarning("Invalid env key rejected: {Key}", key); return BadRequest(new { error = $"Invalid environment variable key: {key}" }); } - // IMPORTANT: Docker Compose does NOT need quotes in .env files - // It handles special characters correctly without them - // When quotes are used, they become part of the value itself - envContent[key] = value; appliedUpdates.Add(key); - _logger.LogInformation(" Setting {Key} = {Value}", key, - key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") || key.Contains("PASSWORD") - ? "***" + (value.Length > 8 ? value[^8..] : "") - : value); + + var maskedValue = key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") || key.Contains("PASSWORD") + ? "***" + (value.Length > 8 ? value[^8..] : "") + : value; + _logger.LogInformation(" Setting {Key} = {Value}", key, maskedValue); - // Auto-set cookie date when Spotify session cookie is updated - if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value)) + var keyPrefix = $"{key}="; + var found = false; + + // 1. Look for active exact key + for (int i = 0; i < envLines.Count; i++) { - var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE"; - var dateValue = DateTime.UtcNow.ToString("o"); // ISO 8601 format - envContent[dateKey] = dateValue; - appliedUpdates.Add(dateKey); - _logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue); + var trimmedLine = envLines[i].TrimStart(); + if (trimmedLine.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase)) + { + envLines[i] = $"{key}={value}"; + found = true; + break; + } + } + + // 2. Look for commented out key + if (!found) + { + var commentedPrefix1 = $"# {key}="; + var commentedPrefix2 = $"#{key}="; + + for (int i = 0; i < envLines.Count; i++) + { + var trimmedLine = envLines[i].TrimStart(); + if (trimmedLine.StartsWith(commentedPrefix1, StringComparison.OrdinalIgnoreCase) || + trimmedLine.StartsWith(commentedPrefix2, StringComparison.OrdinalIgnoreCase)) + { + envLines[i] = $"{key}={value}"; + found = true; + break; + } + } + } + + // 3. Append to end of file if entirely missing + if (!found) + { + if (envLines.Count > 0 && !string.IsNullOrWhiteSpace(envLines.Last())) + { + envLines.Add(""); + } + envLines.Add($"{key}={value}"); } } - // Write back to .env file (no quoting needed - Docker Compose handles special chars) - var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}")); - await System.IO.File.WriteAllTextAsync(_helperService.GetEnvFilePath(), newContent + "\n"); + await System.IO.File.WriteAllLinesAsync(envFilePath, envLines); _logger.LogDebug("Config file updated successfully at {Path}", _helperService.GetEnvFilePath()); diff --git a/allstarr/Controllers/DownloadActivityController.cs b/allstarr/Controllers/DownloadActivityController.cs index c6df7d0..e1cee78 100644 --- a/allstarr/Controllers/DownloadActivityController.cs +++ b/allstarr/Controllers/DownloadActivityController.cs @@ -1,6 +1,7 @@ using System.Text.Json; using allstarr.Models.Download; using allstarr.Services; +using allstarr.Services.Jellyfin; using Microsoft.AspNetCore.Mvc; namespace allstarr.Controllers; @@ -10,13 +11,16 @@ namespace allstarr.Controllers; public class DownloadActivityController : ControllerBase { private readonly IEnumerable _downloadServices; + private readonly JellyfinSessionManager _sessionManager; private readonly ILogger _logger; public DownloadActivityController( IEnumerable downloadServices, + JellyfinSessionManager sessionManager, ILogger logger) { _downloadServices = downloadServices; + _sessionManager = sessionManager; _logger = logger; } @@ -26,7 +30,7 @@ public class DownloadActivityController : ControllerBase [HttpGet("queue")] public IActionResult GetDownloadQueue() { - var allDownloads = GetAllActiveDownloads(); + var allDownloads = GetAllActivityEntries(); return Ok(allDownloads); } @@ -58,7 +62,7 @@ public class DownloadActivityController : ControllerBase { while (!token.IsCancellationRequested) { - var allDownloads = GetAllActiveDownloads(); + var allDownloads = GetAllActivityEntries(); var payload = JsonSerializer.Serialize(allDownloads, jsonOptions); var message = $"data: {payload}\n\n"; @@ -83,7 +87,7 @@ public class DownloadActivityController : ControllerBase } } - private List GetAllActiveDownloads() + private List GetAllActivityEntries() { var allDownloads = new List(); foreach (var service in _downloadServices) @@ -91,10 +95,87 @@ public class DownloadActivityController : ControllerBase allDownloads.AddRange(service.GetActiveDownloads()); } - // Sort: InProgress first, then by StartedAt descending - return allDownloads + var orderedDownloads = allDownloads .OrderByDescending(d => d.Status == DownloadStatus.InProgress) .ThenByDescending(d => d.StartedAt) .ToList(); + + var playbackByItemId = _sessionManager + .GetActivePlaybackStates(TimeSpan.FromMinutes(5)) + .GroupBy(state => NormalizeExternalItemId(state.ItemId)) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(state => state.LastActivity).First()); + + return orderedDownloads + .Select(download => + { + var normalizedSongId = NormalizeExternalItemId(download.SongId); + var hasPlayback = playbackByItemId.TryGetValue(normalizedSongId, out var playbackState); + var playbackProgress = hasPlayback && download.DurationSeconds.GetValueOrDefault() > 0 + ? Math.Clamp( + playbackState!.PositionTicks / (double)TimeSpan.TicksPerSecond / download.DurationSeconds!.Value, + 0d, + 1d) + : (double?)null; + + return new DownloadActivityEntry + { + SongId = download.SongId, + ExternalId = download.ExternalId, + ExternalProvider = download.ExternalProvider, + Title = download.Title, + Artist = download.Artist, + Status = download.Status, + Progress = download.Progress, + RequestedForStreaming = download.RequestedForStreaming, + DurationSeconds = download.DurationSeconds, + LocalPath = download.LocalPath, + ErrorMessage = download.ErrorMessage, + StartedAt = download.StartedAt, + CompletedAt = download.CompletedAt, + IsPlaying = hasPlayback, + PlaybackPositionSeconds = hasPlayback + ? (int)Math.Max(0, playbackState!.PositionTicks / TimeSpan.TicksPerSecond) + : null, + PlaybackProgress = playbackProgress + }; + }) + .ToList(); + } + + private static string NormalizeExternalItemId(string itemId) + { + if (string.IsNullOrWhiteSpace(itemId) || !itemId.StartsWith("ext-", StringComparison.OrdinalIgnoreCase)) + { + return itemId; + } + + var parts = itemId.Split('-', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 3) + { + return itemId; + } + + var knownTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "song", + "album", + "artist" + }; + + if (parts.Length >= 4 && knownTypes.Contains(parts[2])) + { + return itemId; + } + + return $"ext-{parts[1]}-song-{string.Join("-", parts.Skip(2))}"; + } + + private sealed class DownloadActivityEntry : DownloadInfo + { + public bool IsPlaying { get; init; } + public int? PlaybackPositionSeconds { get; init; } + public double? PlaybackProgress { get; init; } } } diff --git a/allstarr/Models/Download/DownloadInfo.cs b/allstarr/Models/Download/DownloadInfo.cs index 74c991f..b0a2c26 100644 --- a/allstarr/Models/Download/DownloadInfo.cs +++ b/allstarr/Models/Download/DownloadInfo.cs @@ -12,6 +12,8 @@ public class DownloadInfo public string Artist { get; set; } = string.Empty; public DownloadStatus Status { get; set; } public double Progress { get; set; } // 0.0 to 1.0 + public bool RequestedForStreaming { get; set; } + public int? DurationSeconds { get; set; } public string? LocalPath { get; set; } public string? ErrorMessage { get; set; } public DateTime StartedAt { get; set; } diff --git a/allstarr/Services/Common/BaseDownloadService.cs b/allstarr/Services/Common/BaseDownloadService.cs index cf1ac7b..c0663ac 100644 --- a/allstarr/Services/Common/BaseDownloadService.cs +++ b/allstarr/Services/Common/BaseDownloadService.cs @@ -39,6 +39,30 @@ public abstract class BaseDownloadService : IDownloadService private DateTime _lastRequestTime = DateTime.MinValue; protected int _minRequestIntervalMs = 200; + protected StorageMode CurrentStorageMode + { + get + { + var backendType = Configuration["Backend:Type"] ?? "Subsonic"; + var modeStr = backendType.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase) + ? Configuration["Jellyfin:StorageMode"] ?? Configuration["Subsonic:StorageMode"] ?? "Permanent" + : Configuration["Subsonic:StorageMode"] ?? "Permanent"; + return Enum.TryParse(modeStr, true, out var result) ? result : StorageMode.Permanent; + } + } + + protected DownloadMode CurrentDownloadMode + { + get + { + var backendType = Configuration["Backend:Type"] ?? "Subsonic"; + var modeStr = backendType.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase) + ? Configuration["Jellyfin:DownloadMode"] ?? Configuration["Subsonic:DownloadMode"] ?? "Track" + : Configuration["Subsonic:DownloadMode"] ?? "Track"; + return Enum.TryParse(modeStr, true, out var result) ? result : DownloadMode.Track; + } + } + /// /// Lazy-loaded PlaylistSyncService to avoid circular dependency /// @@ -105,7 +129,12 @@ public abstract class BaseDownloadService : IDownloadService /// public async Task DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { - return await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, cancellationToken); + return await DownloadSongInternalAsync( + externalProvider, + externalId, + triggerAlbumDownload: true, + requestedForStreaming: false, + cancellationToken); } @@ -129,7 +158,7 @@ public abstract class BaseDownloadService : IDownloadService Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath); // Update write time for cache cleanup (extends cache lifetime) - if (SubsonicSettings.StorageMode == StorageMode.Cache) + if (CurrentStorageMode == StorageMode.Cache) { IOFile.SetLastWriteTime(localPath, DateTime.UtcNow); } @@ -152,7 +181,12 @@ public abstract class BaseDownloadService : IDownloadService // IMPORTANT: Use CancellationToken.None for the actual download // This ensures downloads complete server-side even if the client cancels the request // The client can request the file again later once it's ready - localPath = await DownloadSongInternalAsync(externalProvider, externalId, triggerAlbumDownload: true, CancellationToken.None); + localPath = await DownloadSongInternalAsync( + externalProvider, + externalId, + triggerAlbumDownload: true, + requestedForStreaming: true, + CancellationToken.None); var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds; Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath); @@ -295,6 +329,24 @@ public abstract class BaseDownloadService : IDownloadService } public abstract Task IsAvailableAsync(); + + protected string BuildTrackedSongId(string externalId) + { + return BuildTrackedSongId(ProviderName, externalId); + } + + protected static string BuildTrackedSongId(string externalProvider, string externalId) + { + return $"ext-{externalProvider}-song-{externalId}"; + } + + protected void SetDownloadProgress(string songId, double progress) + { + if (ActiveDownloads.TryGetValue(songId, out var info)) + { + info.Progress = Math.Clamp(progress, 0d, 1d); + } + } public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId) { @@ -371,15 +423,20 @@ public abstract class BaseDownloadService : IDownloadService /// /// Internal method for downloading a song with control over album download triggering /// - protected async Task DownloadSongInternalAsync(string externalProvider, string externalId, bool triggerAlbumDownload, CancellationToken cancellationToken = default) + protected async Task DownloadSongInternalAsync( + string externalProvider, + string externalId, + bool triggerAlbumDownload, + bool requestedForStreaming = false, + CancellationToken cancellationToken = default) { if (externalProvider != ProviderName) { throw new NotSupportedException($"Provider '{externalProvider}' is not supported"); } - var songId = $"ext-{externalProvider}-{externalId}"; - var isCache = SubsonicSettings.StorageMode == StorageMode.Cache; + var songId = BuildTrackedSongId(externalProvider, externalId); + var isCache = CurrentStorageMode == StorageMode.Cache; bool isInitiator = false; @@ -405,6 +462,11 @@ public abstract class BaseDownloadService : IDownloadService // Check if download in progress if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress) { + if (requestedForStreaming) + { + activeDownload.RequestedForStreaming = true; + } + Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId); // We are not the initiator; we will wait outside the lock. } @@ -420,6 +482,8 @@ public abstract class BaseDownloadService : IDownloadService Title = "Unknown Title", // Will be updated after fetching Artist = "Unknown Artist", Status = DownloadStatus.InProgress, + Progress = 0, + RequestedForStreaming = requestedForStreaming, StartedAt = DateTime.UtcNow }; } @@ -464,7 +528,7 @@ public abstract class BaseDownloadService : IDownloadService // In Album mode, fetch the full album first to ensure AlbumArtist is correctly set Song? song = null; - if (SubsonicSettings.DownloadMode == DownloadMode.Album) + if (CurrentDownloadMode == DownloadMode.Album) { // First try to get the song to extract album ID var tempSong = await MetadataService.GetSongAsync(externalProvider, externalId); @@ -500,6 +564,7 @@ public abstract class BaseDownloadService : IDownloadService { info.Title = song.Title ?? "Unknown Title"; info.Artist = song.Artist ?? "Unknown Artist"; + info.DurationSeconds = song.Duration; } var localPath = await DownloadTrackAsync(externalId, song, cancellationToken); @@ -507,6 +572,7 @@ public abstract class BaseDownloadService : IDownloadService if (ActiveDownloads.TryGetValue(songId, out var successInfo)) { successInfo.Status = DownloadStatus.Completed; + successInfo.Progress = 1.0; successInfo.LocalPath = localPath; successInfo.CompletedAt = DateTime.UtcNow; } @@ -559,7 +625,7 @@ public abstract class BaseDownloadService : IDownloadService }); // If download mode is Album and triggering is enabled, start background download of remaining tracks - if (triggerAlbumDownload && SubsonicSettings.DownloadMode == DownloadMode.Album && !string.IsNullOrEmpty(song.AlbumId)) + if (triggerAlbumDownload && CurrentDownloadMode == DownloadMode.Album && !string.IsNullOrEmpty(song.AlbumId)) { var albumExternalId = ExtractExternalIdFromAlbumId(song.AlbumId); if (!string.IsNullOrEmpty(albumExternalId)) @@ -642,7 +708,7 @@ public abstract class BaseDownloadService : IDownloadService } // Check if download is already in progress or recently completed - var songId = $"ext-{ProviderName}-{track.ExternalId}"; + var songId = BuildTrackedSongId(track.ExternalId!); if (ActiveDownloads.TryGetValue(songId, out var activeDownload)) { if (activeDownload.Status == DownloadStatus.InProgress) @@ -659,7 +725,12 @@ public abstract class BaseDownloadService : IDownloadService } Logger.LogInformation("Downloading track '{Title}' from album '{Album}'", track.Title, album.Title); - await DownloadSongInternalAsync(ProviderName, track.ExternalId!, triggerAlbumDownload: false, CancellationToken.None); + await DownloadSongInternalAsync( + ProviderName, + track.ExternalId!, + triggerAlbumDownload: false, + requestedForStreaming: false, + CancellationToken.None); } catch (Exception ex) { diff --git a/allstarr/Services/Common/EnvMigrationService.cs b/allstarr/Services/Common/EnvMigrationService.cs index 7596f28..62c8071 100644 --- a/allstarr/Services/Common/EnvMigrationService.cs +++ b/allstarr/Services/Common/EnvMigrationService.cs @@ -35,13 +35,13 @@ public class EnvMigrationService if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#")) continue; - // Migrate DOWNLOAD_PATH to Library__DownloadPath - if (line.StartsWith("DOWNLOAD_PATH=")) + // Migrate Library__DownloadPath to DOWNLOAD_PATH (inverse migration) + if (line.StartsWith("Library__DownloadPath=")) { - var value = line.Substring("DOWNLOAD_PATH=".Length); - lines[i] = $"Library__DownloadPath={value}"; + var value = line.Substring("Library__DownloadPath=".Length); + lines[i] = $"DOWNLOAD_PATH={value}"; modified = true; - _logger.LogDebug("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file"); + _logger.LogInformation("Migrated Library__DownloadPath to DOWNLOAD_PATH in .env file"); } // Migrate old SquidWTF quality values to new format @@ -104,10 +104,107 @@ public class EnvMigrationService File.WriteAllLines(_envFilePath, lines); _logger.LogInformation("✅ .env file migration completed successfully"); } + + ReformatEnvFileIfSquashed(); } catch (Exception ex) { _logger.LogError(ex, "Failed to migrate .env file"); } } + + private void ReformatEnvFileIfSquashed() + { + try + { + if (!File.Exists(_envFilePath)) return; + + var currentLines = File.ReadAllLines(_envFilePath); + var commentCount = currentLines.Count(l => l.TrimStart().StartsWith("#")); + + // If the file has fewer than 5 comments, it's likely a flattened/squashed file + // from an older version or raw docker output. Let's rehydrate it. + if (commentCount < 5) + { + var examplePath = Path.Combine(Directory.GetCurrentDirectory(), ".env.example"); + if (!File.Exists(examplePath)) + { + examplePath = Path.Combine(Directory.GetParent(Directory.GetCurrentDirectory())?.FullName ?? "", ".env.example"); + } + + if (!File.Exists(examplePath)) return; + + _logger.LogInformation("Flattened/raw .env file detected (only {Count} comments). Rehydrating formatting from .env.example...", commentCount); + + var currentValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var line in currentLines) + { + var trimmed = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith("#")) continue; + + var eqIndex = trimmed.IndexOf('='); + if (eqIndex > 0) + { + var key = trimmed[..eqIndex].Trim(); + var value = trimmed[(eqIndex + 1)..].Trim(); + currentValues[key] = value; + } + } + + var exampleLines = File.ReadAllLines(examplePath).ToList(); + var usedKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + for (int i = 0; i < exampleLines.Count; i++) + { + var line = exampleLines[i].TrimStart(); + if (string.IsNullOrWhiteSpace(line)) continue; + + if (!line.StartsWith("#")) + { + var eqIndex = line.IndexOf('='); + if (eqIndex > 0) + { + var key = line[..eqIndex].Trim(); + if (currentValues.TryGetValue(key, out var val)) + { + exampleLines[i] = $"{key}={val}"; + usedKeys.Add(key); + } + } + } + else + { + var eqIndex = line.IndexOf('='); + if (eqIndex > 0) + { + var keyPart = line[..eqIndex].TrimStart('#').Trim(); + if (!keyPart.Contains(" ") && keyPart.Length > 0 && currentValues.TryGetValue(keyPart, out var val)) + { + exampleLines[i] = $"{keyPart}={val}"; + usedKeys.Add(keyPart); + } + } + } + } + + var leftoverKeys = currentValues.Keys.Except(usedKeys).ToList(); + if (leftoverKeys.Any()) + { + exampleLines.Add(""); + exampleLines.Add("# ===== CUSTOM / UNKNOWN VARIABLES ====="); + foreach (var key in leftoverKeys) + { + exampleLines.Add($"{key}={currentValues[key]}"); + } + } + + File.WriteAllLines(_envFilePath, exampleLines); + _logger.LogInformation("✅ .env file successfully rehydrated with comments and formatting"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to rehydrate .env file formatting"); + } + } } diff --git a/allstarr/Services/Deezer/DeezerDownloadService.cs b/allstarr/Services/Deezer/DeezerDownloadService.cs index 526bd2b..531da45 100644 --- a/allstarr/Services/Deezer/DeezerDownloadService.cs +++ b/allstarr/Services/Deezer/DeezerDownloadService.cs @@ -99,10 +99,9 @@ public class DeezerDownloadService : BaseDownloadService // Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles) var artistForPath = song.AlbumArtist ?? song.Artist; - // Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/ - var basePath = SubsonicSettings.StorageMode == StorageMode.Cache - ? Path.Combine("downloads", "cache") - : Path.Combine("downloads", "permanent"); + var basePath = CurrentStorageMode == StorageMode.Cache + ? Path.Combine(DownloadPath, "cache") + : Path.Combine(DownloadPath, "permanent"); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "deezer", trackId); // Create directories if they don't exist diff --git a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs index aaddb2e..c5ba874 100644 --- a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs +++ b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs @@ -296,6 +296,25 @@ public class JellyfinSessionManager : IDisposable return (null, null); } + /// + /// Returns current active playback states for tracked sessions. + /// + public IReadOnlyList GetActivePlaybackStates(TimeSpan maxAge) + { + var cutoff = DateTime.UtcNow - maxAge; + + return _sessions.Values + .Where(session => + !string.IsNullOrWhiteSpace(session.LastPlayingItemId) && + session.LastActivity >= cutoff) + .Select(session => new ActivePlaybackState( + session.DeviceId, + session.LastPlayingItemId!, + session.LastPlayingPositionTicks ?? 0, + session.LastActivity)) + .ToList(); + } + /// /// Marks a session as potentially ended (e.g., after playback stops). /// Jellyfin should decide when the upstream playback session expires. @@ -678,6 +697,12 @@ public class JellyfinSessionManager : IDisposable public DateTime? LastExplicitStopAtUtc { get; set; } } + public sealed record ActivePlaybackState( + string DeviceId, + string ItemId, + long PositionTicks, + DateTime LastActivity); + public void Dispose() { _keepAliveTimer?.Dispose(); diff --git a/allstarr/Services/Qobuz/QobuzDownloadService.cs b/allstarr/Services/Qobuz/QobuzDownloadService.cs index 040794c..1c1be76 100644 --- a/allstarr/Services/Qobuz/QobuzDownloadService.cs +++ b/allstarr/Services/Qobuz/QobuzDownloadService.cs @@ -102,8 +102,7 @@ public class QobuzDownloadService : BaseDownloadService // Build organized folder structure using AlbumArtist (fallback to Artist for singles) var artistForPath = song.AlbumArtist ?? song.Artist; - // Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/ - var basePath = SubsonicSettings.StorageMode == StorageMode.Cache + var basePath = CurrentStorageMode == StorageMode.Cache ? Path.Combine(DownloadPath, "cache") : Path.Combine(DownloadPath, "permanent"); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "qobuz", trackId); diff --git a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs index 1d36453..37ee0a3 100644 --- a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs @@ -101,6 +101,7 @@ public class SquidWTFDownloadService : BaseDownloadService { return await _fallbackHelper.TryWithFallbackAsync(async baseUrl => { + var songId = BuildTrackedSongId(trackId); var downloadInfo = await FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken); Logger.LogInformation( @@ -136,8 +137,29 @@ public class SquidWTFDownloadService : BaseDownloadService await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken); await using var outputFile = IOFile.Create(outputPath); - await responseStream.CopyToAsync(outputFile, cancellationToken); + var totalBytes = res.Content.Headers.ContentLength; + var buffer = new byte[81920]; + long totalBytesRead = 0; + + while (true) + { + var bytesRead = await responseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + if (bytesRead <= 0) + { + break; + } + + await outputFile.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + totalBytesRead += bytesRead; + + if (totalBytes.HasValue && totalBytes.Value > 0) + { + SetDownloadProgress(songId, (double)totalBytesRead / totalBytes.Value); + } + } + await outputFile.DisposeAsync(); + SetDownloadProgress(songId, 1.0); _ = Task.Run(async () => { @@ -166,8 +188,8 @@ public class SquidWTFDownloadService : BaseDownloadService { Exception? lastException = null; var qualityOrder = BuildQualityFallbackOrder(_squidwtfSettings.Quality); - var basePath = SubsonicSettings.StorageMode == StorageMode.Cache - ? Path.Combine("downloads", "cache") : Path.Combine("downloads", "permanent"); + var basePath = CurrentStorageMode == StorageMode.Cache + ? Path.Combine(DownloadPath, "cache") : Path.Combine(DownloadPath, "permanent"); foreach (var quality in qualityOrder) { diff --git a/allstarr/wwwroot/js/dashboard-data.js b/allstarr/wwwroot/js/dashboard-data.js index bd5bd23..151f4d4 100644 --- a/allstarr/wwwroot/js/dashboard-data.js +++ b/allstarr/wwwroot/js/dashboard-data.js @@ -424,6 +424,9 @@ function renderDownloadActivity(downloads) { }; const html = downloads.map(d => { + const downloadProgress = clampProgress(d.progress); + const playbackProgress = clampProgress(d.playbackProgress); + // Determine elapsed/duration text let timeText = ""; if (d.startedAt) { @@ -433,9 +436,37 @@ function renderDownloadActivity(downloads) { timeText = diffSecs < 60 ? `${diffSecs}s` : `${Math.floor(diffSecs/60)}m ${diffSecs%60}s`; } + const progressMeta = []; + if (typeof d.durationSeconds === "number" && typeof d.playbackPositionSeconds === "number") { + progressMeta.push(`${formatSeconds(d.playbackPositionSeconds)} / ${formatSeconds(d.durationSeconds)}`); + } else if (typeof d.durationSeconds === "number") { + progressMeta.push(formatSeconds(d.durationSeconds)); + } + if (d.requestedForStreaming) { + progressMeta.push("stream"); + } + + const progressMetaText = progressMeta.length > 0 + ? `
${progressMeta.map(escapeHtml).join(" • ")}
` + : ""; + + const progressBar = ` + + ${progressMetaText} + `; + const title = d.title || 'Unknown Title'; const artist = d.artist || 'Unknown Artist'; const errorText = d.errorMessage ? `
${escapeHtml(d.errorMessage)}
` : ''; + const streamBadge = d.requestedForStreaming + ? 'Stream' + : ''; + const playingBadge = d.isPlaying + ? 'Playing' + : ''; return `
@@ -444,7 +475,10 @@ function renderDownloadActivity(downloads) {
${escapeHtml(artist)} ${escapeHtml(d.externalProvider)} + ${streamBadge} + ${playingBadge}
+ ${progressBar} ${errorText}
@@ -458,6 +492,24 @@ function renderDownloadActivity(downloads) { container.innerHTML = html; } +function clampProgress(value) { + if (typeof value !== "number" || Number.isNaN(value)) { + return 0; + } + + return Math.max(0, Math.min(1, value)); +} + +function formatSeconds(totalSeconds) { + if (typeof totalSeconds !== "number" || Number.isNaN(totalSeconds) || totalSeconds < 0) { + return "0:00"; + } + + const minutes = Math.floor(totalSeconds / 60); + const seconds = Math.floor(totalSeconds % 60); + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} + export function initDashboardData(options) { isAuthenticated = options.isAuthenticated; isAdminSession = options.isAdminSession; diff --git a/allstarr/wwwroot/styles.css b/allstarr/wwwroot/styles.css index 6f27fdf..5ec4036 100644 --- a/allstarr/wwwroot/styles.css +++ b/allstarr/wwwroot/styles.css @@ -1003,6 +1003,8 @@ input::placeholder { display: flex; flex-direction: column; gap: 4px; + min-width: 0; + flex: 1; } .download-queue-title { @@ -1030,6 +1032,52 @@ input::placeholder { text-transform: uppercase; } +.download-queue-badge { + font-size: 0.75rem; + padding: 2px 6px; + background: rgba(255, 255, 255, 0.08); + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: 999px; + text-transform: uppercase; +} + +.download-queue-badge.is-playing { + color: #79c0ff; + border-color: rgba(121, 192, 255, 0.45); + background: rgba(56, 139, 253, 0.16); +} + +.download-progress-bar { + position: relative; + height: 8px; + width: 100%; + margin-top: 6px; + background: rgba(255, 255, 255, 0.06); + border-radius: 999px; + overflow: hidden; +} + +.download-progress-buffer { + position: absolute; + inset: 0 auto 0 0; + background: rgba(201, 209, 217, 0.28); + border-radius: 999px; +} + +.download-progress-playback { + position: absolute; + inset: 0 auto 0 0; + background: linear-gradient(90deg, #2f81f7 0%, #79c0ff 100%); + border-radius: 999px; +} + +.download-progress-meta { + margin-top: 4px; + color: var(--text-secondary); + font-size: 0.75rem; +} + .download-queue-status { display: flex; align-items: center; diff --git a/docker-compose-redis2valkey.yml b/docker-compose-redis2valkey.yml new file mode 100644 index 0000000..939e5b8 --- /dev/null +++ b/docker-compose-redis2valkey.yml @@ -0,0 +1,249 @@ +services: + valkey: + image: valkey/valkey:8 + container_name: allstarr-valkey + restart: unless-stopped + # Valkey is only accessible internally - no external port exposure + expose: + - "6379" + # Use a self-healing entrypoint to automatically handle Redis -> Valkey migration pitfalls (like RDB format 12 errors) + # Only delete Valkey/Redis persistence artifacts so misconfigured REDIS_DATA_PATH values do not wipe app cache files. + entrypoint: + - "sh" + - "-ec" + - | + log_file=/tmp/valkey-startup.log + log_pipe=/tmp/valkey-startup.pipe + server_pid= + tee_pid= + + forward_signal() { + if [ -n "$$server_pid" ]; then + kill -TERM "$$server_pid" 2>/dev/null || true + wait "$$server_pid" 2>/dev/null || true + fi + + if [ -n "$$tee_pid" ]; then + kill "$$tee_pid" 2>/dev/null || true + wait "$$tee_pid" 2>/dev/null || true + fi + + rm -f "$$log_pipe" + exit 143 + } + + trap forward_signal TERM INT + + start_valkey() { + rm -f "$$log_file" "$$log_pipe" + : > "$$log_file" + mkfifo "$$log_pipe" + + tee -a "$$log_file" < "$$log_pipe" & + tee_pid=$$! + + valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes > "$$log_pipe" 2>&1 & + server_pid=$$! + + wait "$$server_pid" + status=$$? + + wait "$$tee_pid" 2>/dev/null || true + rm -f "$$log_pipe" + server_pid= + tee_pid= + + return "$$status" + } + + is_incompatible_persistence_error() { + grep -Eq "Can't handle RDB format version|Error reading the RDB base file|AOF loading aborted" "$$log_file" + } + + cleanup_incompatible_persistence() { + echo 'Valkey failed to start (likely incompatible Redis persistence files). Removing persisted RDB/AOF artifacts and retrying...' + rm -f /data/*.rdb /data/*.aof /data/*.manifest + rm -rf /data/appendonlydir /data/appendonlydir-* + } + + if ! start_valkey; then + if is_incompatible_persistence_error; then + cleanup_incompatible_persistence + exec valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes + fi + + exit 1 + fi + healthcheck: + # Use CMD-SHELL for broader compatibility in some environments + test: ["CMD-SHELL", "valkey-cli ping || exit 1"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 20s + volumes: + - ${REDIS_DATA_PATH:-./redis-data}:/data + 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: + - "8365:8080" + environment: + - SP_DC=${SPOTIFY_API_SESSION_COOKIE:-} + networks: + - allstarr-network + + allstarr: + # Use pre-built image from GitHub Container Registry + # For latest stable: ghcr.io/sopat712/allstarr:latest + # For beta/testing: ghcr.io/sopat712/allstarr:beta + # To build locally instead, uncomment the build section below + image: ghcr.io/sopat712/allstarr:latest + + # Uncomment to build locally instead of using GHCR image: + # build: + # context: . + # dockerfile: Dockerfile + # image: allstarr:local + + container_name: allstarr + restart: unless-stopped + ports: + - "5274:8080" + # Admin UI on port 5275 - for local/Tailscale access only + # DO NOT expose through reverse proxy - contains sensitive config + - "5275:5275" + depends_on: + valkey: + condition: service_healthy + spotify-lyrics: + condition: service_started + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - allstarr-network + environment: + - ASPNETCORE_ENVIRONMENT=Production + # Backend type: Subsonic or Jellyfin (default: Subsonic) + - Backend__Type=${BACKEND_TYPE:-Subsonic} + # Admin network controls (port 5275) + - Admin__BindAnyIp=${ADMIN_BIND_ANY_IP:-false} + - Admin__TrustedSubnets=${ADMIN_TRUSTED_SUBNETS:-} + + # ===== REDIS / VALKEY CACHE ===== + - Redis__ConnectionString=valkey:6379 + - Redis__Enabled=${REDIS_ENABLED:-true} + + # ===== CACHE TTL SETTINGS ===== + - Cache__SearchResultsMinutes=${CACHE_SEARCH_RESULTS_MINUTES:-1} + - Cache__PlaylistImagesHours=${CACHE_PLAYLIST_IMAGES_HOURS:-168} + - Cache__SpotifyPlaylistItemsHours=${CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS:-168} + - Cache__SpotifyMatchedTracksDays=${CACHE_SPOTIFY_MATCHED_TRACKS_DAYS:-30} + - Cache__LyricsDays=${CACHE_LYRICS_DAYS:-14} + - Cache__GenreDays=${CACHE_GENRE_DAYS:-30} + - Cache__MetadataDays=${CACHE_METADATA_DAYS:-7} + - Cache__OdesliLookupDays=${CACHE_ODESLI_LOOKUP_DAYS:-60} + - Cache__ProxyImagesDays=${CACHE_PROXY_IMAGES_DAYS:-14} + - Cache__TranscodeCacheMinutes=${CACHE_TRANSCODE_MINUTES:-60} + + # ===== SUBSONIC BACKEND ===== + - Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533} + - Subsonic__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly} + - Subsonic__DownloadMode=${DOWNLOAD_MODE:-Track} + - Subsonic__MusicService=${MUSIC_SERVICE:-SquidWTF} + - Subsonic__StorageMode=${STORAGE_MODE:-Permanent} + - Subsonic__CacheDurationHours=${CACHE_DURATION_HOURS:-1} + - Subsonic__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true} + - Subsonic__PlaylistsDirectory=${PLAYLISTS_DIRECTORY:-playlists} + + # ===== JELLYFIN BACKEND ===== + - Jellyfin__Url=${JELLYFIN_URL:-http://localhost:8096} + - Jellyfin__ApiKey=${JELLYFIN_API_KEY:-} + - Jellyfin__UserId=${JELLYFIN_USER_ID:-} + - Jellyfin__LibraryId=${JELLYFIN_LIBRARY_ID:-} + - Jellyfin__ClientUsername=${JELLYFIN_CLIENT_USERNAME:-} + - Jellyfin__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly} + - Jellyfin__DownloadMode=${DOWNLOAD_MODE:-Track} + - Jellyfin__MusicService=${MUSIC_SERVICE:-SquidWTF} + - Jellyfin__StorageMode=${STORAGE_MODE:-Permanent} + - Jellyfin__CacheDurationHours=${CACHE_DURATION_HOURS:-1} + - Jellyfin__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true} + - Jellyfin__PlaylistsDirectory=${PLAYLISTS_DIRECTORY:-playlists} + + # ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) ===== + - SpotifyImport__Enabled=${SPOTIFY_IMPORT_ENABLED:-false} + - SpotifyImport__SyncStartHour=${SPOTIFY_IMPORT_SYNC_START_HOUR:-16} + - SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15} + - SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2} + - SpotifyImport__MatchingIntervalHours=${SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS:-24} + - SpotifyImport__Playlists=${SPOTIFY_IMPORT_PLAYLISTS:-} + - SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-} + - SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-} + - SpotifyImport__PlaylistLocalTracksPositions=${SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS:-} + + # ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) ===== + - SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false} + - 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} + # Spotify Lyrics API sidecar service URL (internal) + - SpotifyApi__LyricsApiUrl=${SPOTIFY_LYRICS_API_URL:-http://spotify-lyrics:8080} + + # ===== SCROBBLING (LAST.FM, LISTENBRAINZ) ===== + - Scrobbling__Enabled=${SCROBBLING_ENABLED:-false} + - Scrobbling__LocalTracksEnabled=${SCROBBLING_LOCAL_TRACKS_ENABLED:-false} + - Scrobbling__SyntheticLocalPlayedSignalEnabled=${SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED:-false} + - Scrobbling__LastFm__Enabled=${SCROBBLING_LASTFM_ENABLED:-false} + - Scrobbling__LastFm__ApiKey=${SCROBBLING_LASTFM_API_KEY:-} + - Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-} + - Scrobbling__LastFm__SessionKey=${SCROBBLING_LASTFM_SESSION_KEY:-} + - Scrobbling__LastFm__Username=${SCROBBLING_LASTFM_USERNAME:-} + - Scrobbling__LastFm__Password=${SCROBBLING_LASTFM_PASSWORD:-} + - Scrobbling__ListenBrainz__Enabled=${SCROBBLING_LISTENBRAINZ_ENABLED:-false} + - Scrobbling__ListenBrainz__UserToken=${SCROBBLING_LISTENBRAINZ_USER_TOKEN:-} + + # ===== DEBUG SETTINGS ===== + - Debug__LogAllRequests=${DEBUG_LOG_ALL_REQUESTS:-false} + - Debug__RedactSensitiveRequestValues=${DEBUG_REDACT_SENSITIVE_REQUEST_VALUES:-false} + + # ===== SHARED ===== + - Library__DownloadPath=/app/downloads + - SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC} + - SquidWTF__MinRequestIntervalMs=${SQUIDWTF_MIN_REQUEST_INTERVAL_MS:-200} + - Deezer__Arl=${DEEZER_ARL:-} + - Deezer__ArlFallback=${DEEZER_ARL_FALLBACK:-} + - Deezer__Quality=${DEEZER_QUALITY:-FLAC} + - Deezer__MinRequestIntervalMs=${DEEZER_MIN_REQUEST_INTERVAL_MS:-200} + - Qobuz__UserAuthToken=${QOBUZ_USER_AUTH_TOKEN:-} + - Qobuz__UserId=${QOBUZ_USER_ID:-} + - Qobuz__Quality=${QOBUZ_QUALITY:-FLAC} + - Qobuz__MinRequestIntervalMs=${QOBUZ_MIN_REQUEST_INTERVAL_MS:-200} + - MusicBrainz__Enabled=${MUSICBRAINZ_ENABLED:-true} + - MusicBrainz__Username=${MUSICBRAINZ_USERNAME:-} + - MusicBrainz__Password=${MUSICBRAINZ_PASSWORD:-} + volumes: + - ${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 + +networks: + allstarr-network: + name: allstarr-network + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 939e5b8..d7268bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,74 +6,7 @@ services: # Valkey is only accessible internally - no external port exposure expose: - "6379" - # Use a self-healing entrypoint to automatically handle Redis -> Valkey migration pitfalls (like RDB format 12 errors) - # Only delete Valkey/Redis persistence artifacts so misconfigured REDIS_DATA_PATH values do not wipe app cache files. - entrypoint: - - "sh" - - "-ec" - - | - log_file=/tmp/valkey-startup.log - log_pipe=/tmp/valkey-startup.pipe - server_pid= - tee_pid= - - forward_signal() { - if [ -n "$$server_pid" ]; then - kill -TERM "$$server_pid" 2>/dev/null || true - wait "$$server_pid" 2>/dev/null || true - fi - - if [ -n "$$tee_pid" ]; then - kill "$$tee_pid" 2>/dev/null || true - wait "$$tee_pid" 2>/dev/null || true - fi - - rm -f "$$log_pipe" - exit 143 - } - - trap forward_signal TERM INT - - start_valkey() { - rm -f "$$log_file" "$$log_pipe" - : > "$$log_file" - mkfifo "$$log_pipe" - - tee -a "$$log_file" < "$$log_pipe" & - tee_pid=$$! - - valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes > "$$log_pipe" 2>&1 & - server_pid=$$! - - wait "$$server_pid" - status=$$? - - wait "$$tee_pid" 2>/dev/null || true - rm -f "$$log_pipe" - server_pid= - tee_pid= - - return "$$status" - } - - is_incompatible_persistence_error() { - grep -Eq "Can't handle RDB format version|Error reading the RDB base file|AOF loading aborted" "$$log_file" - } - - cleanup_incompatible_persistence() { - echo 'Valkey failed to start (likely incompatible Redis persistence files). Removing persisted RDB/AOF artifacts and retrying...' - rm -f /data/*.rdb /data/*.aof /data/*.manifest - rm -rf /data/appendonlydir /data/appendonlydir-* - } - - if ! start_valkey; then - if is_incompatible_persistence_error; then - cleanup_incompatible_persistence - exec valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes - fi - - exit 1 - fi + command: valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes healthcheck: # Use CMD-SHELL for broader compatibility in some environments test: ["CMD-SHELL", "valkey-cli ping || exit 1"]