mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-21 02:02:31 -04:00
v1.4.2: added an env migration service, fixed DOWNLOAD_PATH requiring Subsonic settings in the backend
This commit is contained in:
+4
-6
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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)
|
||||
{
|
||||
var httpClientFactory = new TestHttpClientFactory(handler);
|
||||
|
||||
@@ -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<string, string>();
|
||||
var envFilePath = _helperService.GetEnvFilePath();
|
||||
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());
|
||||
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<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))
|
||||
{
|
||||
_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());
|
||||
|
||||
|
||||
@@ -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<IDownloadService> _downloadServices;
|
||||
private readonly JellyfinSessionManager _sessionManager;
|
||||
private readonly ILogger<DownloadActivityController> _logger;
|
||||
|
||||
public DownloadActivityController(
|
||||
IEnumerable<IDownloadService> downloadServices,
|
||||
JellyfinSessionManager sessionManager,
|
||||
ILogger<DownloadActivityController> 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<DownloadInfo> GetAllActiveDownloads()
|
||||
private List<DownloadActivityEntry> GetAllActivityEntries()
|
||||
{
|
||||
var allDownloads = new List<DownloadInfo>();
|
||||
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<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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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<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>
|
||||
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
|
||||
/// </summary>
|
||||
@@ -105,7 +129,12 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
// 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<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)
|
||||
{
|
||||
@@ -371,15 +423,20 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
/// <summary>
|
||||
/// Internal method for downloading a song with control over album download triggering
|
||||
/// </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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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<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)
|
||||
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
|
||||
|
||||
@@ -296,6 +296,25 @@ public class JellyfinSessionManager : IDisposable
|
||||
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>
|
||||
/// 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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
? `<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 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 streamBadge = d.requestedForStreaming
|
||||
? '<span class="download-queue-badge">Stream</span>'
|
||||
: '';
|
||||
const playingBadge = d.isPlaying
|
||||
? '<span class="download-queue-badge is-playing">Playing</span>'
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="download-queue-item">
|
||||
@@ -444,7 +475,10 @@ function renderDownloadActivity(downloads) {
|
||||
<div class="download-queue-meta">
|
||||
<span class="download-queue-artist">${escapeHtml(artist)}</span>
|
||||
<span class="download-queue-provider">${escapeHtml(d.externalProvider)}</span>
|
||||
${streamBadge}
|
||||
${playingBadge}
|
||||
</div>
|
||||
${progressBar}
|
||||
${errorText}
|
||||
</div>
|
||||
<div class="download-queue-status">
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user