mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
886 lines
34 KiB
C#
886 lines
34 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Options;
|
|
using allstarr.Models.Settings;
|
|
using allstarr.Services.Spotify;
|
|
using allstarr.Services.Jellyfin;
|
|
using allstarr.Services.Common;
|
|
using allstarr.Filters;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace allstarr.Controllers;
|
|
|
|
/// <summary>
|
|
/// Admin API controller for the web dashboard.
|
|
/// Provides endpoints for viewing status, playlists, and modifying configuration.
|
|
/// Only accessible on internal admin port (5275) - not exposed through reverse proxy.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("api/admin")]
|
|
[ServiceFilter(typeof(AdminPortFilter))]
|
|
public class AdminController : ControllerBase
|
|
{
|
|
private readonly ILogger<AdminController> _logger;
|
|
private readonly IConfiguration _configuration;
|
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
|
private readonly SpotifyImportSettings _spotifyImportSettings;
|
|
private readonly JellyfinSettings _jellyfinSettings;
|
|
private readonly DeezerSettings _deezerSettings;
|
|
private readonly QobuzSettings _qobuzSettings;
|
|
private readonly SquidWTFSettings _squidWtfSettings;
|
|
private readonly SpotifyApiClient _spotifyClient;
|
|
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
|
private readonly RedisCacheService _cache;
|
|
private readonly HttpClient _jellyfinHttpClient;
|
|
private const string EnvFilePath = "/app/.env";
|
|
private const string CacheDirectory = "/app/cache/spotify";
|
|
|
|
public AdminController(
|
|
ILogger<AdminController> logger,
|
|
IConfiguration configuration,
|
|
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
|
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
|
IOptions<JellyfinSettings> jellyfinSettings,
|
|
IOptions<DeezerSettings> deezerSettings,
|
|
IOptions<QobuzSettings> qobuzSettings,
|
|
IOptions<SquidWTFSettings> squidWtfSettings,
|
|
SpotifyApiClient spotifyClient,
|
|
SpotifyPlaylistFetcher playlistFetcher,
|
|
RedisCacheService cache,
|
|
IHttpClientFactory httpClientFactory)
|
|
{
|
|
_logger = logger;
|
|
_configuration = configuration;
|
|
_spotifyApiSettings = spotifyApiSettings.Value;
|
|
_spotifyImportSettings = spotifyImportSettings.Value;
|
|
_jellyfinSettings = jellyfinSettings.Value;
|
|
_deezerSettings = deezerSettings.Value;
|
|
_qobuzSettings = qobuzSettings.Value;
|
|
_squidWtfSettings = squidWtfSettings.Value;
|
|
_spotifyClient = spotifyClient;
|
|
_playlistFetcher = playlistFetcher;
|
|
_cache = cache;
|
|
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get current system status and configuration
|
|
/// </summary>
|
|
[HttpGet("status")]
|
|
public IActionResult GetStatus()
|
|
{
|
|
// Determine Spotify auth status based on configuration only
|
|
// DO NOT call Spotify API here - this endpoint is polled frequently
|
|
var spotifyAuthStatus = "not_configured";
|
|
string? spotifyUser = null;
|
|
|
|
if (_spotifyApiSettings.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
|
{
|
|
// If cookie is set, assume it's working until proven otherwise
|
|
// Actual validation happens when playlists are fetched
|
|
spotifyAuthStatus = "configured";
|
|
spotifyUser = "(cookie set)";
|
|
}
|
|
else if (_spotifyApiSettings.Enabled)
|
|
{
|
|
spotifyAuthStatus = "missing_cookie";
|
|
}
|
|
|
|
return Ok(new
|
|
{
|
|
version = "1.0.0",
|
|
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
|
jellyfinUrl = _jellyfinSettings.Url,
|
|
spotify = new
|
|
{
|
|
apiEnabled = _spotifyApiSettings.Enabled,
|
|
authStatus = spotifyAuthStatus,
|
|
user = spotifyUser,
|
|
hasCookie = !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie),
|
|
cookieSetDate = _spotifyApiSettings.SessionCookieSetDate,
|
|
cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes,
|
|
preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching
|
|
},
|
|
spotifyImport = new
|
|
{
|
|
enabled = _spotifyImportSettings.Enabled,
|
|
syncTime = $"{_spotifyImportSettings.SyncStartHour:D2}:{_spotifyImportSettings.SyncStartMinute:D2}",
|
|
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
|
|
playlistCount = _spotifyImportSettings.Playlists.Count
|
|
},
|
|
deezer = new
|
|
{
|
|
hasArl = !string.IsNullOrEmpty(_deezerSettings.Arl),
|
|
quality = _deezerSettings.Quality ?? "FLAC"
|
|
},
|
|
qobuz = new
|
|
{
|
|
hasToken = !string.IsNullOrEmpty(_qobuzSettings.UserAuthToken),
|
|
quality = _qobuzSettings.Quality ?? "FLAC"
|
|
},
|
|
squidWtf = new
|
|
{
|
|
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get list of configured playlists with their current data
|
|
/// </summary>
|
|
[HttpGet("playlists")]
|
|
public async Task<IActionResult> GetPlaylists()
|
|
{
|
|
var playlists = new List<object>();
|
|
|
|
foreach (var config in _spotifyImportSettings.Playlists)
|
|
{
|
|
var playlistInfo = new Dictionary<string, object?>
|
|
{
|
|
["name"] = config.Name,
|
|
["id"] = config.Id,
|
|
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
|
["trackCount"] = 0,
|
|
["lastFetched"] = null as DateTime?,
|
|
["cacheAge"] = null as string
|
|
};
|
|
|
|
// Try to get cached playlist data
|
|
var cacheFilePath = Path.Combine(CacheDirectory, $"{SanitizeFileName(config.Name)}_spotify.json");
|
|
if (System.IO.File.Exists(cacheFilePath))
|
|
{
|
|
try
|
|
{
|
|
var json = await System.IO.File.ReadAllTextAsync(cacheFilePath);
|
|
using var doc = JsonDocument.Parse(json);
|
|
var root = doc.RootElement;
|
|
|
|
if (root.TryGetProperty("tracks", out var tracks))
|
|
{
|
|
playlistInfo["trackCount"] = tracks.GetArrayLength();
|
|
}
|
|
|
|
if (root.TryGetProperty("fetchedAt", out var fetchedAt))
|
|
{
|
|
var fetchedTime = fetchedAt.GetDateTime();
|
|
playlistInfo["lastFetched"] = fetchedTime;
|
|
var age = DateTime.UtcNow - fetchedTime;
|
|
playlistInfo["cacheAge"] = age.TotalHours < 1
|
|
? $"{age.TotalMinutes:F0}m"
|
|
: $"{age.TotalHours:F1}h";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to read cache for playlist {Name}", config.Name);
|
|
}
|
|
}
|
|
|
|
playlists.Add(playlistInfo);
|
|
}
|
|
|
|
return Ok(new { playlists });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get tracks for a specific playlist
|
|
/// </summary>
|
|
[HttpGet("playlists/{name}/tracks")]
|
|
public async Task<IActionResult> GetPlaylistTracks(string name)
|
|
{
|
|
var decodedName = Uri.UnescapeDataString(name);
|
|
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
|
|
|
|
return Ok(new
|
|
{
|
|
name = decodedName,
|
|
trackCount = tracks.Count,
|
|
tracks = tracks.Select(t => new
|
|
{
|
|
position = t.Position,
|
|
title = t.Title,
|
|
artists = t.Artists,
|
|
album = t.Album,
|
|
isrc = t.Isrc,
|
|
spotifyId = t.SpotifyId,
|
|
durationMs = t.DurationMs,
|
|
albumArtUrl = t.AlbumArtUrl
|
|
})
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Trigger a manual refresh of all playlists
|
|
/// </summary>
|
|
[HttpPost("playlists/refresh")]
|
|
public async Task<IActionResult> RefreshPlaylists()
|
|
{
|
|
_logger.LogInformation("Manual playlist refresh triggered from admin UI");
|
|
await _playlistFetcher.TriggerFetchAsync();
|
|
return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get current configuration (safe values only)
|
|
/// </summary>
|
|
[HttpGet("config")]
|
|
public IActionResult GetConfig()
|
|
{
|
|
return Ok(new
|
|
{
|
|
spotifyApi = new
|
|
{
|
|
enabled = _spotifyApiSettings.Enabled,
|
|
sessionCookie = MaskValue(_spotifyApiSettings.SessionCookie, showLast: 8),
|
|
sessionCookieSetDate = _spotifyApiSettings.SessionCookieSetDate,
|
|
cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes,
|
|
rateLimitDelayMs = _spotifyApiSettings.RateLimitDelayMs,
|
|
preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching
|
|
},
|
|
spotifyImport = new
|
|
{
|
|
enabled = _spotifyImportSettings.Enabled,
|
|
syncStartHour = _spotifyImportSettings.SyncStartHour,
|
|
syncStartMinute = _spotifyImportSettings.SyncStartMinute,
|
|
syncWindowHours = _spotifyImportSettings.SyncWindowHours,
|
|
playlists = _spotifyImportSettings.Playlists.Select(p => new
|
|
{
|
|
name = p.Name,
|
|
id = p.Id,
|
|
localTracksPosition = p.LocalTracksPosition.ToString()
|
|
})
|
|
},
|
|
jellyfin = new
|
|
{
|
|
url = _jellyfinSettings.Url,
|
|
apiKey = MaskValue(_jellyfinSettings.ApiKey),
|
|
libraryId = _jellyfinSettings.LibraryId
|
|
},
|
|
deezer = new
|
|
{
|
|
arl = MaskValue(_deezerSettings.Arl, showLast: 8),
|
|
arlFallback = MaskValue(_deezerSettings.ArlFallback, showLast: 8),
|
|
quality = _deezerSettings.Quality ?? "FLAC"
|
|
},
|
|
qobuz = new
|
|
{
|
|
userAuthToken = MaskValue(_qobuzSettings.UserAuthToken, showLast: 8),
|
|
userId = _qobuzSettings.UserId,
|
|
quality = _qobuzSettings.Quality ?? "FLAC"
|
|
},
|
|
squidWtf = new
|
|
{
|
|
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update configuration by modifying .env file
|
|
/// </summary>
|
|
[HttpPost("config")]
|
|
public async Task<IActionResult> UpdateConfig([FromBody] ConfigUpdateRequest request)
|
|
{
|
|
if (request == null || request.Updates == null || request.Updates.Count == 0)
|
|
{
|
|
return BadRequest(new { error = "No updates provided" });
|
|
}
|
|
|
|
_logger.LogInformation("Config update requested: {Count} changes", request.Updates.Count);
|
|
|
|
try
|
|
{
|
|
// Read current .env file or create new one
|
|
var envContent = new Dictionary<string, string>();
|
|
|
|
if (System.IO.File.Exists(EnvFilePath))
|
|
{
|
|
var lines = await System.IO.File.ReadAllLinesAsync(EnvFilePath);
|
|
foreach (var line in lines)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
|
|
continue;
|
|
|
|
var eqIndex = line.IndexOf('=');
|
|
if (eqIndex > 0)
|
|
{
|
|
var key = line[..eqIndex].Trim();
|
|
var value = line[(eqIndex + 1)..].Trim();
|
|
envContent[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply updates with validation
|
|
var appliedUpdates = new List<string>();
|
|
foreach (var (key, value) in request.Updates)
|
|
{
|
|
// Validate key format
|
|
if (!IsValidEnvKey(key))
|
|
{
|
|
return BadRequest(new { error = $"Invalid environment variable key: {key}" });
|
|
}
|
|
|
|
envContent[key] = value;
|
|
appliedUpdates.Add(key);
|
|
_logger.LogInformation(" Setting {Key}", key);
|
|
|
|
// Auto-set cookie date when Spotify session cookie is updated
|
|
if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value))
|
|
{
|
|
var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE";
|
|
var dateValue = DateTime.UtcNow.ToString("o"); // ISO 8601 format
|
|
envContent[dateKey] = dateValue;
|
|
appliedUpdates.Add(dateKey);
|
|
_logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue);
|
|
}
|
|
}
|
|
|
|
// Write back to .env file
|
|
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
|
|
await System.IO.File.WriteAllTextAsync(EnvFilePath, newContent + "\n");
|
|
|
|
_logger.LogInformation("Config file updated successfully");
|
|
|
|
return Ok(new
|
|
{
|
|
message = "Configuration updated. Restart container to apply changes.",
|
|
updatedKeys = appliedUpdates,
|
|
requiresRestart = true
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to update configuration");
|
|
return StatusCode(500, new { error = "Failed to update configuration", details = ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Add a new playlist to the configuration
|
|
/// </summary>
|
|
[HttpPost("playlists")]
|
|
public async Task<IActionResult> AddPlaylist([FromBody] AddPlaylistRequest request)
|
|
{
|
|
if (string.IsNullOrEmpty(request.Name) || string.IsNullOrEmpty(request.SpotifyId))
|
|
{
|
|
return BadRequest(new { error = "Name and SpotifyId are required" });
|
|
}
|
|
|
|
_logger.LogInformation("Adding playlist: {Name} ({SpotifyId})", request.Name, request.SpotifyId);
|
|
|
|
// Get current playlists
|
|
var currentPlaylists = _spotifyImportSettings.Playlists.ToList();
|
|
|
|
// Check for duplicates
|
|
if (currentPlaylists.Any(p => p.Id == request.SpotifyId || p.Name == request.Name))
|
|
{
|
|
return BadRequest(new { error = "Playlist with this name or ID already exists" });
|
|
}
|
|
|
|
// Add new playlist
|
|
currentPlaylists.Add(new SpotifyPlaylistConfig
|
|
{
|
|
Name = request.Name,
|
|
Id = request.SpotifyId,
|
|
LocalTracksPosition = request.LocalTracksPosition == "last"
|
|
? LocalTracksPosition.Last
|
|
: LocalTracksPosition.First
|
|
});
|
|
|
|
// Convert to JSON format for env var
|
|
var playlistsJson = JsonSerializer.Serialize(
|
|
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
|
);
|
|
|
|
// Update .env file
|
|
var updateRequest = new ConfigUpdateRequest
|
|
{
|
|
Updates = new Dictionary<string, string>
|
|
{
|
|
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
|
}
|
|
};
|
|
|
|
return await UpdateConfig(updateRequest);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Remove a playlist from the configuration
|
|
/// </summary>
|
|
[HttpDelete("playlists/{name}")]
|
|
public async Task<IActionResult> RemovePlaylist(string name)
|
|
{
|
|
var decodedName = Uri.UnescapeDataString(name);
|
|
_logger.LogInformation("Removing playlist: {Name}", decodedName);
|
|
|
|
var currentPlaylists = _spotifyImportSettings.Playlists.ToList();
|
|
var playlist = currentPlaylists.FirstOrDefault(p => p.Name == decodedName);
|
|
|
|
if (playlist == null)
|
|
{
|
|
return NotFound(new { error = "Playlist not found" });
|
|
}
|
|
|
|
currentPlaylists.Remove(playlist);
|
|
|
|
// Convert to JSON format for env var
|
|
var playlistsJson = JsonSerializer.Serialize(
|
|
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
|
);
|
|
|
|
// Update .env file
|
|
var updateRequest = new ConfigUpdateRequest
|
|
{
|
|
Updates = new Dictionary<string, string>
|
|
{
|
|
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
|
}
|
|
};
|
|
|
|
return await UpdateConfig(updateRequest);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clear all cached data
|
|
/// </summary>
|
|
[HttpPost("cache/clear")]
|
|
public async Task<IActionResult> ClearCache()
|
|
{
|
|
_logger.LogInformation("Cache clear requested from admin UI");
|
|
|
|
var clearedFiles = 0;
|
|
|
|
// Clear file cache
|
|
if (Directory.Exists(CacheDirectory))
|
|
{
|
|
foreach (var file in Directory.GetFiles(CacheDirectory, "*.json"))
|
|
{
|
|
try
|
|
{
|
|
System.IO.File.Delete(file);
|
|
clearedFiles++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to delete cache file {File}", file);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear Redis cache for spotify playlists
|
|
foreach (var playlist in _spotifyImportSettings.Playlists)
|
|
{
|
|
await _cache.DeleteAsync($"spotify:playlist:{playlist.Name}");
|
|
await _cache.DeleteAsync($"spotify:missing:{playlist.Name}");
|
|
}
|
|
|
|
return Ok(new { message = "Cache cleared", filesDeleted = clearedFiles });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restart the allstarr container to apply configuration changes
|
|
/// </summary>
|
|
[HttpPost("restart")]
|
|
public async Task<IActionResult> RestartContainer()
|
|
{
|
|
_logger.LogInformation("Container restart requested from admin UI");
|
|
|
|
try
|
|
{
|
|
// Use Docker socket to restart the container
|
|
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-compose restart allstarr"
|
|
});
|
|
}
|
|
|
|
// Get container ID from hostname (Docker sets hostname to container ID by default)
|
|
// Or use the well-known container name
|
|
var containerId = Environment.MachineName;
|
|
var containerName = "allstarr";
|
|
|
|
_logger.LogInformation("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName);
|
|
|
|
// Create Unix socket HTTP client
|
|
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")
|
|
};
|
|
|
|
// Try to restart by container name first, then by ID
|
|
var response = await dockerClient.PostAsync($"/containers/{containerName}/restart?t=5", null);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
// Try by container ID
|
|
response = await dockerClient.PostAsync($"/containers/{containerId}/restart?t=5", null);
|
|
}
|
|
|
|
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-compose restart allstarr"
|
|
});
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error restarting container");
|
|
return StatusCode(500, new {
|
|
error = "Failed to restart container",
|
|
details = ex.Message,
|
|
message = "Please restart manually: docker-compose restart allstarr"
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initialize cookie date to current date if cookie exists but date is not set
|
|
/// </summary>
|
|
[HttpPost("config/init-cookie-date")]
|
|
public async Task<IActionResult> InitCookieDate()
|
|
{
|
|
// Only init if cookie exists but date is not set
|
|
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
|
{
|
|
return BadRequest(new { error = "No cookie set" });
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(_spotifyApiSettings.SessionCookieSetDate))
|
|
{
|
|
return Ok(new { message = "Cookie date already set", date = _spotifyApiSettings.SessionCookieSetDate });
|
|
}
|
|
|
|
_logger.LogInformation("Initializing cookie date to current date (cookie existed without date tracking)");
|
|
|
|
var updateRequest = new ConfigUpdateRequest
|
|
{
|
|
Updates = new Dictionary<string, string>
|
|
{
|
|
["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = DateTime.UtcNow.ToString("o")
|
|
}
|
|
};
|
|
|
|
return await UpdateConfig(updateRequest);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get all Jellyfin users
|
|
/// </summary>
|
|
[HttpGet("jellyfin/users")]
|
|
public async Task<IActionResult> GetJellyfinUsers()
|
|
{
|
|
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
|
{
|
|
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
|
}
|
|
|
|
try
|
|
{
|
|
var url = $"{_jellyfinSettings.Url}/Users";
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
|
|
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorBody = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("Failed to fetch Jellyfin users: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
|
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch users from Jellyfin" });
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
var users = new List<object>();
|
|
|
|
foreach (var user in doc.RootElement.EnumerateArray())
|
|
{
|
|
var id = user.GetProperty("Id").GetString();
|
|
var name = user.GetProperty("Name").GetString();
|
|
|
|
users.Add(new { id, name });
|
|
}
|
|
|
|
return Ok(new { users });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error fetching Jellyfin users");
|
|
return StatusCode(500, new { error = "Failed to fetch users", details = ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get all Jellyfin libraries (virtual folders)
|
|
/// </summary>
|
|
[HttpGet("jellyfin/libraries")]
|
|
public async Task<IActionResult> GetJellyfinLibraries()
|
|
{
|
|
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
|
{
|
|
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
|
}
|
|
|
|
try
|
|
{
|
|
var url = $"{_jellyfinSettings.Url}/Library/VirtualFolders";
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
|
|
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorBody = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("Failed to fetch Jellyfin libraries: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
|
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch libraries from Jellyfin" });
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
var libraries = new List<object>();
|
|
|
|
foreach (var lib in doc.RootElement.EnumerateArray())
|
|
{
|
|
var name = lib.GetProperty("Name").GetString();
|
|
var itemId = lib.TryGetProperty("ItemId", out var id) ? id.GetString() : null;
|
|
var collectionType = lib.TryGetProperty("CollectionType", out var ct) ? ct.GetString() : null;
|
|
|
|
libraries.Add(new { id = itemId, name, collectionType });
|
|
}
|
|
|
|
return Ok(new { libraries });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error fetching Jellyfin libraries");
|
|
return StatusCode(500, new { error = "Failed to fetch libraries", details = ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get all playlists from Jellyfin
|
|
/// </summary>
|
|
[HttpGet("jellyfin/playlists")]
|
|
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null, [FromQuery] string? parentId = null)
|
|
{
|
|
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
|
{
|
|
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
|
}
|
|
|
|
try
|
|
{
|
|
// Build URL with optional userId and parentId (library) filters
|
|
var url = $"{_jellyfinSettings.Url}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ProviderIds,ChildCount,RecursiveItemCount,SongCount";
|
|
|
|
if (!string.IsNullOrEmpty(userId))
|
|
{
|
|
url += $"&UserId={userId}";
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(parentId))
|
|
{
|
|
url += $"&ParentId={parentId}";
|
|
}
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
|
|
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorBody = await response.Content.ReadAsStringAsync();
|
|
_logger.LogError("Failed to fetch Jellyfin playlists: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
|
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch playlists from Jellyfin" });
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
var playlists = new List<object>();
|
|
|
|
if (doc.RootElement.TryGetProperty("Items", out var items))
|
|
{
|
|
foreach (var item in items.EnumerateArray())
|
|
{
|
|
var id = item.GetProperty("Id").GetString();
|
|
var name = item.GetProperty("Name").GetString();
|
|
|
|
// Try multiple fields for track count - Jellyfin may use different fields
|
|
var childCount = 0;
|
|
if (item.TryGetProperty("ChildCount", out var cc) && cc.ValueKind == JsonValueKind.Number)
|
|
childCount = cc.GetInt32();
|
|
else if (item.TryGetProperty("SongCount", out var sc) && sc.ValueKind == JsonValueKind.Number)
|
|
childCount = sc.GetInt32();
|
|
else if (item.TryGetProperty("RecursiveItemCount", out var ric) && ric.ValueKind == JsonValueKind.Number)
|
|
childCount = ric.GetInt32();
|
|
|
|
// Check if this playlist is configured in allstarr and get linked Spotify ID
|
|
var configuredPlaylist = _spotifyImportSettings.Playlists
|
|
.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
var isConfigured = configuredPlaylist != null;
|
|
var linkedSpotifyId = configuredPlaylist?.Id;
|
|
|
|
playlists.Add(new
|
|
{
|
|
id,
|
|
name,
|
|
trackCount = childCount,
|
|
linkedSpotifyId,
|
|
isConfigured
|
|
});
|
|
}
|
|
}
|
|
|
|
return Ok(new { playlists });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error fetching Jellyfin playlists");
|
|
return StatusCode(500, new { error = "Failed to fetch playlists", details = ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Link a Jellyfin playlist to a Spotify playlist
|
|
/// </summary>
|
|
[HttpPost("jellyfin/playlists/{jellyfinPlaylistId}/link")]
|
|
public async Task<IActionResult> LinkPlaylist(string jellyfinPlaylistId, [FromBody] LinkPlaylistRequest request)
|
|
{
|
|
if (string.IsNullOrEmpty(request.SpotifyPlaylistId))
|
|
{
|
|
return BadRequest(new { error = "SpotifyPlaylistId is required" });
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(request.Name))
|
|
{
|
|
return BadRequest(new { error = "Name is required" });
|
|
}
|
|
|
|
_logger.LogInformation("Linking Jellyfin playlist {JellyfinId} to Spotify playlist {SpotifyId} with name {Name}",
|
|
jellyfinPlaylistId, request.SpotifyPlaylistId, request.Name);
|
|
|
|
// Check if already configured
|
|
var existingPlaylist = _spotifyImportSettings.Playlists
|
|
.FirstOrDefault(p => p.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (existingPlaylist != null)
|
|
{
|
|
return BadRequest(new { error = $"Playlist '{request.Name}' is already configured" });
|
|
}
|
|
|
|
// Add the playlist to configuration
|
|
var currentPlaylists = _spotifyImportSettings.Playlists.ToList();
|
|
currentPlaylists.Add(new SpotifyPlaylistConfig
|
|
{
|
|
Name = request.Name,
|
|
Id = request.SpotifyPlaylistId,
|
|
LocalTracksPosition = LocalTracksPosition.First // Use Spotify order
|
|
});
|
|
|
|
// Convert to JSON format for env var
|
|
var playlistsJson = JsonSerializer.Serialize(
|
|
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
|
);
|
|
|
|
// Update .env file
|
|
var updateRequest = new ConfigUpdateRequest
|
|
{
|
|
Updates = new Dictionary<string, string>
|
|
{
|
|
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
|
}
|
|
};
|
|
|
|
return await UpdateConfig(updateRequest);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unlink a playlist (remove from configuration)
|
|
/// </summary>
|
|
[HttpDelete("jellyfin/playlists/{name}/unlink")]
|
|
public async Task<IActionResult> UnlinkPlaylist(string name)
|
|
{
|
|
var decodedName = Uri.UnescapeDataString(name);
|
|
return await RemovePlaylist(decodedName);
|
|
}
|
|
|
|
private string GetJellyfinAuthHeader()
|
|
{
|
|
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
|
}
|
|
|
|
private static string MaskValue(string? value, int showLast = 0)
|
|
{
|
|
if (string.IsNullOrEmpty(value)) return "(not set)";
|
|
if (value.Length <= showLast) return "***";
|
|
return showLast > 0 ? "***" + value[^showLast..] : value[..8] + "...";
|
|
}
|
|
|
|
private static string SanitizeFileName(string name)
|
|
{
|
|
return string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
|
|
}
|
|
|
|
private static bool IsValidEnvKey(string key)
|
|
{
|
|
// Only allow alphanumeric, underscore, and must start with letter/underscore
|
|
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
|
|
}
|
|
}
|
|
|
|
public class ConfigUpdateRequest
|
|
{
|
|
public Dictionary<string, string> Updates { get; set; } = new();
|
|
}
|
|
|
|
public class AddPlaylistRequest
|
|
{
|
|
public string Name { get; set; } = string.Empty;
|
|
public string SpotifyId { get; set; } = string.Empty;
|
|
public string LocalTracksPosition { get; set; } = "first";
|
|
}
|
|
|
|
public class LinkPlaylistRequest
|
|
{
|
|
public string Name { get; set; } = string.Empty;
|
|
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
|
}
|