v1.4.2: added an env migration service, fixed DOWNLOAD_PATH requiring Subsonic settings in the backend
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled

This commit is contained in:
2026-03-24 11:11:20 -04:00
parent 4b423eecb2
commit 5c184d38c8
16 changed files with 795 additions and 146 deletions
+4 -6
View File
@@ -100,12 +100,10 @@ JELLYFIN_LIBRARY_ID=
# Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF) # Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF)
MUSIC_SERVICE=SquidWTF MUSIC_SERVICE=SquidWTF
# Base directory for all downloads (default: ./downloads) # Base directory for permanently downloaded tracks (default: ./downloads)
# This creates three subdirectories: # Note: Temporarily cached tracks are stored in {DOWNLOAD_PATH}/cache. Favorited
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent) # tracks are stored separately in KEPT_PATH (default: ./kept)
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache) DOWNLOAD_PATH=./downloads
# - downloads/kept/ - Favorited external tracks (always permanent)
Library__DownloadPath=./downloads
# ===== SQUIDWTF CONFIGURATION ===== # ===== SQUIDWTF CONFIGURATION =====
# Preferred audio quality (optional, default: LOSSLESS) # Preferred audio quality (optional, default: LOSSLESS)
+1
View File
@@ -13,6 +13,7 @@ COPY allstarr/ allstarr/
COPY allstarr.Tests/ allstarr.Tests/ COPY allstarr.Tests/ allstarr.Tests/
RUN dotnet publish allstarr/allstarr.csproj -c Release -o /app/publish RUN dotnet publish allstarr/allstarr.csproj -c Release -o /app/publish
COPY .env.example /app/publish/
# Runtime stage # Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:10.0 FROM mcr.microsoft.com/dotnet/aspnet:10.0
@@ -91,6 +91,47 @@ public class JellyfinSessionManagerTests
Assert.DoesNotContain("/Sessions/Logout", requestedPaths); 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<JellyfinSessionManager>.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) private static JellyfinProxyService CreateProxyService(HttpMessageHandler handler, JellyfinSettings settings)
{ {
var httpClientFactory = new TestHttpClientFactory(handler); var httpClientFactory = new TestHttpClientFactory(handler);
+74 -43
View File
@@ -474,70 +474,101 @@ public class ConfigController : ControllerBase
_logger.LogWarning(".env file not found at {Path}, creating new file", _helperService.GetEnvFilePath()); _logger.LogWarning(".env file not found at {Path}, creating new file", _helperService.GetEnvFilePath());
} }
// Read current .env file or create new one var envFilePath = _helperService.GetEnvFilePath();
var envContent = new Dictionary<string, string>(); var envLines = new List<string>();
if (System.IO.File.Exists(_helperService.GetEnvFilePath())) if (System.IO.File.Exists(envFilePath))
{ {
var lines = await System.IO.File.ReadAllLinesAsync(_helperService.GetEnvFilePath()); envLines = (await System.IO.File.ReadAllLinesAsync(envFilePath)).ToList();
foreach (var line in lines) }
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('#')) examplePath = Path.Combine(Directory.GetParent(Directory.GetCurrentDirectory())?.FullName ?? "", ".env.example");
continue; }
var eqIndex = line.IndexOf('='); if (System.IO.File.Exists(examplePath))
if (eqIndex > 0) {
{ _logger.LogInformation("Creating new .env from .env.example to preserve formatting");
var key = line[..eqIndex].Trim(); envLines = (await System.IO.File.ReadAllLinesAsync(examplePath)).ToList();
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;
}
} }
_logger.LogDebug("Loaded {Count} existing env vars from {Path}", envContent.Count, _helperService.GetEnvFilePath());
} }
// Apply updates with validation // Apply updates with validation
var appliedUpdates = new List<string>(); var appliedUpdates = new List<string>();
foreach (var (key, value) in request.Updates) var updatesToProcess = new Dictionary<string, string>(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)) if (!AdminHelperService.IsValidEnvKey(key))
{ {
_logger.LogWarning("Invalid env key rejected: {Key}", key); _logger.LogWarning("Invalid env key rejected: {Key}", key);
return BadRequest(new { error = $"Invalid environment variable 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); appliedUpdates.Add(key);
_logger.LogInformation(" Setting {Key} = {Value}", key,
key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") || key.Contains("PASSWORD") var maskedValue = key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") || key.Contains("PASSWORD")
? "***" + (value.Length > 8 ? value[^8..] : "") ? "***" + (value.Length > 8 ? value[^8..] : "")
: value); : value;
_logger.LogInformation(" Setting {Key} = {Value}", key, maskedValue);
// Auto-set cookie date when Spotify session cookie is updated var keyPrefix = $"{key}=";
if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value)) 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 trimmedLine = envLines[i].TrimStart();
var dateValue = DateTime.UtcNow.ToString("o"); // ISO 8601 format if (trimmedLine.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase))
envContent[dateKey] = dateValue; {
appliedUpdates.Add(dateKey); envLines[i] = $"{key}={value}";
_logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue); 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) await System.IO.File.WriteAllLinesAsync(envFilePath, envLines);
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
await System.IO.File.WriteAllTextAsync(_helperService.GetEnvFilePath(), newContent + "\n");
_logger.LogDebug("Config file updated successfully at {Path}", _helperService.GetEnvFilePath()); _logger.LogDebug("Config file updated successfully at {Path}", _helperService.GetEnvFilePath());
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using allstarr.Models.Download; using allstarr.Models.Download;
using allstarr.Services; using allstarr.Services;
using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace allstarr.Controllers; namespace allstarr.Controllers;
@@ -10,13 +11,16 @@ namespace allstarr.Controllers;
public class DownloadActivityController : ControllerBase public class DownloadActivityController : ControllerBase
{ {
private readonly IEnumerable<IDownloadService> _downloadServices; private readonly IEnumerable<IDownloadService> _downloadServices;
private readonly JellyfinSessionManager _sessionManager;
private readonly ILogger<DownloadActivityController> _logger; private readonly ILogger<DownloadActivityController> _logger;
public DownloadActivityController( public DownloadActivityController(
IEnumerable<IDownloadService> downloadServices, IEnumerable<IDownloadService> downloadServices,
JellyfinSessionManager sessionManager,
ILogger<DownloadActivityController> logger) ILogger<DownloadActivityController> logger)
{ {
_downloadServices = downloadServices; _downloadServices = downloadServices;
_sessionManager = sessionManager;
_logger = logger; _logger = logger;
} }
@@ -26,7 +30,7 @@ public class DownloadActivityController : ControllerBase
[HttpGet("queue")] [HttpGet("queue")]
public IActionResult GetDownloadQueue() public IActionResult GetDownloadQueue()
{ {
var allDownloads = GetAllActiveDownloads(); var allDownloads = GetAllActivityEntries();
return Ok(allDownloads); return Ok(allDownloads);
} }
@@ -58,7 +62,7 @@ public class DownloadActivityController : ControllerBase
{ {
while (!token.IsCancellationRequested) while (!token.IsCancellationRequested)
{ {
var allDownloads = GetAllActiveDownloads(); var allDownloads = GetAllActivityEntries();
var payload = JsonSerializer.Serialize(allDownloads, jsonOptions); var payload = JsonSerializer.Serialize(allDownloads, jsonOptions);
var message = $"data: {payload}\n\n"; var message = $"data: {payload}\n\n";
@@ -83,7 +87,7 @@ public class DownloadActivityController : ControllerBase
} }
} }
private List<DownloadInfo> GetAllActiveDownloads() private List<DownloadActivityEntry> GetAllActivityEntries()
{ {
var allDownloads = new List<DownloadInfo>(); var allDownloads = new List<DownloadInfo>();
foreach (var service in _downloadServices) foreach (var service in _downloadServices)
@@ -91,10 +95,87 @@ public class DownloadActivityController : ControllerBase
allDownloads.AddRange(service.GetActiveDownloads()); allDownloads.AddRange(service.GetActiveDownloads());
} }
// Sort: InProgress first, then by StartedAt descending var orderedDownloads = allDownloads
return allDownloads
.OrderByDescending(d => d.Status == DownloadStatus.InProgress) .OrderByDescending(d => d.Status == DownloadStatus.InProgress)
.ThenByDescending(d => d.StartedAt) .ThenByDescending(d => d.StartedAt)
.ToList(); .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<string>(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; }
} }
} }
+2
View File
@@ -12,6 +12,8 @@ public class DownloadInfo
public string Artist { get; set; } = string.Empty; public string Artist { get; set; } = string.Empty;
public DownloadStatus Status { get; set; } public DownloadStatus Status { get; set; }
public double Progress { get; set; } // 0.0 to 1.0 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? LocalPath { get; set; }
public string? ErrorMessage { get; set; } public string? ErrorMessage { get; set; }
public DateTime StartedAt { get; set; } public DateTime StartedAt { get; set; }
+81 -10
View File
@@ -39,6 +39,30 @@ public abstract class BaseDownloadService : IDownloadService
private DateTime _lastRequestTime = DateTime.MinValue; private DateTime _lastRequestTime = DateTime.MinValue;
protected int _minRequestIntervalMs = 200; 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<StorageMode>(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<DownloadMode>(modeStr, true, out var result) ? result : DownloadMode.Track;
}
}
/// <summary> /// <summary>
/// Lazy-loaded PlaylistSyncService to avoid circular dependency /// Lazy-loaded PlaylistSyncService to avoid circular dependency
/// </summary> /// </summary>
@@ -105,7 +129,12 @@ public abstract class BaseDownloadService : IDownloadService
/// </summary> /// </summary>
public async Task<string> DownloadSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) public async Task<string> 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); Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
// Update write time for cache cleanup (extends cache lifetime) // Update write time for cache cleanup (extends cache lifetime)
if (SubsonicSettings.StorageMode == StorageMode.Cache) if (CurrentStorageMode == StorageMode.Cache)
{ {
IOFile.SetLastWriteTime(localPath, DateTime.UtcNow); IOFile.SetLastWriteTime(localPath, DateTime.UtcNow);
} }
@@ -152,7 +181,12 @@ public abstract class BaseDownloadService : IDownloadService
// IMPORTANT: Use CancellationToken.None for the actual download // IMPORTANT: Use CancellationToken.None for the actual download
// This ensures downloads complete server-side even if the client cancels the request // 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 // 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; var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath); Logger.LogInformation("Download completed, starting stream ({ElapsedMs}ms total): {Path}", elapsed, localPath);
@@ -295,6 +329,24 @@ public abstract class BaseDownloadService : IDownloadService
} }
public abstract Task<bool> IsAvailableAsync(); public abstract Task<bool> 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) public void DownloadRemainingAlbumTracksInBackground(string externalProvider, string albumExternalId, string excludeTrackExternalId)
{ {
@@ -371,15 +423,20 @@ public abstract class BaseDownloadService : IDownloadService
/// <summary> /// <summary>
/// Internal method for downloading a song with control over album download triggering /// Internal method for downloading a song with control over album download triggering
/// </summary> /// </summary>
protected async Task<string> DownloadSongInternalAsync(string externalProvider, string externalId, bool triggerAlbumDownload, CancellationToken cancellationToken = default) protected async Task<string> DownloadSongInternalAsync(
string externalProvider,
string externalId,
bool triggerAlbumDownload,
bool requestedForStreaming = false,
CancellationToken cancellationToken = default)
{ {
if (externalProvider != ProviderName) if (externalProvider != ProviderName)
{ {
throw new NotSupportedException($"Provider '{externalProvider}' is not supported"); throw new NotSupportedException($"Provider '{externalProvider}' is not supported");
} }
var songId = $"ext-{externalProvider}-{externalId}"; var songId = BuildTrackedSongId(externalProvider, externalId);
var isCache = SubsonicSettings.StorageMode == StorageMode.Cache; var isCache = CurrentStorageMode == StorageMode.Cache;
bool isInitiator = false; bool isInitiator = false;
@@ -405,6 +462,11 @@ public abstract class BaseDownloadService : IDownloadService
// Check if download in progress // Check if download in progress
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress) 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); Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
// We are not the initiator; we will wait outside the lock. // 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 Title = "Unknown Title", // Will be updated after fetching
Artist = "Unknown Artist", Artist = "Unknown Artist",
Status = DownloadStatus.InProgress, Status = DownloadStatus.InProgress,
Progress = 0,
RequestedForStreaming = requestedForStreaming,
StartedAt = DateTime.UtcNow 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 // In Album mode, fetch the full album first to ensure AlbumArtist is correctly set
Song? song = null; Song? song = null;
if (SubsonicSettings.DownloadMode == DownloadMode.Album) if (CurrentDownloadMode == DownloadMode.Album)
{ {
// First try to get the song to extract album ID // First try to get the song to extract album ID
var tempSong = await MetadataService.GetSongAsync(externalProvider, externalId); var tempSong = await MetadataService.GetSongAsync(externalProvider, externalId);
@@ -500,6 +564,7 @@ public abstract class BaseDownloadService : IDownloadService
{ {
info.Title = song.Title ?? "Unknown Title"; info.Title = song.Title ?? "Unknown Title";
info.Artist = song.Artist ?? "Unknown Artist"; info.Artist = song.Artist ?? "Unknown Artist";
info.DurationSeconds = song.Duration;
} }
var localPath = await DownloadTrackAsync(externalId, song, cancellationToken); var localPath = await DownloadTrackAsync(externalId, song, cancellationToken);
@@ -507,6 +572,7 @@ public abstract class BaseDownloadService : IDownloadService
if (ActiveDownloads.TryGetValue(songId, out var successInfo)) if (ActiveDownloads.TryGetValue(songId, out var successInfo))
{ {
successInfo.Status = DownloadStatus.Completed; successInfo.Status = DownloadStatus.Completed;
successInfo.Progress = 1.0;
successInfo.LocalPath = localPath; successInfo.LocalPath = localPath;
successInfo.CompletedAt = DateTime.UtcNow; 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 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); var albumExternalId = ExtractExternalIdFromAlbumId(song.AlbumId);
if (!string.IsNullOrEmpty(albumExternalId)) if (!string.IsNullOrEmpty(albumExternalId))
@@ -642,7 +708,7 @@ public abstract class BaseDownloadService : IDownloadService
} }
// Check if download is already in progress or recently completed // 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 (ActiveDownloads.TryGetValue(songId, out var activeDownload))
{ {
if (activeDownload.Status == DownloadStatus.InProgress) 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); 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) catch (Exception ex)
{ {
+102 -5
View File
@@ -35,13 +35,13 @@ public class EnvMigrationService
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#")) if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
continue; continue;
// Migrate DOWNLOAD_PATH to Library__DownloadPath // Migrate Library__DownloadPath to DOWNLOAD_PATH (inverse migration)
if (line.StartsWith("DOWNLOAD_PATH=")) if (line.StartsWith("Library__DownloadPath="))
{ {
var value = line.Substring("DOWNLOAD_PATH=".Length); var value = line.Substring("Library__DownloadPath=".Length);
lines[i] = $"Library__DownloadPath={value}"; lines[i] = $"DOWNLOAD_PATH={value}";
modified = true; 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 // Migrate old SquidWTF quality values to new format
@@ -104,10 +104,107 @@ public class EnvMigrationService
File.WriteAllLines(_envFilePath, lines); File.WriteAllLines(_envFilePath, lines);
_logger.LogInformation("✅ .env file migration completed successfully"); _logger.LogInformation("✅ .env file migration completed successfully");
} }
ReformatEnvFileIfSquashed();
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Failed to migrate .env file"); _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<string, string>(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<string>(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");
}
}
} }
@@ -99,10 +99,9 @@ public class DeezerDownloadService : BaseDownloadService
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles) // Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist; var artistForPath = song.AlbumArtist ?? song.Artist;
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/ var basePath = CurrentStorageMode == StorageMode.Cache
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? Path.Combine(DownloadPath, "cache")
? Path.Combine("downloads", "cache") : Path.Combine(DownloadPath, "permanent");
: Path.Combine("downloads", "permanent");
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "deezer", trackId); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "deezer", trackId);
// Create directories if they don't exist // Create directories if they don't exist
@@ -296,6 +296,25 @@ public class JellyfinSessionManager : IDisposable
return (null, null); return (null, null);
} }
/// <summary>
/// Returns current active playback states for tracked sessions.
/// </summary>
public IReadOnlyList<ActivePlaybackState> 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();
}
/// <summary> /// <summary>
/// Marks a session as potentially ended (e.g., after playback stops). /// Marks a session as potentially ended (e.g., after playback stops).
/// Jellyfin should decide when the upstream playback session expires. /// Jellyfin should decide when the upstream playback session expires.
@@ -678,6 +697,12 @@ public class JellyfinSessionManager : IDisposable
public DateTime? LastExplicitStopAtUtc { get; set; } public DateTime? LastExplicitStopAtUtc { get; set; }
} }
public sealed record ActivePlaybackState(
string DeviceId,
string ItemId,
long PositionTicks,
DateTime LastActivity);
public void Dispose() public void Dispose()
{ {
_keepAliveTimer?.Dispose(); _keepAliveTimer?.Dispose();
@@ -102,8 +102,7 @@ public class QobuzDownloadService : BaseDownloadService
// Build organized folder structure using AlbumArtist (fallback to Artist for singles) // Build organized folder structure using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist; var artistForPath = song.AlbumArtist ?? song.Artist;
// Cache mode uses downloads/cache/ folder, Permanent mode uses downloads/permanent/ var basePath = CurrentStorageMode == StorageMode.Cache
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
? Path.Combine(DownloadPath, "cache") ? Path.Combine(DownloadPath, "cache")
: Path.Combine(DownloadPath, "permanent"); : Path.Combine(DownloadPath, "permanent");
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "qobuz", trackId); var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "qobuz", trackId);
@@ -101,6 +101,7 @@ public class SquidWTFDownloadService : BaseDownloadService
{ {
return await _fallbackHelper.TryWithFallbackAsync(async baseUrl => return await _fallbackHelper.TryWithFallbackAsync(async baseUrl =>
{ {
var songId = BuildTrackedSongId(trackId);
var downloadInfo = await FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken); var downloadInfo = await FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken);
Logger.LogInformation( Logger.LogInformation(
@@ -136,8 +137,29 @@ public class SquidWTFDownloadService : BaseDownloadService
await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken); await using var responseStream = await res.Content.ReadAsStreamAsync(cancellationToken);
await using var outputFile = IOFile.Create(outputPath); 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(); await outputFile.DisposeAsync();
SetDownloadProgress(songId, 1.0);
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
@@ -166,8 +188,8 @@ public class SquidWTFDownloadService : BaseDownloadService
{ {
Exception? lastException = null; Exception? lastException = null;
var qualityOrder = BuildQualityFallbackOrder(_squidwtfSettings.Quality); var qualityOrder = BuildQualityFallbackOrder(_squidwtfSettings.Quality);
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache var basePath = CurrentStorageMode == StorageMode.Cache
? Path.Combine("downloads", "cache") : Path.Combine("downloads", "permanent"); ? Path.Combine(DownloadPath, "cache") : Path.Combine(DownloadPath, "permanent");
foreach (var quality in qualityOrder) foreach (var quality in qualityOrder)
{ {
+52
View File
@@ -424,6 +424,9 @@ function renderDownloadActivity(downloads) {
}; };
const html = downloads.map(d => { const html = downloads.map(d => {
const downloadProgress = clampProgress(d.progress);
const playbackProgress = clampProgress(d.playbackProgress);
// Determine elapsed/duration text // Determine elapsed/duration text
let timeText = ""; let timeText = "";
if (d.startedAt) { if (d.startedAt) {
@@ -433,9 +436,37 @@ function renderDownloadActivity(downloads) {
timeText = diffSecs < 60 ? `${diffSecs}s` : `${Math.floor(diffSecs/60)}m ${diffSecs%60}s`; 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
? `<div class="download-progress-meta">${progressMeta.map(escapeHtml).join(" • ")}</div>`
: "";
const progressBar = `
<div class="download-progress-bar" aria-hidden="true">
<div class="download-progress-buffer" style="width:${downloadProgress * 100}%"></div>
<div class="download-progress-playback" style="width:${playbackProgress * 100}%"></div>
</div>
${progressMetaText}
`;
const title = d.title || 'Unknown Title'; const title = d.title || 'Unknown Title';
const artist = d.artist || 'Unknown Artist'; const artist = d.artist || 'Unknown Artist';
const errorText = d.errorMessage ? `<div style="color:var(--error); font-size:0.8rem; margin-top:4px;">${escapeHtml(d.errorMessage)}</div>` : ''; const errorText = d.errorMessage ? `<div style="color:var(--error); font-size:0.8rem; margin-top:4px;">${escapeHtml(d.errorMessage)}</div>` : '';
const streamBadge = d.requestedForStreaming
? '<span class="download-queue-badge">Stream</span>'
: '';
const playingBadge = d.isPlaying
? '<span class="download-queue-badge is-playing">Playing</span>'
: '';
return ` return `
<div class="download-queue-item"> <div class="download-queue-item">
@@ -444,7 +475,10 @@ function renderDownloadActivity(downloads) {
<div class="download-queue-meta"> <div class="download-queue-meta">
<span class="download-queue-artist">${escapeHtml(artist)}</span> <span class="download-queue-artist">${escapeHtml(artist)}</span>
<span class="download-queue-provider">${escapeHtml(d.externalProvider)}</span> <span class="download-queue-provider">${escapeHtml(d.externalProvider)}</span>
${streamBadge}
${playingBadge}
</div> </div>
${progressBar}
${errorText} ${errorText}
</div> </div>
<div class="download-queue-status"> <div class="download-queue-status">
@@ -458,6 +492,24 @@ function renderDownloadActivity(downloads) {
container.innerHTML = html; 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) { export function initDashboardData(options) {
isAuthenticated = options.isAuthenticated; isAuthenticated = options.isAuthenticated;
isAdminSession = options.isAdminSession; isAdminSession = options.isAdminSession;
+48
View File
@@ -1003,6 +1003,8 @@ input::placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
min-width: 0;
flex: 1;
} }
.download-queue-title { .download-queue-title {
@@ -1030,6 +1032,52 @@ input::placeholder {
text-transform: uppercase; 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 { .download-queue-status {
display: flex; display: flex;
align-items: center; align-items: center;
+249
View File
@@ -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
+1 -68
View File
@@ -6,74 +6,7 @@ services:
# Valkey is only accessible internally - no external port exposure # Valkey is only accessible internally - no external port exposure
expose: expose:
- "6379" - "6379"
# Use a self-healing entrypoint to automatically handle Redis -> Valkey migration pitfalls (like RDB format 12 errors) command: valkey-server --maxmemory 1gb --maxmemory-policy allkeys-lru --save 60 1 --appendonly yes
# 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: healthcheck:
# Use CMD-SHELL for broader compatibility in some environments # Use CMD-SHELL for broader compatibility in some environments
test: ["CMD-SHELL", "valkey-cli ping || exit 1"] test: ["CMD-SHELL", "valkey-cli ping || exit 1"]