mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-07 22:03:14 -04:00
1293 lines
55 KiB
C#
1293 lines
55 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
using allstarr.Models.Settings;
|
|
using allstarr.Models.Admin;
|
|
using allstarr.Filters;
|
|
using allstarr.Services.Admin;
|
|
using allstarr.Services.Common;
|
|
using allstarr.Services.Spotify;
|
|
using allstarr.Core.Secrets;
|
|
using allstarr.Core.Storage;
|
|
using allstarr.Core.Configuration;
|
|
using allstarr.Core.Settings;
|
|
using allstarr.Core.Capabilities;
|
|
using allstarr.Middleware;
|
|
using System.Text.Json;
|
|
using System.Net.Sockets;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace allstarr.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/admin")]
|
|
[ServiceFilter(typeof(AdminPortFilter))]
|
|
public class ConfigController : ControllerBase
|
|
{
|
|
private readonly ILogger<ConfigController> _logger;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
|
private readonly JellyfinSettings _jellyfinSettings;
|
|
private readonly SubsonicSettings _subsonicSettings;
|
|
private readonly DeezerSettings _deezerSettings;
|
|
private readonly QobuzSettings _qobuzSettings;
|
|
private readonly AppleDownloadSettings _appleMusicSettings;
|
|
private readonly MusicBrainzSettings _musicBrainzSettings;
|
|
private readonly SpotifyImportSettings _spotifyImportSettings;
|
|
private readonly ScrobblingSettings _scrobblingSettings;
|
|
private readonly string _envFilePath;
|
|
private readonly SpotifySessionCookieService _spotifySessionCookieService;
|
|
private readonly IApplicationCache _cache;
|
|
|
|
public ConfigController(
|
|
ILogger<ConfigController> logger,
|
|
IConfiguration configuration,
|
|
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
|
IOptions<JellyfinSettings> jellyfinSettings,
|
|
IOptions<SubsonicSettings> subsonicSettings,
|
|
IOptions<DeezerSettings> deezerSettings,
|
|
IOptions<QobuzSettings> qobuzSettings,
|
|
IOptions<AppleDownloadSettings> appleMusicSettings,
|
|
IOptions<MusicBrainzSettings> musicBrainzSettings,
|
|
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
|
IOptions<ScrobblingSettings> scrobblingSettings,
|
|
IWebHostEnvironment environment,
|
|
SpotifySessionCookieService spotifySessionCookieService,
|
|
IApplicationCache cache)
|
|
{
|
|
_logger = logger;
|
|
_configuration = configuration;
|
|
_spotifyApiSettings = spotifyApiSettings.Value;
|
|
_jellyfinSettings = jellyfinSettings.Value;
|
|
_subsonicSettings = subsonicSettings.Value;
|
|
_deezerSettings = deezerSettings.Value;
|
|
_qobuzSettings = qobuzSettings.Value;
|
|
_appleMusicSettings = appleMusicSettings.Value;
|
|
_musicBrainzSettings = musicBrainzSettings.Value;
|
|
_spotifyImportSettings = spotifyImportSettings.Value;
|
|
_scrobblingSettings = scrobblingSettings.Value;
|
|
_envFilePath = RuntimeEnvConfiguration.ResolveEnvFilePath(environment);
|
|
_spotifySessionCookieService = spotifySessionCookieService;
|
|
_cache = cache;
|
|
}
|
|
|
|
[HttpGet("config")]
|
|
public async Task<IActionResult> GetConfig()
|
|
{
|
|
IReadOnlyDictionary<string, EffectiveRuntimeSetting> runtimeSettings =
|
|
new Dictionary<string, EffectiveRuntimeSetting>(StringComparer.OrdinalIgnoreCase);
|
|
if (GetAdminSession()?.TenantId is { } tenantId &&
|
|
HttpContext.RequestServices.GetService<IDurableRuntimeSettings>() is { } settings)
|
|
{
|
|
runtimeSettings = await settings.GetManyAsync(tenantId, RuntimeSettingCatalog.Definitions.Keys);
|
|
}
|
|
|
|
string RuntimeString(string key, string fallback) =>
|
|
runtimeSettings.TryGetValue(key, out var setting) ? setting.NormalizedValue : fallback;
|
|
bool RuntimeBool(string key, bool fallback) =>
|
|
runtimeSettings.TryGetValue(key, out var setting) && setting.Value is bool value ? value : fallback;
|
|
int RuntimeInt(string key, int fallback) =>
|
|
runtimeSettings.TryGetValue(key, out var setting) && setting.Value is int value ? value : fallback;
|
|
|
|
// Backend selection is resolved once at process startup. Never let a stale
|
|
// legacy .env file disagree with the controller and Home status.
|
|
var backendType = _configuration.GetValue<string>("Backend:Type")
|
|
?? throw new InvalidOperationException("The deployment backend is unavailable.");
|
|
var useJellyfinSettings = backendType.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase);
|
|
|
|
var fallbackExplicitFilter = useJellyfinSettings
|
|
? _jellyfinSettings.ExplicitFilter.ToString()
|
|
: _subsonicSettings.ExplicitFilter.ToString();
|
|
var fallbackEnableExternalPlaylists = useJellyfinSettings
|
|
? _jellyfinSettings.EnableExternalPlaylists
|
|
: _subsonicSettings.EnableExternalPlaylists;
|
|
var fallbackPlaylistsDirectory = useJellyfinSettings
|
|
? _jellyfinSettings.PlaylistsDirectory
|
|
: _subsonicSettings.PlaylistsDirectory;
|
|
var fallbackStorageMode = useJellyfinSettings
|
|
? _jellyfinSettings.StorageMode.ToString()
|
|
: _subsonicSettings.StorageMode.ToString();
|
|
var fallbackCacheDurationHours = useJellyfinSettings
|
|
? _jellyfinSettings.CacheDurationHours
|
|
: _subsonicSettings.CacheDurationHours;
|
|
var fallbackDownloadMode = useJellyfinSettings
|
|
? _jellyfinSettings.DownloadMode.ToString()
|
|
: _subsonicSettings.DownloadMode.ToString();
|
|
|
|
var storageModeValue = RuntimeString("Library:StorageMode", fallbackStorageMode);
|
|
var isCacheStorageMode = storageModeValue.Equals(nameof(StorageMode.Cache), StringComparison.OrdinalIgnoreCase);
|
|
runtimeSettings.TryGetValue(AudioQualityPolicy.SettingKey, out var sharedAudioQuality);
|
|
var audioQuality = sharedAudioQuality != null &&
|
|
(sharedAudioQuality.Origin == RuntimeSettingOrigin.Durable ||
|
|
_configuration[AudioQualityPolicy.SettingKey] != null)
|
|
? sharedAudioQuality.NormalizedValue
|
|
: AudioQualityPolicy.FromProviderCeilings(
|
|
_appleMusicSettings.Quality, _deezerSettings.Quality, _qobuzSettings.Quality);
|
|
|
|
var libraryDownloadRoot = _configuration["Library:DownloadPath"] ?? "./downloads";
|
|
var libraryKeptPath = _configuration["Library:KeptPath"] ?? Path.Combine(libraryDownloadRoot, "kept");
|
|
var effectivePlaylists = runtimeSettings.TryGetValue("SpotifyImport:Playlists", out var playlistSetting) &&
|
|
playlistSetting.Value is string playlistJson &&
|
|
!string.IsNullOrWhiteSpace(playlistJson)
|
|
? SpotifyPlaylistConfigParser.Parse(playlistJson)
|
|
: _spotifyImportSettings.Playlists;
|
|
var sessionUserId = GetAuthenticatedUserId();
|
|
var cookieStatus = await _spotifySessionCookieService.GetCookieStatusAsync(sessionUserId);
|
|
var effectiveSessionCookie = await _spotifySessionCookieService.ResolveSessionCookieAsync(sessionUserId);
|
|
var userCookieSetDate = !string.IsNullOrWhiteSpace(sessionUserId)
|
|
? await _spotifySessionCookieService.GetCookieSetDateAsync(sessionUserId)
|
|
: null;
|
|
var effectiveCookieSetDate = userCookieSetDate?.ToString("o");
|
|
|
|
if (string.IsNullOrWhiteSpace(effectiveCookieSetDate) && cookieStatus.UsingGlobalFallback)
|
|
{
|
|
effectiveCookieSetDate = _spotifyApiSettings.SessionCookieSetDate ?? string.Empty;
|
|
}
|
|
|
|
return Ok(new
|
|
{
|
|
backendType,
|
|
explicitFilter = RuntimeString("Library:ExplicitFilter", fallbackExplicitFilter),
|
|
enableExternalPlaylists = RuntimeBool("Library:EnableExternalPlaylists", fallbackEnableExternalPlaylists),
|
|
matching = new
|
|
{
|
|
localPreferencePercent = RuntimeInt("Matching:LocalPreferencePercent", 7)
|
|
},
|
|
audio = new { quality = audioQuality },
|
|
playlistsDirectory = RuntimeString("Library:PlaylistsDirectory", fallbackPlaylistsDirectory),
|
|
providers = new
|
|
{
|
|
metadataOrder = RuntimeString("Providers:MetadataOrder", "apple-download,deezer,qobuz"),
|
|
downloadOrder = RuntimeString("Providers:DownloadOrder", "apple-download,deezer,qobuz"),
|
|
streamingOrder = RuntimeString("Providers:StreamingOrder", "apple-download,deezer,qobuz"),
|
|
playlistOrder = RuntimeString("Providers:PlaylistOrder", "spotify,deezer,qobuz"),
|
|
lyricsOrder = RuntimeString("Providers:LyricsOrder", "spotify,apple-download,lrclib"),
|
|
enabledSearch = RuntimeString("Providers:EnabledSearch", "deezer,qobuz"),
|
|
enabledPlaylist = RuntimeString("Providers:EnabledPlaylist", "spotify"),
|
|
disabledProviders = RuntimeString("Providers:Disabled", string.Empty),
|
|
},
|
|
debug = new
|
|
{
|
|
logAllRequests = _configuration.GetValue<bool>("Debug:LogAllRequests", false),
|
|
redactSensitiveRequestValues = true
|
|
},
|
|
admin = new
|
|
{
|
|
bindAnyIp = AdminNetworkBindingPolicy.ShouldBindAdminAnyIp(_configuration),
|
|
trustedSubnets = _configuration.GetValue<string>("Admin:TrustedSubnets") ?? string.Empty,
|
|
allowEnvExport = IsEnvExportEnabled(),
|
|
redactSensitiveValues = _configuration.GetValue<bool>("Admin:RedactSensitiveValues", false)
|
|
},
|
|
spotifyApi = new
|
|
{
|
|
enabled = RuntimeBool("SpotifyApi:Enabled", _spotifyApiSettings.Enabled),
|
|
sessionCookie = AdminHelperService.MaskValue(effectiveSessionCookie, showLast: 8),
|
|
sessionCookieSetDate = effectiveCookieSetDate ?? string.Empty,
|
|
usingGlobalFallback = cookieStatus.UsingGlobalFallback,
|
|
cacheDurationMinutes = RuntimeInt("SpotifyApi:CacheDurationMinutes", _spotifyApiSettings.CacheDurationMinutes),
|
|
rateLimitDelayMs = RuntimeInt("SpotifyApi:RateLimitDelayMs", _spotifyApiSettings.RateLimitDelayMs),
|
|
preferIsrcMatching = RuntimeBool("SpotifyApi:PreferIsrcMatching", _spotifyApiSettings.PreferIsrcMatching)
|
|
},
|
|
spotifyImport = new
|
|
{
|
|
enabled = RuntimeBool("SpotifyImport:Enabled", _spotifyImportSettings.Enabled),
|
|
matchingIntervalHours = RuntimeInt("SpotifyImport:MatchingIntervalHours", _spotifyImportSettings.MatchingIntervalHours),
|
|
playlists = effectivePlaylists.Select(p => new
|
|
{
|
|
name = p.Name,
|
|
id = p.Id,
|
|
localTracksPosition = p.LocalTracksPosition.ToString()
|
|
})
|
|
},
|
|
jellyfin = new
|
|
{
|
|
url = _jellyfinSettings.Url ?? string.Empty,
|
|
apiKey = AdminHelperService.MaskValue(_jellyfinSettings.ApiKey ?? string.Empty),
|
|
userId = _jellyfinSettings.UserId ?? string.Empty,
|
|
libraryId = _jellyfinSettings.LibraryId ?? string.Empty
|
|
},
|
|
subsonic = new
|
|
{
|
|
url = _subsonicSettings.Url ?? string.Empty
|
|
},
|
|
library = new
|
|
{
|
|
downloadPath = isCacheStorageMode
|
|
? Path.Combine(libraryDownloadRoot, "cache")
|
|
: Path.Combine(libraryDownloadRoot, "permanent"),
|
|
keptPath = libraryKeptPath,
|
|
storageMode = storageModeValue,
|
|
cacheDurationHours = RuntimeInt("Library:CacheDurationHours", fallbackCacheDurationHours),
|
|
downloadMode = RuntimeString("Library:DownloadMode", fallbackDownloadMode)
|
|
},
|
|
deezer = new
|
|
{
|
|
arl = AdminHelperService.MaskValue(_deezerSettings.Arl ?? string.Empty, showLast: 8),
|
|
arlFallback = AdminHelperService.MaskValue(_deezerSettings.ArlFallback ?? string.Empty, showLast: 8),
|
|
quality = RuntimeString("Deezer:Quality", _deezerSettings.Quality ?? "FLAC"),
|
|
minRequestIntervalMs = RuntimeInt("Deezer:MinRequestIntervalMs", _deezerSettings.MinRequestIntervalMs)
|
|
},
|
|
qobuz = new
|
|
{
|
|
userAuthToken = AdminHelperService.MaskValue(_qobuzSettings.UserAuthToken ?? string.Empty, showLast: 8),
|
|
userId = _qobuzSettings.UserId ?? string.Empty,
|
|
quality = RuntimeString("Qobuz:Quality", _qobuzSettings.Quality ?? "FLAC"),
|
|
minRequestIntervalMs = RuntimeInt("Qobuz:MinRequestIntervalMs", _qobuzSettings.MinRequestIntervalMs)
|
|
},
|
|
appleDownload = new
|
|
{
|
|
baseUrl = !string.IsNullOrWhiteSpace(_configuration["AppleDownload:BaseUrl"])
|
|
? _appleMusicSettings.BaseUrl ?? string.Empty
|
|
: RuntimeString("AppleDownload:BaseUrl", _appleMusicSettings.BaseUrl ?? string.Empty),
|
|
endpointManagedByDeployment = !string.IsNullOrWhiteSpace(_configuration["AppleDownload:BaseUrl"]),
|
|
quality = RuntimeString("AppleDownload:Quality", _appleMusicSettings.Quality ?? "alac-16-44")
|
|
},
|
|
musicBrainz = new
|
|
{
|
|
enabled = RuntimeBool("MusicBrainz:Enabled", _musicBrainzSettings.Enabled),
|
|
username = _musicBrainzSettings.Username ?? string.Empty,
|
|
password = AdminHelperService.MaskValue(_musicBrainzSettings.Password ?? string.Empty),
|
|
baseUrl = _musicBrainzSettings.BaseUrl,
|
|
rateLimitMs = _musicBrainzSettings.RateLimitMs
|
|
},
|
|
cache = new
|
|
{
|
|
searchResultsMinutes = RuntimeInt("Cache:SearchResultsMinutes", _configuration.GetValue<int>("Cache:SearchResultsMinutes", 1)),
|
|
playlistImagesHours = RuntimeInt("Cache:PlaylistImagesHours", _configuration.GetValue<int>("Cache:PlaylistImagesHours", 168)),
|
|
lyricsDays = RuntimeInt("Cache:LyricsDays", _configuration.GetValue<int>("Cache:LyricsDays", 14)),
|
|
genreDays = RuntimeInt("Cache:GenreDays", _configuration.GetValue<int>("Cache:GenreDays", 30)),
|
|
metadataDays = RuntimeInt("Cache:MetadataDays", _configuration.GetValue<int>("Cache:MetadataDays", 7)),
|
|
odesliLookupDays = RuntimeInt("Cache:OdesliLookupDays", _configuration.GetValue<int>("Cache:OdesliLookupDays", 60)),
|
|
proxyImagesDays = RuntimeInt("Cache:ProxyImagesDays", _configuration.GetValue<int>("Cache:ProxyImagesDays", 14)),
|
|
mediaDirectory = _configuration["Cache:MediaDirectory"] ?? "/app/cache/media",
|
|
mediaMaximumMegabytes = _configuration.GetValue<int>("Cache:MediaMaximumMegabytes", 512),
|
|
mediaMaximumEntryMegabytes = _configuration.GetValue<int>("Cache:MediaMaximumEntryMegabytes", 16),
|
|
mediaCleanupFileLimit = _configuration.GetValue<int>("Cache:MediaCleanupFileLimit", 10_000),
|
|
transcodeCacheMinutes = RuntimeInt("Cache:TranscodeCacheMinutes", _configuration.GetValue<int>("Cache:TranscodeCacheMinutes", 60))
|
|
},
|
|
extensions = new
|
|
{
|
|
repositories = _configuration.GetValue<string>("EXTENSION_REPOSITORIES") ?? string.Empty
|
|
},
|
|
scrobbling = new
|
|
{
|
|
enabled = RuntimeBool("Scrobbling:Enabled", _scrobblingSettings.Enabled),
|
|
localTracksEnabled = RuntimeBool("Scrobbling:LocalTracksEnabled", _scrobblingSettings.LocalTracksEnabled),
|
|
syntheticLocalPlayedSignalEnabled = RuntimeBool(
|
|
"Scrobbling:SyntheticLocalPlayedSignalEnabled",
|
|
_scrobblingSettings.SyntheticLocalPlayedSignalEnabled),
|
|
lastFm = new
|
|
{
|
|
enabled = RuntimeBool("Scrobbling:LastFm:Enabled", _scrobblingSettings.LastFm.Enabled),
|
|
apiKey = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.ApiKey, showLast: 8),
|
|
sharedSecret = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SharedSecret, showLast: 8),
|
|
sessionKey = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.SessionKey, showLast: 8),
|
|
username = _scrobblingSettings.LastFm.Username ?? "(not set)",
|
|
password = AdminHelperService.MaskValue(_scrobblingSettings.LastFm.Password, showLast: 0)
|
|
},
|
|
listenBrainz = new
|
|
{
|
|
enabled = RuntimeBool("Scrobbling:ListenBrainz:Enabled", _scrobblingSettings.ListenBrainz.Enabled),
|
|
userToken = AdminHelperService.MaskValue(_scrobblingSettings.ListenBrainz.UserToken, showLast: 8)
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpGet("config/effective-provider-policy")]
|
|
public async Task<IActionResult> GetEffectiveProviderPolicy(CancellationToken cancellationToken = default)
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("effective provider policy inspection");
|
|
if (adminCheck != null)
|
|
{
|
|
return adminCheck;
|
|
}
|
|
|
|
var session = GetAdminSession();
|
|
if (session?.TenantId is not { } tenantId)
|
|
{
|
|
return Conflict(new
|
|
{
|
|
error = "The administrator session is not linked to an Allstarr tenant.",
|
|
code = "tenant_required"
|
|
});
|
|
}
|
|
|
|
var services = HttpContext.RequestServices;
|
|
var policy = await services.GetRequiredService<IEffectiveProviderPolicyResolver>()
|
|
.ResolveAsync(tenantId, cancellationToken);
|
|
var contextFactory = services.GetRequiredService<IDbContextFactory<AllstarrDbContext>>();
|
|
await using var db = await contextFactory.CreateDbContextAsync(cancellationToken);
|
|
var eligibleAccounts = await db.ProviderAccounts.AsNoTracking()
|
|
.Where(item => item.Enabled && (item.TenantId == null || item.TenantId == tenantId))
|
|
.Select(item => new { item.ProviderId, item.Scope })
|
|
.ToArrayAsync(cancellationToken);
|
|
var accountScopes = eligibleAccounts
|
|
.GroupBy(item => new { item.ProviderId, item.Scope })
|
|
.Select(group => new
|
|
{
|
|
providerId = group.Key.ProviderId,
|
|
scope = group.Key.Scope.ToString().ToLowerInvariant(),
|
|
count = group.Count()
|
|
})
|
|
.OrderBy(item => item.providerId)
|
|
.ThenBy(item => item.scope)
|
|
.ToArray();
|
|
|
|
return Ok(new
|
|
{
|
|
audioQuality = policy.AudioQuality,
|
|
disabledProviders = policy.DisabledProviders.Order(StringComparer.Ordinal).ToArray(),
|
|
providerOrders = ProviderOrderPolicyCatalog.Definitions.Select(definition => new
|
|
{
|
|
capability = definition.Capability.ToString().ToLowerInvariant(),
|
|
order = policy.GetProviderOrder(definition.Capability)
|
|
}),
|
|
accountScopes,
|
|
routeSelection = new
|
|
{
|
|
localPreferencePercent = policy.LocalPreferenceWindow * 100d,
|
|
localReason = "Prefer a playable local route when its score is within the configured local preference window.",
|
|
externalReason = "Otherwise choose the first available provider in effective order that satisfies the requested quality."
|
|
}
|
|
});
|
|
}
|
|
|
|
[HttpPost("config")]
|
|
public async Task<IActionResult> UpdateConfig([FromBody] ConfigUpdateRequest request)
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("config update");
|
|
if (adminCheck != null)
|
|
{
|
|
return adminCheck;
|
|
}
|
|
|
|
if (request == null || request.Updates == null || request.Updates.Count == 0)
|
|
{
|
|
return BadRequest(new { error = "No updates provided" });
|
|
}
|
|
|
|
_logger.LogDebug("Config update requested: {Count} changes", request.Updates.Count);
|
|
|
|
try
|
|
{
|
|
var session = GetAdminSession();
|
|
if (session?.TenantId is not { } tenantId)
|
|
{
|
|
return Conflict(new
|
|
{
|
|
error = "The administrator session is not linked to an Allstarr tenant.",
|
|
code = "tenant_required"
|
|
});
|
|
}
|
|
|
|
var normalized = new List<(string LegacyKey, string DurableKey, string Value)>();
|
|
foreach (var (key, value) in request.Updates)
|
|
{
|
|
if (!LegacyEnvParser.TryGetDurableAlias(key, out var durableKey))
|
|
{
|
|
return BadRequest(new
|
|
{
|
|
error = $"{key} is deployment-owned, secret, deprecated, or unsupported and cannot be changed through runtime settings.",
|
|
code = "deployment_setting_read_only",
|
|
key,
|
|
message = "Change bootstrap values in the deployment configuration. Manage provider credentials through provider accounts."
|
|
});
|
|
}
|
|
|
|
normalized.Add((key, durableKey, value));
|
|
}
|
|
|
|
var settings = HttpContext.RequestServices.GetRequiredService<IDurableRuntimeSettings>();
|
|
var current = await settings.GetManyAsync(tenantId, normalized.Select(item => item.DurableKey));
|
|
var writes = normalized.Select(item =>
|
|
{
|
|
var existing = current[item.DurableKey];
|
|
return new RuntimeSettingWrite(
|
|
item.DurableKey,
|
|
item.Value,
|
|
existing.Origin == RuntimeSettingOrigin.Durable ? existing.Revision : null);
|
|
}).ToArray();
|
|
var result = await settings.ApplyBatchAsync(
|
|
tenantId,
|
|
writes,
|
|
"admin-ui",
|
|
session.AllstarrUserId,
|
|
HttpContext.RequestAborted);
|
|
var cacheEntriesInvalidated = normalized.Any(item =>
|
|
!item.DurableKey.StartsWith("Scrobbling:", StringComparison.OrdinalIgnoreCase) &&
|
|
!item.DurableKey.StartsWith("WebUi:", StringComparison.OrdinalIgnoreCase))
|
|
? await _cache.PurgeAllAsync()
|
|
: 0;
|
|
|
|
return Ok(new
|
|
{
|
|
message = "Runtime configuration updated.",
|
|
updatedKeys = normalized.Select(item => item.LegacyKey).ToArray(),
|
|
requiresRestart = false,
|
|
changeVersion = result.ChangeVersion,
|
|
cacheEntriesInvalidated,
|
|
settings = result.Settings.Select(item => new { item.Key, item.Revision, item.UpdatedAt })
|
|
});
|
|
}
|
|
catch (RuntimeSettingConflictException ex)
|
|
{
|
|
return Conflict(new { error = ex.Message, code = "setting_conflict" });
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return BadRequest(new { error = ex.Message, code = "invalid_setting" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to update durable runtime configuration");
|
|
return StatusCode(500, new
|
|
{
|
|
error = "Failed to update configuration"
|
|
});
|
|
}
|
|
}
|
|
|
|
[HttpPost("cache/clear")]
|
|
public async Task<IActionResult> ClearCache()
|
|
{
|
|
_logger.LogDebug("Cache clear requested from admin UI");
|
|
|
|
var clearedCacheEntries = await _cache.PurgeAllAsync();
|
|
_logger.LogInformation("Cache cleared: {Entries} disposable entries", clearedCacheEntries);
|
|
|
|
return Ok(new
|
|
{
|
|
message = "Cache cleared successfully",
|
|
cacheEntriesDeleted = clearedCacheEntries
|
|
});
|
|
}
|
|
|
|
[HttpPost("restart")]
|
|
public async Task<IActionResult> RestartContainer()
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("container restart");
|
|
if (adminCheck != null)
|
|
{
|
|
return adminCheck;
|
|
}
|
|
|
|
_logger.LogDebug("Container restart requested from admin UI");
|
|
|
|
try
|
|
{
|
|
var socketPath = "/var/run/docker.sock";
|
|
|
|
if (!System.IO.File.Exists(socketPath))
|
|
{
|
|
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
|
|
return StatusCode(503, new
|
|
{
|
|
error = "Docker socket not available",
|
|
message = "Please restart manually: docker restart allstarr"
|
|
});
|
|
}
|
|
|
|
var containerId = Environment.MachineName;
|
|
var containerName = "allstarr";
|
|
|
|
_logger.LogDebug("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName);
|
|
|
|
var handler = new SocketsHttpHandler
|
|
{
|
|
ConnectCallback = async (context, cancellationToken) =>
|
|
{
|
|
var socket = new System.Net.Sockets.Socket(
|
|
System.Net.Sockets.AddressFamily.Unix,
|
|
System.Net.Sockets.SocketType.Stream,
|
|
System.Net.Sockets.ProtocolType.Unspecified);
|
|
|
|
var endpoint = new System.Net.Sockets.UnixDomainSocketEndPoint(socketPath);
|
|
await socket.ConnectAsync(endpoint, cancellationToken);
|
|
|
|
return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
|
|
}
|
|
};
|
|
|
|
using var dockerClient = new HttpClient(handler)
|
|
{
|
|
BaseAddress = new Uri("http://localhost")
|
|
};
|
|
|
|
var restartResponse = await dockerClient.PostAsync($"/containers/{containerName}/restart?t=5", null);
|
|
|
|
if (!restartResponse.IsSuccessStatusCode)
|
|
{
|
|
restartResponse.Dispose();
|
|
restartResponse = await dockerClient.PostAsync($"/containers/{containerId}/restart?t=5", null);
|
|
}
|
|
|
|
using var response = restartResponse;
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogInformation("Container restart initiated successfully");
|
|
return Ok(new { message = "Restarting container...", success = true });
|
|
}
|
|
else
|
|
{
|
|
var errorBody = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
|
return StatusCode((int)response.StatusCode, new
|
|
{
|
|
error = "Failed to restart container",
|
|
message = "Please restart manually: docker restart allstarr"
|
|
});
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error restarting container");
|
|
return StatusCode(500, new
|
|
{
|
|
error = "Failed to restart container",
|
|
message = "Please restart manually: docker restart allstarr"
|
|
});
|
|
}
|
|
}
|
|
|
|
[HttpGet("export-env")]
|
|
public IActionResult ExportEnv()
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("export env");
|
|
if (adminCheck != null)
|
|
{
|
|
return adminCheck;
|
|
}
|
|
|
|
if (!IsEnvExportEnabled())
|
|
{
|
|
_logger.LogWarning("Blocked export-env request because ADMIN__ENABLE_ENV_EXPORT is disabled");
|
|
return NotFound(new
|
|
{
|
|
error = "Export endpoint is disabled by default",
|
|
message = "Set ADMIN__ENABLE_ENV_EXPORT=true to temporarily enable .env export."
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!System.IO.File.Exists(_envFilePath))
|
|
{
|
|
return NotFound(new { error = ".env file not found" });
|
|
}
|
|
|
|
var envContent = System.IO.File.ReadAllText(_envFilePath);
|
|
var bytes = System.Text.Encoding.UTF8.GetBytes(envContent);
|
|
|
|
return File(bytes, "text/plain", ".env");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to export .env file");
|
|
return StatusCode(500, new { error = "Failed to export .env file" });
|
|
}
|
|
}
|
|
|
|
[HttpPost("import-env")]
|
|
public async Task<IActionResult> ImportEnv([FromForm] IFormFile file)
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("import env");
|
|
if (adminCheck != null)
|
|
{
|
|
return adminCheck;
|
|
}
|
|
|
|
await Task.CompletedTask;
|
|
return StatusCode(StatusCodes.Status410Gone, new
|
|
{
|
|
error = "The wholesale .env import endpoint has been retired.",
|
|
message = "Use /api/admin/config/migration/preview and explicitly confirm /api/admin/config/migration/apply."
|
|
});
|
|
}
|
|
|
|
[HttpPost("export-selective-state")]
|
|
public async Task<IActionResult> ExportSelectiveState(
|
|
[FromBody] SelectiveStateTransferControllerRequest? request,
|
|
[FromServices] SelectiveStateTransferService transferService,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("export selective state");
|
|
if (adminCheck != null) return adminCheck;
|
|
|
|
var session = GetAdminSession();
|
|
if (session == null) return Unauthorized(new { error = "Admin session required" });
|
|
|
|
var exportRequest = (request ?? new SelectiveStateTransferControllerRequest())
|
|
.ToServiceExportRequest();
|
|
var tempDir = Path.Combine(Path.GetTempPath(), "allstarr-export", Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(tempDir);
|
|
try
|
|
{
|
|
var (artifact, report) = await transferService.ExportAsync(
|
|
tempDir,
|
|
exportRequest,
|
|
cancellationToken);
|
|
|
|
var reportJson = JsonSerializer.Serialize(report, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
});
|
|
var reportBytes = System.Text.Encoding.UTF8.GetBytes(reportJson);
|
|
var filename = $"allstarr-selective-export-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zip";
|
|
|
|
Response.Headers["X-Allstarr-Selective-Report"] = Convert.ToBase64String(reportBytes);
|
|
Response.OnCompleted(() =>
|
|
{
|
|
try { Directory.Delete(tempDir, true); } catch { }
|
|
return Task.CompletedTask;
|
|
});
|
|
return File(
|
|
new FileStream(
|
|
artifact.Path,
|
|
FileMode.Open,
|
|
FileAccess.Read,
|
|
FileShare.Read,
|
|
81920,
|
|
FileOptions.Asynchronous | FileOptions.SequentialScan),
|
|
"application/zip",
|
|
filename);
|
|
}
|
|
catch
|
|
{
|
|
try { Directory.Delete(tempDir, true); } catch { }
|
|
throw;
|
|
}
|
|
}
|
|
|
|
[HttpPost("import-selective-state")]
|
|
[RequestSizeLimit(SelectiveStateTransferService.MaximumRequestBytes)]
|
|
public async Task<IActionResult> ImportSelectiveState(
|
|
[FromForm] SelectiveStateTransferControllerRequest request,
|
|
[FromServices] SelectiveStateTransferService transferService,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("import selective state");
|
|
if (adminCheck != null) return adminCheck;
|
|
|
|
var session = GetAdminSession();
|
|
if (session == null) return Unauthorized(new { error = "Admin session required" });
|
|
if (session.AllstarrUserId is not { } actorUserId)
|
|
{
|
|
return Conflict(new { error = "The administrator session is not linked to an Allstarr user." });
|
|
}
|
|
if (request?.File == null ||
|
|
request.File.Length is <= 0 or > SelectiveStateTransferService.MaximumArchiveBytes)
|
|
{
|
|
return BadRequest(new { error = "Choose a bounded selective-transfer ZIP archive." });
|
|
}
|
|
|
|
try
|
|
{
|
|
var importRequest = request.ToServiceImportRequest();
|
|
await using var archive = request.File.OpenReadStream();
|
|
var report = await transferService.ImportAsync(
|
|
archive,
|
|
importRequest,
|
|
actorUserId,
|
|
HttpContext.TraceIdentifier,
|
|
cancellationToken);
|
|
return Ok(new
|
|
{
|
|
success = true,
|
|
message = "Selective import applied cleanly.",
|
|
report = new
|
|
{
|
|
includedCategories = report.IncludedCategories,
|
|
excludedCategories = report.ExcludedCategories,
|
|
totalRows = report.TotalRows,
|
|
rowsByEntry = report.RowsByEntry
|
|
}
|
|
});
|
|
}
|
|
catch (SelectiveTransferConflictException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Selective state import rejected by conflict");
|
|
return Conflict(new { error = ex.Message });
|
|
}
|
|
catch (SelectiveTransferValidationException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Selective state import rejected by validation");
|
|
return BadRequest(new { error = ex.Message });
|
|
}
|
|
catch (SelectiveTransferSchemaMismatchException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Selective state import schema mismatch");
|
|
return StatusCode(StatusCodes.Status409Conflict, new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
[HttpPost("preview-selective-state")]
|
|
[RequestSizeLimit(SelectiveStateTransferService.MaximumRequestBytes)]
|
|
public async Task<IActionResult> PreviewSelectiveState(
|
|
[FromForm] SelectiveStateTransferControllerRequest request,
|
|
[FromServices] SelectiveStateTransferService transferService,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("preview selective state");
|
|
if (adminCheck != null) return adminCheck;
|
|
|
|
if (request?.File == null ||
|
|
request.File.Length is <= 0 or > SelectiveStateTransferService.MaximumArchiveBytes)
|
|
{
|
|
return BadRequest(new { error = "Choose a bounded selective-transfer ZIP archive." });
|
|
}
|
|
|
|
try
|
|
{
|
|
var importRequest = request.ToServiceImportRequest();
|
|
await using var archive = request.File.OpenReadStream();
|
|
var preview = await transferService.PreviewAsync(
|
|
archive,
|
|
importRequest,
|
|
cancellationToken);
|
|
return Ok(new
|
|
{
|
|
preview.CanImport,
|
|
preview.Dependencies,
|
|
preview.Conflicts,
|
|
preview.Report
|
|
});
|
|
}
|
|
catch (SelectiveTransferValidationException ex)
|
|
{
|
|
return BadRequest(new { error = ex.Message });
|
|
}
|
|
catch (SelectiveTransferSchemaMismatchException ex)
|
|
{
|
|
return StatusCode(StatusCodes.Status409Conflict, new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
[HttpGet("config/migration/status")]
|
|
public async Task<IActionResult> GetEnvMigrationStatus(CancellationToken cancellationToken = default)
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("env migration status");
|
|
if (adminCheck != null)
|
|
{
|
|
return adminCheck;
|
|
}
|
|
|
|
var session = GetAdminSession();
|
|
var service = HttpContext.RequestServices.GetRequiredService<LegacyEnvMigrationService>();
|
|
return Ok(await service.GetStatusAsync(session?.TenantId, cancellationToken));
|
|
}
|
|
|
|
[HttpPost("config/migration/preview")]
|
|
[RequestSizeLimit(LegacyEnvParser.MaxBytes * 2L)]
|
|
public async Task<IActionResult> PreviewEnvMigration(
|
|
[FromForm] IFormFile? file,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Response.Headers.CacheControl = "no-store";
|
|
Response.Headers.Pragma = "no-cache";
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("env migration preview");
|
|
if (adminCheck != null)
|
|
{
|
|
return adminCheck;
|
|
}
|
|
|
|
if (file == null || file.Length is <= 0 or > LegacyEnvParser.MaxBytes)
|
|
{
|
|
return BadRequest(new { error = $"Choose a .env file between 1 and {LegacyEnvParser.MaxBytes} bytes." });
|
|
}
|
|
|
|
var sourceName = Path.GetFileName(file.FileName);
|
|
if (sourceName.Length is 0 or > 255 ||
|
|
!sourceName.EndsWith(".env", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return BadRequest(new { error = "Choose a file with a bounded .env filename." });
|
|
}
|
|
|
|
try
|
|
{
|
|
await using var source = file.OpenReadStream();
|
|
using var buffer = new MemoryStream((int)file.Length);
|
|
await source.CopyToAsync(buffer, cancellationToken);
|
|
var bytes = buffer.ToArray();
|
|
try
|
|
{
|
|
var service = HttpContext.RequestServices.GetRequiredService<LegacyEnvMigrationService>();
|
|
return Ok(await service.PreviewAsync(
|
|
bytes,
|
|
CreateMigrationActor(),
|
|
cancellationToken));
|
|
}
|
|
finally
|
|
{
|
|
System.Security.Cryptography.CryptographicOperations.ZeroMemory(bytes);
|
|
}
|
|
}
|
|
catch (LegacyEnvParseException ex)
|
|
{
|
|
return BadRequest(new { error = ex.Message, code = "invalid_env" });
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return BadRequest(new { error = ex.Message, code = "invalid_setting" });
|
|
}
|
|
}
|
|
|
|
[HttpPost("config/migration/apply")]
|
|
public async Task<IActionResult> ApplyEnvMigration(
|
|
[FromBody] ApplyLegacyEnvMigrationRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("env migration apply");
|
|
if (adminCheck != null)
|
|
{
|
|
return adminCheck;
|
|
}
|
|
|
|
try
|
|
{
|
|
var service = HttpContext.RequestServices.GetRequiredService<LegacyEnvMigrationService>();
|
|
return Ok(await service.ApplyAsync(
|
|
request.PreviewToken ?? string.Empty,
|
|
request.Revision ?? string.Empty,
|
|
request.Confirmed,
|
|
CreateMigrationActor(),
|
|
cancellationToken));
|
|
}
|
|
catch (LegacyEnvMigrationException ex) when (ex.Code == "preview_owner_mismatch")
|
|
{
|
|
return StatusCode(StatusCodes.Status403Forbidden, new { error = ex.Message, code = ex.Code });
|
|
}
|
|
catch (LegacyEnvMigrationException ex) when (ex.Code is "revision_mismatch" or "state_changed" or
|
|
"provider_account_conflict")
|
|
{
|
|
return Conflict(new { error = ex.Message, code = ex.Code });
|
|
}
|
|
catch (LegacyEnvMigrationException ex)
|
|
{
|
|
return BadRequest(new { error = ex.Message, code = ex.Code });
|
|
}
|
|
catch (RuntimeSettingConflictException ex)
|
|
{
|
|
return Conflict(new { error = ex.Message, code = "setting_conflict" });
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return BadRequest(new { error = ex.Message, code = "invalid_setting" });
|
|
}
|
|
}
|
|
|
|
[HttpPost("config/migration/reset")]
|
|
public async Task<IActionResult> ResetEnvMigration(
|
|
[FromBody] ResetLegacyEnvMigrationRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("env migration reset");
|
|
if (adminCheck != null)
|
|
{
|
|
return adminCheck;
|
|
}
|
|
|
|
try
|
|
{
|
|
var service = HttpContext.RequestServices.GetRequiredService<LegacyEnvMigrationService>();
|
|
await service.ResetPreviewAsync(
|
|
request.PreviewToken ?? string.Empty,
|
|
CreateMigrationActor(),
|
|
cancellationToken);
|
|
return NoContent();
|
|
}
|
|
catch (LegacyEnvMigrationException ex) when (ex.Code == "preview_owner_mismatch")
|
|
{
|
|
return StatusCode(
|
|
StatusCodes.Status403Forbidden,
|
|
new { error = ex.Message, code = ex.Code });
|
|
}
|
|
catch (LegacyEnvMigrationException ex) when (ex.Code == "preview_applied")
|
|
{
|
|
return Conflict(new { error = ex.Message, code = ex.Code });
|
|
}
|
|
catch (LegacyEnvMigrationException ex)
|
|
{
|
|
return BadRequest(new { error = ex.Message, code = ex.Code });
|
|
}
|
|
}
|
|
|
|
private AdminAuthSession? GetAdminSession() =>
|
|
HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var value)
|
|
? value as AdminAuthSession
|
|
: null;
|
|
|
|
private LegacyEnvMigrationActor CreateMigrationActor()
|
|
{
|
|
var session = GetAdminSession() ?? throw new LegacyEnvMigrationException(
|
|
"admin_session_required",
|
|
"An administrator session is required.");
|
|
var correlationId = HttpContext.Items[CorrelationMiddleware.HttpContextItemKey]?.ToString()
|
|
?? HttpContext.TraceIdentifier;
|
|
return new(session.SessionId, session.TenantId, session.AllstarrUserId, correlationId);
|
|
}
|
|
|
|
public sealed class ApplyLegacyEnvMigrationRequest
|
|
{
|
|
public string? PreviewToken { get; set; }
|
|
public string? Revision { get; set; }
|
|
public bool Confirmed { get; set; }
|
|
}
|
|
|
|
public sealed class ResetLegacyEnvMigrationRequest
|
|
{
|
|
public string? PreviewToken { get; set; }
|
|
}
|
|
|
|
private string? GetAuthenticatedUserId()
|
|
{
|
|
if (HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) &&
|
|
sessionObj is AdminAuthSession session &&
|
|
!string.IsNullOrWhiteSpace(session.UserId))
|
|
{
|
|
return session.UserId;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private IActionResult? RequireAdministratorForSensitiveOperation(string operationName)
|
|
{
|
|
if (HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) &&
|
|
sessionObj is AdminAuthSession session &&
|
|
session.IsAdministrator)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
_logger.LogWarning("Blocked sensitive admin operation '{Operation}' due to missing administrator session", operationName);
|
|
return StatusCode(StatusCodes.Status403Forbidden, new
|
|
{
|
|
error = "Administrator permissions required",
|
|
message = "This operation is restricted to Jellyfin administrators."
|
|
});
|
|
}
|
|
|
|
private bool IsEnvExportEnabled()
|
|
{
|
|
if (_configuration.GetValue<bool>("Admin:EnableEnvExport"))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (_configuration.GetValue<bool>("ADMIN__ENABLE_ENV_EXPORT"))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return _configuration.GetValue<bool>("ADMIN_ENABLE_ENV_EXPORT");
|
|
}
|
|
|
|
[HttpGet("providers/status")]
|
|
public async Task<IActionResult> GetProvidersStatus(CancellationToken cancellationToken = default)
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("get providers status");
|
|
if (adminCheck != null)
|
|
{
|
|
return adminCheck;
|
|
}
|
|
|
|
var statusManager = HttpContext.RequestServices.GetRequiredService<ProviderStatusManager>();
|
|
var contextFactory = HttpContext.RequestServices
|
|
.GetRequiredService<IDbContextFactory<AllstarrDbContext>>();
|
|
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
|
var accounts = await context.ProviderAccounts.AsNoTracking()
|
|
.OrderBy(item => item.ProviderId)
|
|
.ThenBy(item => item.Id)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var results = new List<object>();
|
|
foreach (var account in accounts)
|
|
{
|
|
IReadOnlyDictionary<string, string> accountSecrets =
|
|
new Dictionary<string, string>(StringComparer.Ordinal);
|
|
if (account.SecretReferenceId.HasValue)
|
|
{
|
|
try
|
|
{
|
|
accountSecrets = await ReadProviderAccountSecretsAsync(account, cancellationToken);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogWarning(
|
|
"Managed provider account status configuration could not be opened ({ExceptionType})",
|
|
ex.GetType().Name);
|
|
}
|
|
}
|
|
|
|
foreach (var status in statusManager.GetAllManagedStatuses(
|
|
account.ProviderId,
|
|
account.Id,
|
|
accountSecrets))
|
|
{
|
|
results.Add(new
|
|
{
|
|
provider = status.Provider,
|
|
providerAccountId = account.Id,
|
|
providerAccountName = account.DisplayName,
|
|
capability = status.Capability,
|
|
accountScope = account.Scope.ToString().ToLowerInvariant(),
|
|
supported = status.IsSupported,
|
|
enabled = account.Enabled && status.IsEnabled,
|
|
configuration = status.Configuration switch
|
|
{
|
|
ProviderConfigurationState.NotRequired => "not_required",
|
|
ProviderConfigurationState.Configured => "configured",
|
|
_ => "needs_configuration"
|
|
},
|
|
health = status.Health.ToString().ToLowerInvariant(),
|
|
ready = account.Enabled && status.IsReady,
|
|
canAttempt = account.Enabled && status.CanAttempt,
|
|
testedAt = status.TestedAt,
|
|
reasonCode = account.Enabled ? status.ReasonCode : "account_disabled",
|
|
canTest = statusManager.CanTestCapability(status.Provider, status.Capability)
|
|
});
|
|
}
|
|
}
|
|
|
|
return Ok(results);
|
|
}
|
|
|
|
[HttpPost("providers/test/{provider}")]
|
|
[HttpPost("providers/test/{provider}/{capability}")]
|
|
public async Task<IActionResult> TestProvider(
|
|
string provider,
|
|
string? capability = null,
|
|
[FromQuery] Guid? accountId = null)
|
|
{
|
|
var adminCheck = RequireAdministratorForSensitiveOperation("test provider connection");
|
|
if (adminCheck != null)
|
|
{
|
|
return adminCheck;
|
|
}
|
|
|
|
var statusManager = HttpContext.RequestServices.GetRequiredService<ProviderStatusManager>();
|
|
var normalizedProvider = provider.Trim().ToLowerInvariant();
|
|
if (!accountId.HasValue || accountId == Guid.Empty)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(capability))
|
|
{
|
|
return BadRequest(new
|
|
{
|
|
success = false,
|
|
error = "Select a capability to test"
|
|
});
|
|
}
|
|
|
|
if (!statusManager.CanTestCapability(normalizedProvider, capability))
|
|
{
|
|
return BadRequest(new { success = false, error = "This provider capability has no endpoint probe" });
|
|
}
|
|
|
|
if (!statusManager.CanTestAccountFreeCapability(normalizedProvider, capability))
|
|
{
|
|
return BadRequest(new { success = false, error = "Select a provider account for this capability" });
|
|
}
|
|
|
|
var accountFreeTimer = System.Diagnostics.Stopwatch.StartNew();
|
|
var testedAccountFree = await statusManager.TestAccountFreeProviderCapabilityAsync(
|
|
normalizedProvider,
|
|
capability,
|
|
HttpContext.RequestAborted);
|
|
accountFreeTimer.Stop();
|
|
var accountFreeLatencyMs = accountFreeTimer.ElapsedMilliseconds;
|
|
return Ok(new
|
|
{
|
|
success = testedAccountFree.Health == allstarr.Services.Common.ProviderHealthState.Healthy,
|
|
provider = testedAccountFree.Provider,
|
|
capability = testedAccountFree.Capability,
|
|
health = testedAccountFree.Health.ToString().ToLowerInvariant(),
|
|
latencyMs = accountFreeLatencyMs,
|
|
bars = ConnectivityQuality.Bars(accountFreeLatencyMs, testedAccountFree.Health == allstarr.Services.Common.ProviderHealthState.Healthy, ConnectivityMetric.ApiLatency),
|
|
metric = "api-latency",
|
|
testedAt = testedAccountFree.TestedAt,
|
|
reasonCode = testedAccountFree.ReasonCode
|
|
});
|
|
}
|
|
|
|
var contextFactory = HttpContext.RequestServices
|
|
.GetRequiredService<IDbContextFactory<AllstarrDbContext>>();
|
|
await using var context = await contextFactory.CreateDbContextAsync(HttpContext.RequestAborted);
|
|
var account = await context.ProviderAccounts.AsNoTracking().SingleOrDefaultAsync(
|
|
item => item.Id == accountId.Value,
|
|
HttpContext.RequestAborted);
|
|
if (account == null ||
|
|
!account.ProviderId.Equals(normalizedProvider, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return NotFound(new { success = false, error = "Managed provider account not found" });
|
|
}
|
|
|
|
IReadOnlyDictionary<string, string> accountSecrets;
|
|
try
|
|
{
|
|
accountSecrets = await ReadProviderAccountSecretsAsync(account, HttpContext.RequestAborted);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogWarning(
|
|
"Managed provider account probe configuration could not be opened ({ExceptionType})",
|
|
ex.GetType().Name);
|
|
return BadRequest(new
|
|
{
|
|
success = false,
|
|
error = "The managed provider account credential is missing or invalid"
|
|
});
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(capability))
|
|
{
|
|
var connectionTimer = System.Diagnostics.Stopwatch.StartNew();
|
|
var healthy = await statusManager.TestManagedProviderConnectionAsync(
|
|
normalizedProvider,
|
|
account.Id,
|
|
accountSecrets,
|
|
HttpContext.RequestAborted);
|
|
connectionTimer.Stop();
|
|
var connectionLatencyMs = connectionTimer.ElapsedMilliseconds;
|
|
var failedCapabilities = statusManager.GetAllManagedStatuses(
|
|
normalizedProvider,
|
|
account.Id,
|
|
accountSecrets)
|
|
.Where(item => item.Health == allstarr.Services.Common.ProviderHealthState.Degraded)
|
|
.Select(item => new { capability = item.Capability, reasonCode = item.ReasonCode })
|
|
.ToArray();
|
|
return Ok(new
|
|
{
|
|
success = true,
|
|
provider = normalizedProvider,
|
|
providerAccountId = account.Id,
|
|
healthy,
|
|
latencyMs = connectionLatencyMs,
|
|
bars = ConnectivityQuality.Bars(connectionLatencyMs, healthy, ConnectivityMetric.ApiLatency),
|
|
metric = "api-latency",
|
|
reasonCode = failedCapabilities.FirstOrDefault()?.reasonCode,
|
|
failedCapabilities
|
|
});
|
|
}
|
|
|
|
var current = statusManager.GetManagedStatus(
|
|
normalizedProvider,
|
|
capability,
|
|
account.Id,
|
|
accountSecrets);
|
|
if (!current.IsSupported)
|
|
{
|
|
return BadRequest(new
|
|
{
|
|
success = false,
|
|
provider = normalizedProvider,
|
|
capability,
|
|
error = "Unsupported provider capability"
|
|
});
|
|
}
|
|
|
|
var capabilityTimer = System.Diagnostics.Stopwatch.StartNew();
|
|
var tested = await statusManager.TestManagedProviderCapabilityAsync(
|
|
normalizedProvider,
|
|
capability,
|
|
account.Id,
|
|
accountSecrets,
|
|
HttpContext.RequestAborted);
|
|
capabilityTimer.Stop();
|
|
var capabilityLatencyMs = capabilityTimer.ElapsedMilliseconds;
|
|
return Ok(new
|
|
{
|
|
success = tested.Health == allstarr.Services.Common.ProviderHealthState.Healthy,
|
|
provider = tested.Provider,
|
|
providerAccountId = account.Id,
|
|
capability = tested.Capability,
|
|
health = tested.Health.ToString().ToLowerInvariant(),
|
|
latencyMs = capabilityLatencyMs,
|
|
bars = ConnectivityQuality.Bars(capabilityLatencyMs, tested.Health == allstarr.Services.Common.ProviderHealthState.Healthy, ConnectivityMetric.ApiLatency),
|
|
metric = "api-latency",
|
|
testedAt = tested.TestedAt,
|
|
reasonCode = tested.ReasonCode
|
|
});
|
|
}
|
|
|
|
private async Task<IReadOnlyDictionary<string, string>> ReadProviderAccountSecretsAsync(
|
|
ProviderAccountRecord account,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!account.SecretReferenceId.HasValue)
|
|
{
|
|
return new Dictionary<string, string>(StringComparer.Ordinal);
|
|
}
|
|
|
|
var secretStore = HttpContext.RequestServices.GetRequiredService<EncryptedSecretStore>();
|
|
using var lease = await secretStore.OpenAsync(
|
|
account.SecretReferenceId.Value,
|
|
new SecretAccessContext(
|
|
account.TenantId,
|
|
AllowGlobal: account.TenantId == null),
|
|
cancellationToken);
|
|
using var document = JsonDocument.Parse(lease.Value);
|
|
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
|
{
|
|
throw new InvalidOperationException("Provider account credentials must be a JSON object.");
|
|
}
|
|
|
|
var values = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var property in document.RootElement.EnumerateObject())
|
|
{
|
|
var normalizedName = new string(property.Name
|
|
.Where(char.IsLetterOrDigit)
|
|
.Select(char.ToLowerInvariant)
|
|
.ToArray());
|
|
var value = property.Value.ValueKind switch
|
|
{
|
|
JsonValueKind.String => property.Value.GetString(),
|
|
JsonValueKind.Number => property.Value.GetRawText(),
|
|
_ => null
|
|
};
|
|
if (!string.IsNullOrWhiteSpace(normalizedName) && !string.IsNullOrWhiteSpace(value))
|
|
{
|
|
values[normalizedName] = value;
|
|
}
|
|
}
|
|
|
|
return values;
|
|
}
|
|
}
|
|
|
|
public sealed class SelectiveStateTransferControllerRequest
|
|
{
|
|
public IFormFile? File { get; set; }
|
|
public SelectiveImportMode Mode { get; set; }
|
|
public bool IncludeSettings { get; set; } = true;
|
|
public bool IncludeAccounts { get; set; } = true;
|
|
public bool IncludePlaylists { get; set; } = true;
|
|
public bool IncludeIntelligence { get; set; } = true;
|
|
public bool IncludeExtensions { get; set; } = true;
|
|
public bool ImportSettings { get; set; } = true;
|
|
public bool ImportAccounts { get; set; } = true;
|
|
public bool ImportPlaylists { get; set; } = true;
|
|
public bool ImportIntelligence { get; set; } = true;
|
|
public bool ImportExtensions { get; set; } = true;
|
|
|
|
public SelectiveExportRequest ToServiceExportRequest() => new()
|
|
{
|
|
IncludeSettings = IncludeSettings,
|
|
IncludeAccounts = IncludeAccounts,
|
|
IncludePlaylists = IncludePlaylists,
|
|
IncludeIntelligence = IncludeIntelligence,
|
|
IncludeExtensions = IncludeExtensions
|
|
};
|
|
|
|
public SelectiveImportRequest ToServiceImportRequest() => new()
|
|
{
|
|
Mode = Mode,
|
|
ImportSettings = ImportSettings,
|
|
ImportAccounts = ImportAccounts,
|
|
ImportPlaylists = ImportPlaylists,
|
|
ImportIntelligence = ImportIntelligence,
|
|
ImportExtensions = ImportExtensions
|
|
};
|
|
}
|