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 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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
# 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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user