using allstarr.Models.Settings; using allstarr.Services; using allstarr.Services.Deezer; using allstarr.Services.Qobuz; using allstarr.Services.SquidWTF; using allstarr.Services.Local; using allstarr.Services.Validation; using allstarr.Services.Subsonic; using allstarr.Services.Jellyfin; using allstarr.Services.Common; using allstarr.Services.Lyrics; using allstarr.Middleware; using allstarr.Filters; using Microsoft.Extensions.Http; using System.Text; var builder = WebApplication.CreateBuilder(args); // Decode SquidWTF API base URLs once at startup var squidWtfApiUrls = DecodeSquidWtfUrls(); static List DecodeSquidWtfUrls() { var encodedUrls = new[] { "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton "aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum "aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus "aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2 "aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1 "aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf "aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund "aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze "aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel "aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus }; return encodedUrls .Select(encoded => Encoding.UTF8.GetString(Convert.FromBase64String(encoded))) .ToList(); } // Determine backend type FIRST var backendType = builder.Configuration.GetValue("Backend:Type"); // Configure Kestrel for large responses over VPN/Tailscale // Also configure admin port on 5275 (internal only, not exposed) builder.WebHost.ConfigureKestrel(serverOptions => { serverOptions.Limits.MaxResponseBufferSize = null; // Disable response buffering limit serverOptions.Limits.MaxRequestBodySize = null; // Allow large request bodies serverOptions.Limits.MinResponseDataRate = null; // Disable minimum data rate for slow connections // Main proxy port (exposed) serverOptions.ListenAnyIP(8080); // Admin UI port (internal only - do NOT expose through reverse proxy) serverOptions.ListenAnyIP(5275); }); // Add response compression for large JSON responses (helps with Tailscale/VPN MTU issues) builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; options.MimeTypes = new[] { "application/json", "text/json" }; }); // Add services to the container - conditionally register controllers builder.Services.AddControllers() .AddJsonOptions(options => { // Use original property names (PascalCase) to match Jellyfin API options.JsonSerializerOptions.PropertyNamingPolicy = null; options.JsonSerializerOptions.DictionaryKeyPolicy = null; }) .ConfigureApplicationPartManager(manager => { // Remove the default controller feature provider var defaultProvider = manager.FeatureProviders .OfType() .FirstOrDefault(); if (defaultProvider != null) { manager.FeatureProviders.Remove(defaultProvider); } // Add our custom provider that filters by backend type manager.FeatureProviders.Add(new BackendControllerFeatureProvider(backendType)); }); builder.Services.AddHttpClient(); builder.Services.ConfigureAll(options => { options.HttpMessageHandlerBuilderActions.Add(builder => { builder.PrimaryHandler = new HttpClientHandler { AllowAutoRedirect = true, MaxAutomaticRedirections = 5 }; }); // Suppress verbose HTTP logging - these are logged at Debug level by default // but we want to reduce noise in production logs options.SuppressHandlerScope = true; }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddHttpContextAccessor(); // Exception handling builder.Services.AddExceptionHandler(); builder.Services.AddProblemDetails(); // Admin port filter (restricts admin API to port 5275) builder.Services.AddScoped(); // Configuration - register both settings, active one determined by backend type builder.Services.Configure( builder.Configuration.GetSection("Subsonic")); builder.Services.Configure( builder.Configuration.GetSection("Jellyfin")); builder.Services.Configure( builder.Configuration.GetSection("Deezer")); builder.Services.Configure( builder.Configuration.GetSection("Qobuz")); builder.Services.Configure( builder.Configuration.GetSection("SquidWTF")); builder.Services.Configure( builder.Configuration.GetSection("Redis")); // Configure Spotify Import settings with custom playlist parsing from env var builder.Services.Configure(options => { builder.Configuration.GetSection("SpotifyImport").Bind(options); // Debug: Check what Bind() populated Console.WriteLine($"DEBUG: After Bind(), Playlists.Count = {options.Playlists.Count}"); #pragma warning disable CS0618 // Type or member is obsolete Console.WriteLine($"DEBUG: After Bind(), PlaylistIds.Count = {options.PlaylistIds.Count}"); Console.WriteLine($"DEBUG: After Bind(), PlaylistNames.Count = {options.PlaylistNames.Count}"); #pragma warning restore CS0618 // Parse SPOTIFY_IMPORT_PLAYLISTS env var (JSON array format) // Format: [["Name","SpotifyId","JellyfinId","first|last"],["Name2","SpotifyId2","JellyfinId2","first|last"]] var playlistsEnv = builder.Configuration.GetValue("SpotifyImport:Playlists"); if (!string.IsNullOrWhiteSpace(playlistsEnv)) { Console.WriteLine($"Found SPOTIFY_IMPORT_PLAYLISTS env var: {playlistsEnv.Length} chars"); try { // Parse as JSON array of arrays var playlistArrays = System.Text.Json.JsonSerializer.Deserialize(playlistsEnv); if (playlistArrays != null && playlistArrays.Length > 0) { // Clear any playlists that Bind() may have incorrectly populated options.Playlists.Clear(); Console.WriteLine($"Parsed {playlistArrays.Length} playlists from JSON format"); foreach (var arr in playlistArrays) { if (arr.Length >= 2) { var config = new SpotifyPlaylistConfig { Name = arr[0].Trim(), Id = arr[1].Trim(), JellyfinId = arr.Length >= 3 ? arr[2].Trim() : "", LocalTracksPosition = arr.Length >= 4 && arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase) ? LocalTracksPosition.Last : LocalTracksPosition.First }; options.Playlists.Add(config); Console.WriteLine($" Added: {config.Name} (Spotify: {config.Id}, Jellyfin: {config.JellyfinId}, Position: {config.LocalTracksPosition})"); } } } else { Console.WriteLine("JSON format was empty or invalid, will try legacy format"); } } catch (System.Text.Json.JsonException ex) { Console.WriteLine($"Warning: Failed to parse SPOTIFY_IMPORT_PLAYLISTS: {ex.Message}"); Console.WriteLine("Expected format: [[\"Name\",\"SpotifyId\",\"JellyfinId\",\"first|last\"],[\"Name2\",\"SpotifyId2\",\"JellyfinId2\",\"first|last\"]]"); Console.WriteLine("Will try legacy format instead"); } } else { Console.WriteLine("No SPOTIFY_IMPORT_PLAYLISTS env var found, will try legacy format"); } // Legacy support: Parse old SPOTIFY_IMPORT_PLAYLIST_IDS/NAMES env vars // Only used if new Playlists format is not configured // Check if we have legacy env vars to parse var playlistIdsEnv = builder.Configuration.GetValue("SpotifyImport:PlaylistIds"); var playlistNamesEnv = builder.Configuration.GetValue("SpotifyImport:PlaylistNames"); var hasLegacyConfig = !string.IsNullOrWhiteSpace(playlistIdsEnv) || !string.IsNullOrWhiteSpace(playlistNamesEnv); if (hasLegacyConfig && options.Playlists.Count == 0) { Console.WriteLine("Parsing legacy Spotify playlist format..."); #pragma warning disable CS0618 // Type or member is obsolete // Clear any auto-bound values from the Bind() call above // The auto-binder doesn't handle comma-separated strings correctly options.PlaylistIds.Clear(); options.PlaylistNames.Clear(); options.PlaylistLocalTracksPositions.Clear(); if (!string.IsNullOrWhiteSpace(playlistIdsEnv)) { options.PlaylistIds = playlistIdsEnv .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(id => id.Trim()) .Where(id => !string.IsNullOrEmpty(id)) .ToList(); Console.WriteLine($" Parsed {options.PlaylistIds.Count} playlist IDs from env var"); } if (!string.IsNullOrWhiteSpace(playlistNamesEnv)) { options.PlaylistNames = playlistNamesEnv .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(name => name.Trim()) .Where(name => !string.IsNullOrEmpty(name)) .ToList(); Console.WriteLine($" Parsed {options.PlaylistNames.Count} playlist names from env var"); } var playlistPositionsEnv = builder.Configuration.GetValue("SpotifyImport:PlaylistLocalTracksPositions"); if (!string.IsNullOrWhiteSpace(playlistPositionsEnv)) { options.PlaylistLocalTracksPositions = playlistPositionsEnv .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(pos => pos.Trim()) .Where(pos => !string.IsNullOrEmpty(pos)) .ToList(); Console.WriteLine($" Parsed {options.PlaylistLocalTracksPositions.Count} playlist positions from env var"); } else { Console.WriteLine(" No playlist positions env var found, will use defaults"); } // Convert legacy format to new Playlists array Console.WriteLine($" Converting {options.PlaylistIds.Count} playlists to new format..."); for (int i = 0; i < options.PlaylistIds.Count; i++) { var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i]; var position = LocalTracksPosition.First; // Default // Parse position if provided if (i < options.PlaylistLocalTracksPositions.Count) { var posStr = options.PlaylistLocalTracksPositions[i]; if (posStr.Equals("last", StringComparison.OrdinalIgnoreCase)) { position = LocalTracksPosition.Last; } } options.Playlists.Add(new SpotifyPlaylistConfig { Name = name, Id = options.PlaylistIds[i], LocalTracksPosition = position }); Console.WriteLine($" [{i}] {name} (ID: {options.PlaylistIds[i]}, Position: {position})"); } #pragma warning restore CS0618 } else if (hasLegacyConfig && options.Playlists.Count > 0) { // Bind() incorrectly populated Playlists from legacy env vars // Clear it and re-parse properly Console.WriteLine($"DEBUG: Bind() incorrectly populated {options.Playlists.Count} playlists, clearing and re-parsing..."); options.Playlists.Clear(); #pragma warning disable CS0618 // Type or member is obsolete options.PlaylistIds.Clear(); options.PlaylistNames.Clear(); options.PlaylistLocalTracksPositions.Clear(); Console.WriteLine("Parsing legacy Spotify playlist format..."); if (!string.IsNullOrWhiteSpace(playlistIdsEnv)) { options.PlaylistIds = playlistIdsEnv .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(id => id.Trim()) .Where(id => !string.IsNullOrEmpty(id)) .ToList(); Console.WriteLine($" Parsed {options.PlaylistIds.Count} playlist IDs from env var"); } if (!string.IsNullOrWhiteSpace(playlistNamesEnv)) { options.PlaylistNames = playlistNamesEnv .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(name => name.Trim()) .Where(name => !string.IsNullOrEmpty(name)) .ToList(); Console.WriteLine($" Parsed {options.PlaylistNames.Count} playlist names from env var"); } var playlistPositionsEnv = builder.Configuration.GetValue("SpotifyImport:PlaylistLocalTracksPositions"); if (!string.IsNullOrWhiteSpace(playlistPositionsEnv)) { options.PlaylistLocalTracksPositions = playlistPositionsEnv .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(pos => pos.Trim()) .Where(pos => !string.IsNullOrEmpty(pos)) .ToList(); Console.WriteLine($" Parsed {options.PlaylistLocalTracksPositions.Count} playlist positions from env var"); } else { Console.WriteLine(" No playlist positions env var found, will use defaults"); } // Convert legacy format to new Playlists array Console.WriteLine($" Converting {options.PlaylistIds.Count} playlists to new format..."); for (int i = 0; i < options.PlaylistIds.Count; i++) { var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i]; var position = LocalTracksPosition.First; // Default // Parse position if provided if (i < options.PlaylistLocalTracksPositions.Count) { var posStr = options.PlaylistLocalTracksPositions[i]; if (posStr.Equals("last", StringComparison.OrdinalIgnoreCase)) { position = LocalTracksPosition.Last; } } options.Playlists.Add(new SpotifyPlaylistConfig { Name = name, Id = options.PlaylistIds[i], LocalTracksPosition = position }); Console.WriteLine($" [{i}] {name} (ID: {options.PlaylistIds[i]}, Position: {position})"); } #pragma warning restore CS0618 } else { Console.WriteLine($"Using new Playlists format: {options.Playlists.Count} playlists configured"); } // Log configuration at startup Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}"); Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured"); foreach (var playlist in options.Playlists) { Console.WriteLine($" - {playlist.Name} (ID: {playlist.Id}, LocalTracks: {playlist.LocalTracksPosition})"); } }); // Get shared settings from the active backend config MusicService musicService; bool enableExternalPlaylists; if (backendType == BackendType.Jellyfin) { musicService = builder.Configuration.GetValue("Jellyfin:MusicService"); enableExternalPlaylists = builder.Configuration.GetValue("Jellyfin:EnableExternalPlaylists", true); } else { // Default to Subsonic musicService = builder.Configuration.GetValue("Subsonic:MusicService"); enableExternalPlaylists = builder.Configuration.GetValue("Subsonic:EnableExternalPlaylists", true); } // Business services - shared across backends builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Register backend-specific services if (backendType == BackendType.Jellyfin) { // Jellyfin services builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Register JellyfinController as a service for dependency injection builder.Services.AddScoped(); } else { // Subsonic services (default) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); } // Register music service based on configuration // IMPORTANT: Primary service MUST be registered LAST because ASP.NET Core DI // will use the last registered implementation when injecting IMusicMetadataService/IDownloadService if (musicService == MusicService.Qobuz) { // If playlists enabled, register Deezer FIRST (secondary provider) if (enableExternalPlaylists) { builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); } // Qobuz services (primary) - registered LAST to be injected by default builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); } else if (musicService == MusicService.Deezer) { // If playlists enabled, register Qobuz FIRST (secondary provider) if (enableExternalPlaylists) { builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); } // Deezer services (primary, default) - registered LAST to be injected by default builder.Services.AddSingleton(); builder.Services.AddSingleton(); } else if (musicService == MusicService.SquidWTF) { // SquidWTF services - pass decoded URLs with fallback support builder.Services.AddSingleton(sp => new SquidWTFMetadataService( sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetRequiredService>(), sp.GetRequiredService>(), sp.GetRequiredService(), squidWtfApiUrls)); builder.Services.AddSingleton(sp => new SquidWTFDownloadService( sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetRequiredService>(), sp, sp.GetRequiredService>(), squidWtfApiUrls)); } // Register ParallelMetadataService to race all registered providers for faster searches builder.Services.AddSingleton(); // Startup validation - register validators based on backend if (backendType == BackendType.Jellyfin) { builder.Services.AddSingleton(); } else { builder.Services.AddSingleton(); } builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new SquidWTFStartupValidator( sp.GetRequiredService>(), sp.GetRequiredService().CreateClient(), squidWtfApiUrls)); builder.Services.AddSingleton(); // Register orchestrator as hosted service builder.Services.AddHostedService(); // Register cache cleanup service (only runs when StorageMode is Cache) builder.Services.AddHostedService(); // Register cache warming service (loads file caches into Redis on startup) builder.Services.AddHostedService(); // Register Spotify API client, lyrics service, and settings for direct API access // Configure from environment variables with SPOTIFY_API_ prefix builder.Services.Configure(options => { builder.Configuration.GetSection("SpotifyApi").Bind(options); // Override from environment variables var enabled = builder.Configuration.GetValue("SpotifyApi:Enabled"); if (!string.IsNullOrEmpty(enabled)) { options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase); } var clientId = builder.Configuration.GetValue("SpotifyApi:ClientId"); if (!string.IsNullOrEmpty(clientId)) { options.ClientId = clientId; } var clientSecret = builder.Configuration.GetValue("SpotifyApi:ClientSecret"); if (!string.IsNullOrEmpty(clientSecret)) { options.ClientSecret = clientSecret; } var sessionCookie = builder.Configuration.GetValue("SpotifyApi:SessionCookie"); if (!string.IsNullOrEmpty(sessionCookie)) { options.SessionCookie = sessionCookie; } var sessionCookieSetDate = builder.Configuration.GetValue("SpotifyApi:SessionCookieSetDate"); if (!string.IsNullOrEmpty(sessionCookieSetDate)) { options.SessionCookieSetDate = sessionCookieSetDate; } var cacheDuration = builder.Configuration.GetValue("SpotifyApi:CacheDurationMinutes"); if (cacheDuration.HasValue) { options.CacheDurationMinutes = cacheDuration.Value; } var preferIsrc = builder.Configuration.GetValue("SpotifyApi:PreferIsrcMatching"); if (!string.IsNullOrEmpty(preferIsrc)) { options.PreferIsrcMatching = preferIsrc.Equals("true", StringComparison.OrdinalIgnoreCase); } // Log configuration (mask sensitive values) Console.WriteLine($"SpotifyApi Configuration:"); Console.WriteLine($" Enabled: {options.Enabled}"); Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}"); Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}"); Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}"); Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}"); Console.WriteLine($" PreferIsrcMatching: {options.PreferIsrcMatching}"); }); builder.Services.AddSingleton(); // Register Spotify lyrics service (uses Spotify's color-lyrics API) builder.Services.AddSingleton(); // Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled) builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Register Spotify missing tracks fetcher (legacy - only runs when SpotifyImport is enabled and SpotifyApi is disabled) builder.Services.AddHostedService(); // Register Spotify track matching service (pre-matches tracks with rate limiting) builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Register lyrics prefetch service (prefetches lyrics for all playlist tracks) builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Register MusicBrainz service for metadata enrichment builder.Services.Configure(options => { builder.Configuration.GetSection("MusicBrainz").Bind(options); // Override from environment variables var enabled = builder.Configuration.GetValue("MusicBrainz:Enabled"); if (!string.IsNullOrEmpty(enabled)) { options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase); } var username = builder.Configuration.GetValue("MusicBrainz:Username"); if (!string.IsNullOrEmpty(username)) { options.Username = username; } var password = builder.Configuration.GetValue("MusicBrainz:Password"); if (!string.IsNullOrEmpty(password)) { options.Password = password; } }); builder.Services.AddSingleton(); // Register genre enrichment service builder.Services.AddSingleton(); builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => { policy.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader() .WithExposedHeaders("X-Content-Duration", "X-Total-Count", "X-Nd-Authorization"); }); }); var app = builder.Build(); // Configure the HTTP request pipeline. app.UseExceptionHandler(_ => { }); // Global exception handler // Enable response compression EARLY in the pipeline app.UseResponseCompression(); // Enable WebSocket support app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(120) }); // Add WebSocket proxy middleware (BEFORE routing) app.UseMiddleware(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); // Serve static files only on admin port (5275) app.UseMiddleware(); app.UseAuthorization(); app.UseCors(); app.MapControllers(); // Health check endpoint for monitoring app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow })); app.Run(); /// /// Controller feature provider that conditionally registers controllers based on backend type. /// This prevents route conflicts between JellyfinController and SubsonicController catch-all routes. /// class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.ControllerFeatureProvider { private readonly BackendType _backendType; public BackendControllerFeatureProvider(BackendType backendType) { _backendType = backendType; } protected override bool IsController(System.Reflection.TypeInfo typeInfo) { var isController = base.IsController(typeInfo); if (!isController) return false; // AdminController should always be registered (for web UI) if (typeInfo.Name == "AdminController") return true; // Only register the controller matching the configured backend type return _backendType switch { BackendType.Jellyfin => typeInfo.Name == "JellyfinController", BackendType.Subsonic => typeInfo.Name == "SubsonicController", _ => false }; } }