mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
- Fixed external track detection (check for provider prefix in ID) - Added genre support to MusicBrainz service (inc=genres+tags) - Created GenreEnrichmentService for async genre lookup with caching - Show provider name and search query for external tracks in admin UI - Display search query that will be used for external track streaming - Aggregate playlist genres from track genres - All 225 tests passing
1667 lines
69 KiB
C#
1667 lines
69 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 MusicBrainzSettings _musicBrainzSettings;
|
|
private readonly SpotifyApiClient _spotifyClient;
|
|
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
|
private readonly SpotifyTrackMatchingService? _matchingService;
|
|
private readonly RedisCacheService _cache;
|
|
private readonly HttpClient _jellyfinHttpClient;
|
|
private readonly IWebHostEnvironment _environment;
|
|
private readonly string _envFilePath;
|
|
private const string CacheDirectory = "/app/cache/spotify";
|
|
|
|
public AdminController(
|
|
ILogger<AdminController> logger,
|
|
IConfiguration configuration,
|
|
IWebHostEnvironment environment,
|
|
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
|
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
|
IOptions<JellyfinSettings> jellyfinSettings,
|
|
IOptions<DeezerSettings> deezerSettings,
|
|
IOptions<QobuzSettings> qobuzSettings,
|
|
IOptions<SquidWTFSettings> squidWtfSettings,
|
|
IOptions<MusicBrainzSettings> musicBrainzSettings,
|
|
SpotifyApiClient spotifyClient,
|
|
SpotifyPlaylistFetcher playlistFetcher,
|
|
RedisCacheService cache,
|
|
IHttpClientFactory httpClientFactory,
|
|
SpotifyTrackMatchingService? matchingService = null)
|
|
{
|
|
_logger = logger;
|
|
_configuration = configuration;
|
|
_environment = environment;
|
|
_spotifyApiSettings = spotifyApiSettings.Value;
|
|
_spotifyImportSettings = spotifyImportSettings.Value;
|
|
_jellyfinSettings = jellyfinSettings.Value;
|
|
_deezerSettings = deezerSettings.Value;
|
|
_qobuzSettings = qobuzSettings.Value;
|
|
_squidWtfSettings = squidWtfSettings.Value;
|
|
_musicBrainzSettings = musicBrainzSettings.Value;
|
|
_spotifyClient = spotifyClient;
|
|
_playlistFetcher = playlistFetcher;
|
|
_matchingService = matchingService;
|
|
_cache = cache;
|
|
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
|
|
|
// .env file path is always /app/.env in Docker (mounted from host)
|
|
// In development, it's in the parent directory of ContentRootPath
|
|
_envFilePath = _environment.IsDevelopment()
|
|
? Path.Combine(_environment.ContentRootPath, "..", ".env")
|
|
: "/app/.env";
|
|
|
|
_logger.LogInformation("Admin controller initialized. .env path: {EnvFilePath}", _envFilePath);
|
|
}
|
|
|
|
/// <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,
|
|
["jellyfinId"] = config.JellyfinId,
|
|
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
|
["trackCount"] = 0,
|
|
["localTracks"] = 0,
|
|
["externalTracks"] = 0,
|
|
["lastFetched"] = null as DateTime?,
|
|
["cacheAge"] = null as string
|
|
};
|
|
|
|
// Get Spotify playlist track count from cache
|
|
var cacheFilePath = Path.Combine(CacheDirectory, $"{SanitizeFileName(config.Name)}_spotify.json");
|
|
int spotifyTrackCount = 0;
|
|
|
|
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))
|
|
{
|
|
spotifyTrackCount = tracks.GetArrayLength();
|
|
playlistInfo["trackCount"] = spotifyTrackCount;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Get current Jellyfin playlist track count
|
|
if (!string.IsNullOrEmpty(config.JellyfinId))
|
|
{
|
|
try
|
|
{
|
|
// Jellyfin requires UserId parameter to fetch playlist items
|
|
var userId = _jellyfinSettings.UserId;
|
|
|
|
// If no user configured, try to get the first user
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
var usersResponse = await _jellyfinHttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users")
|
|
{
|
|
Headers = { { "X-Emby-Authorization", GetJellyfinAuthHeader() } }
|
|
});
|
|
|
|
if (usersResponse.IsSuccessStatusCode)
|
|
{
|
|
var usersJson = await usersResponse.Content.ReadAsStringAsync();
|
|
using var usersDoc = JsonDocument.Parse(usersJson);
|
|
if (usersDoc.RootElement.GetArrayLength() > 0)
|
|
{
|
|
userId = usersDoc.RootElement[0].GetProperty("Id").GetString();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
_logger.LogWarning("No user ID available to fetch playlist items for {Name}", config.Name);
|
|
}
|
|
else
|
|
{
|
|
var url = $"{_jellyfinSettings.Url}/Playlists/{config.JellyfinId}/Items?UserId={userId}&Fields=Path";
|
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
|
|
|
_logger.LogDebug("Fetching Jellyfin playlist items for {Name} from {Url}", config.Name, url);
|
|
|
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
var jellyfinJson = await response.Content.ReadAsStringAsync();
|
|
using var jellyfinDoc = JsonDocument.Parse(jellyfinJson);
|
|
|
|
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
|
|
{
|
|
var localCount = 0;
|
|
var externalMatchedCount = 0;
|
|
|
|
// Count local vs external tracks
|
|
foreach (var item in items.EnumerateArray())
|
|
{
|
|
// External tracks from allstarr have ExternalProvider in ProviderIds
|
|
// Local tracks have real filesystem paths
|
|
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
|
|
pathProp.ValueKind == JsonValueKind.String &&
|
|
!string.IsNullOrEmpty(pathProp.GetString());
|
|
|
|
// Check if it's an external track by looking at the ID format
|
|
// External tracks have IDs like "deezer:123456" or "qobuz:123456"
|
|
var isExternal = false;
|
|
if (item.TryGetProperty("Id", out var idProp))
|
|
{
|
|
var id = idProp.GetString() ?? "";
|
|
isExternal = id.Contains(":"); // External IDs contain provider prefix
|
|
}
|
|
|
|
if (isExternal)
|
|
{
|
|
externalMatchedCount++;
|
|
}
|
|
else if (hasPath)
|
|
{
|
|
localCount++;
|
|
}
|
|
}
|
|
|
|
var totalInJellyfin = localCount + externalMatchedCount;
|
|
var externalMissingCount = Math.Max(0, spotifyTrackCount - totalInJellyfin);
|
|
|
|
playlistInfo["localTracks"] = localCount;
|
|
playlistInfo["externalMatched"] = externalMatchedCount;
|
|
playlistInfo["externalMissing"] = externalMissingCount;
|
|
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
|
|
playlistInfo["totalInJellyfin"] = totalInJellyfin;
|
|
|
|
_logger.LogDebug("Playlist {Name}: {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing",
|
|
config.Name, spotifyTrackCount, localCount, externalMatchedCount, externalMissingCount);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("No Items property in Jellyfin response for {Name}", config.Name);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Failed to get Jellyfin playlist {Name}: {StatusCode}",
|
|
config.Name, response.StatusCode);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("Playlist {Name} has no JellyfinId configured", config.Name);
|
|
}
|
|
|
|
playlists.Add(playlistInfo);
|
|
}
|
|
|
|
return Ok(new { playlists });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get tracks for a specific playlist with local/external status
|
|
/// </summary>
|
|
[HttpGet("playlists/{name}/tracks")]
|
|
public async Task<IActionResult> GetPlaylistTracks(string name)
|
|
{
|
|
var decodedName = Uri.UnescapeDataString(name);
|
|
|
|
// Get Spotify tracks
|
|
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
|
|
|
|
// Get the playlist config to find Jellyfin ID
|
|
var playlistConfig = _spotifyImportSettings.Playlists
|
|
.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
|
|
|
|
var tracksWithStatus = new List<object>();
|
|
|
|
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
|
{
|
|
// Get existing tracks from Jellyfin to determine local/external status
|
|
var userId = _jellyfinSettings.UserId;
|
|
if (!string.IsNullOrEmpty(userId))
|
|
{
|
|
try
|
|
{
|
|
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistConfig.JellyfinId}/Items?UserId={userId}";
|
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
|
|
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
|
if (response.IsSuccessStatusCode)
|
|
{
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
// Build list of local tracks (match by name only - no Spotify IDs!)
|
|
var localTracks = new List<(string Title, string Artist)>();
|
|
if (doc.RootElement.TryGetProperty("Items", out var items))
|
|
{
|
|
foreach (var item in items.EnumerateArray())
|
|
{
|
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
|
var artist = "";
|
|
|
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
|
{
|
|
artist = artistsEl[0].GetString() ?? "";
|
|
}
|
|
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
|
{
|
|
artist = albumArtistEl.GetString() ?? "";
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(title))
|
|
{
|
|
localTracks.Add((title, artist));
|
|
}
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
|
|
localTracks.Count, decodedName);
|
|
|
|
// Match Spotify tracks to local tracks by name (fuzzy matching)
|
|
foreach (var track in spotifyTracks)
|
|
{
|
|
var isLocal = false;
|
|
|
|
if (localTracks.Count > 0)
|
|
{
|
|
var bestMatch = localTracks
|
|
.Select(local => new
|
|
{
|
|
Local = local,
|
|
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
|
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
|
})
|
|
.Select(x => new
|
|
{
|
|
x.Local,
|
|
x.TitleScore,
|
|
x.ArtistScore,
|
|
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
|
})
|
|
.OrderByDescending(x => x.TotalScore)
|
|
.FirstOrDefault();
|
|
|
|
// Use 70% threshold (same as playback matching)
|
|
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
|
{
|
|
isLocal = true;
|
|
}
|
|
}
|
|
|
|
tracksWithStatus.Add(new
|
|
{
|
|
position = track.Position,
|
|
title = track.Title,
|
|
artists = track.Artists,
|
|
album = track.Album,
|
|
isrc = track.Isrc,
|
|
spotifyId = track.SpotifyId,
|
|
durationMs = track.DurationMs,
|
|
albumArtUrl = track.AlbumArtUrl,
|
|
isLocal = isLocal,
|
|
// For external tracks, show what will be searched
|
|
externalProvider = isLocal ? null : _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
|
|
searchQuery = isLocal ? null : $"{track.Title} {track.PrimaryArtist}"
|
|
});
|
|
}
|
|
|
|
return Ok(new
|
|
{
|
|
name = decodedName,
|
|
trackCount = spotifyTracks.Count,
|
|
tracks = tracksWithStatus
|
|
});
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to get local track status for {Playlist}", decodedName);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: return tracks without local/external status
|
|
return Ok(new
|
|
{
|
|
name = decodedName,
|
|
trackCount = spotifyTracks.Count,
|
|
tracks = spotifyTracks.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,
|
|
isLocal = (bool?)null, // Unknown
|
|
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
|
|
searchQuery = $"{t.Title} {t.PrimaryArtist}"
|
|
})
|
|
});
|
|
}
|
|
|
|
/// <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>
|
|
/// Trigger track matching for a specific playlist
|
|
/// </summary>
|
|
[HttpPost("playlists/{name}/match")]
|
|
public async Task<IActionResult> MatchPlaylistTracks(string name)
|
|
{
|
|
var decodedName = Uri.UnescapeDataString(name);
|
|
_logger.LogInformation("Manual track matching triggered for playlist: {Name}", decodedName);
|
|
|
|
if (_matchingService == null)
|
|
{
|
|
return BadRequest(new { error = "Track matching service is not available" });
|
|
}
|
|
|
|
try
|
|
{
|
|
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
|
return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to trigger track matching for {Name}", decodedName);
|
|
return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Search Jellyfin library for tracks (for manual mapping)
|
|
/// </summary>
|
|
[HttpGet("jellyfin/search")]
|
|
public async Task<IActionResult> SearchJellyfinTracks([FromQuery] string query)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(query))
|
|
{
|
|
return BadRequest(new { error = "Query is required" });
|
|
}
|
|
|
|
try
|
|
{
|
|
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
|
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
|
|
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" });
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
var tracks = new List<object>();
|
|
if (doc.RootElement.TryGetProperty("Items", out var items))
|
|
{
|
|
foreach (var item in items.EnumerateArray())
|
|
{
|
|
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
|
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
|
var artist = "";
|
|
|
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
|
{
|
|
artist = artistsEl[0].GetString() ?? "";
|
|
}
|
|
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
|
{
|
|
artist = albumArtistEl.GetString() ?? "";
|
|
}
|
|
|
|
tracks.Add(new { id, title, artist, album });
|
|
}
|
|
}
|
|
|
|
return Ok(new { tracks });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to search Jellyfin tracks");
|
|
return StatusCode(500, new { error = "Search failed" });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get track details by Jellyfin ID (for URL-based mapping)
|
|
/// </summary>
|
|
[HttpGet("jellyfin/track/{id}")]
|
|
public async Task<IActionResult> GetJellyfinTrack(string id)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(id))
|
|
{
|
|
return BadRequest(new { error = "Track ID is required" });
|
|
}
|
|
|
|
try
|
|
{
|
|
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
|
|
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
return StatusCode((int)response.StatusCode, new { error = "Track not found" });
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
var item = doc.RootElement;
|
|
var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
|
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
|
var artist = "";
|
|
|
|
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
|
{
|
|
artist = artistsEl[0].GetString() ?? "";
|
|
}
|
|
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
|
{
|
|
artist = albumArtistEl.GetString() ?? "";
|
|
}
|
|
|
|
return Ok(new { id = trackId, title, artist, album });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to get Jellyfin track {Id}", id);
|
|
return StatusCode(500, new { error = "Failed to get track details" });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Save manual track mapping
|
|
/// </summary>
|
|
[HttpPost("playlists/{name}/map")]
|
|
public async Task<IActionResult> SaveManualMapping(string name, [FromBody] ManualMappingRequest request)
|
|
{
|
|
var decodedName = Uri.UnescapeDataString(name);
|
|
|
|
if (string.IsNullOrWhiteSpace(request.SpotifyId) || string.IsNullOrWhiteSpace(request.JellyfinId))
|
|
{
|
|
return BadRequest(new { error = "SpotifyId and JellyfinId are required" });
|
|
}
|
|
|
|
try
|
|
{
|
|
// Store mapping in cache (you could also persist to a file)
|
|
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
|
await _cache.SetAsync(mappingKey, request.JellyfinId, TimeSpan.FromDays(365)); // Long TTL
|
|
|
|
_logger.LogInformation("Manual mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
|
decodedName, request.SpotifyId, request.JellyfinId);
|
|
|
|
// Clear the matched tracks cache to force re-matching
|
|
var cacheKey = $"spotify:matched:{decodedName}";
|
|
await _cache.DeleteAsync(cacheKey);
|
|
|
|
return Ok(new { message = "Mapping saved successfully" });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to save manual mapping");
|
|
return StatusCode(500, new { error = "Failed to save mapping" });
|
|
}
|
|
}
|
|
|
|
public class ManualMappingRequest
|
|
{
|
|
public string SpotifyId { get; set; } = "";
|
|
public string JellyfinId { get; set; } = "";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Trigger track matching for all playlists
|
|
/// </summary>
|
|
[HttpPost("playlists/match-all")]
|
|
public async Task<IActionResult> MatchAllPlaylistTracks()
|
|
{
|
|
_logger.LogInformation("Manual track matching triggered for all playlists");
|
|
|
|
if (_matchingService == null)
|
|
{
|
|
return BadRequest(new { error = "Track matching service is not available" });
|
|
}
|
|
|
|
try
|
|
{
|
|
await _matchingService.TriggerMatchingAsync();
|
|
return Ok(new { message = "Track matching triggered for all playlists", timestamp = DateTime.UtcNow });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to trigger track matching for all playlists");
|
|
return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <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),
|
|
userId = _jellyfinSettings.UserId ?? "(not set)",
|
|
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"
|
|
},
|
|
musicBrainz = new
|
|
{
|
|
enabled = _musicBrainzSettings.Enabled,
|
|
username = _musicBrainzSettings.Username ?? "(not set)",
|
|
password = MaskValue(_musicBrainzSettings.Password),
|
|
baseUrl = _musicBrainzSettings.BaseUrl,
|
|
rateLimitMs = _musicBrainzSettings.RateLimitMs
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <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
|
|
{
|
|
// Check if .env file exists
|
|
if (!System.IO.File.Exists(_envFilePath))
|
|
{
|
|
_logger.LogWarning(".env file not found at {Path}, creating new file", _envFilePath);
|
|
}
|
|
|
|
// 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.TrimStart().StartsWith('#'))
|
|
continue;
|
|
|
|
var eqIndex = line.IndexOf('=');
|
|
if (eqIndex > 0)
|
|
{
|
|
var key = line[..eqIndex].Trim();
|
|
var value = line[(eqIndex + 1)..].Trim();
|
|
envContent[key] = value;
|
|
}
|
|
}
|
|
_logger.LogInformation("Loaded {Count} existing env vars from {Path}", envContent.Count, _envFilePath);
|
|
}
|
|
|
|
// Apply updates with validation
|
|
var appliedUpdates = new List<string>();
|
|
foreach (var (key, value) in request.Updates)
|
|
{
|
|
// Validate key format
|
|
if (!IsValidEnvKey(key))
|
|
{
|
|
_logger.LogWarning("Invalid env key rejected: {Key}", key);
|
|
return BadRequest(new { error = $"Invalid environment variable key: {key}" });
|
|
}
|
|
|
|
envContent[key] = value;
|
|
appliedUpdates.Add(key);
|
|
_logger.LogInformation(" Setting {Key} = {Value}", key,
|
|
key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL")
|
|
? "***" + (value.Length > 8 ? value[^8..] : "")
|
|
: value);
|
|
|
|
// 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 at {Path}", _envFilePath);
|
|
|
|
return Ok(new
|
|
{
|
|
message = "Configuration updated. Restart container to apply changes.",
|
|
updatedKeys = appliedUpdates,
|
|
requiresRestart = true,
|
|
envFilePath = _envFilePath
|
|
});
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
_logger.LogError(ex, "Permission denied writing to .env file at {Path}", _envFilePath);
|
|
return StatusCode(500, new {
|
|
error = "Permission denied",
|
|
details = "Cannot write to .env file. Check file permissions and volume mount.",
|
|
path = _envFilePath
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to update configuration at {Path}", _envFilePath);
|
|
return StatusCode(500, new {
|
|
error = "Failed to update configuration",
|
|
details = ex.Message,
|
|
path = _envFilePath
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
|
|
// Read current playlists from .env file (not stale in-memory config)
|
|
var currentPlaylists = await ReadPlaylistsFromEnvFile();
|
|
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: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
|
var playlistsJson = JsonSerializer.Serialize(
|
|
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, 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;
|
|
var clearedRedisKeys = 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 ALL Redis cache keys for Spotify playlists
|
|
// This includes matched tracks, ordered tracks, missing tracks, playlist items, etc.
|
|
foreach (var playlist in _spotifyImportSettings.Playlists)
|
|
{
|
|
var keysToDelete = new[]
|
|
{
|
|
$"spotify:playlist:{playlist.Name}",
|
|
$"spotify:missing:{playlist.Name}",
|
|
$"spotify:matched:{playlist.Name}",
|
|
$"spotify:matched:ordered:{playlist.Name}",
|
|
$"spotify:playlist:items:{playlist.Name}" // NEW: Clear file-backed playlist items cache
|
|
};
|
|
|
|
foreach (var key in keysToDelete)
|
|
{
|
|
if (await _cache.DeleteAsync(key))
|
|
{
|
|
clearedRedisKeys++;
|
|
_logger.LogInformation("Cleared Redis cache key: {Key}", key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear all search cache keys (pattern-based deletion)
|
|
var searchKeysDeleted = await _cache.DeleteByPatternAsync("search:*");
|
|
clearedRedisKeys += searchKeysDeleted;
|
|
|
|
// Clear all image cache keys (pattern-based deletion)
|
|
var imageKeysDeleted = await _cache.DeleteByPatternAsync("image:*");
|
|
clearedRedisKeys += imageKeysDeleted;
|
|
|
|
_logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys (including {SearchKeys} search keys, {ImageKeys} image keys)",
|
|
clearedFiles, clearedRedisKeys, searchKeysDeleted, imageKeysDeleted);
|
|
|
|
return Ok(new {
|
|
message = "Cache cleared successfully",
|
|
filesDeleted = clearedFiles,
|
|
redisKeysDeleted = clearedRedisKeys
|
|
});
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
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 filter
|
|
var url = $"{_jellyfinSettings.Url}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ProviderIds,ChildCount,RecursiveItemCount,SongCount";
|
|
|
|
if (!string.IsNullOrEmpty(userId))
|
|
{
|
|
url += $"&UserId={userId}";
|
|
}
|
|
|
|
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>();
|
|
|
|
// Read current playlists from .env file for accurate linked status
|
|
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
|
|
|
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 by Jellyfin ID
|
|
var configuredPlaylist = configuredPlaylists
|
|
.FirstOrDefault(p => p.JellyfinId.Equals(id, StringComparison.OrdinalIgnoreCase));
|
|
var isConfigured = configuredPlaylist != null;
|
|
var linkedSpotifyId = configuredPlaylist?.Id;
|
|
|
|
// Fetch track details to categorize local vs external
|
|
var trackStats = await GetPlaylistTrackStats(id!);
|
|
|
|
playlists.Add(new
|
|
{
|
|
id,
|
|
name,
|
|
trackCount = childCount,
|
|
linkedSpotifyId,
|
|
isConfigured,
|
|
localTracks = trackStats.LocalTracks,
|
|
externalTracks = trackStats.ExternalTracks,
|
|
externalAvailable = trackStats.ExternalAvailable
|
|
});
|
|
}
|
|
}
|
|
|
|
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>
|
|
/// Get track statistics for a playlist (local vs external)
|
|
/// </summary>
|
|
private async Task<(int LocalTracks, int ExternalTracks, int ExternalAvailable)> GetPlaylistTrackStats(string playlistId)
|
|
{
|
|
try
|
|
{
|
|
// Jellyfin requires a UserId to fetch playlist items
|
|
// We'll use the first available user if not specified
|
|
var userId = _jellyfinSettings.UserId;
|
|
|
|
// If no user configured, try to get the first user
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
var usersResponse = await _jellyfinHttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users")
|
|
{
|
|
Headers = { { "X-Emby-Authorization", GetJellyfinAuthHeader() } }
|
|
});
|
|
|
|
if (usersResponse.IsSuccessStatusCode)
|
|
{
|
|
var usersJson = await usersResponse.Content.ReadAsStringAsync();
|
|
using var usersDoc = JsonDocument.Parse(usersJson);
|
|
if (usersDoc.RootElement.GetArrayLength() > 0)
|
|
{
|
|
userId = usersDoc.RootElement[0].GetProperty("Id").GetString();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
_logger.LogWarning("No user ID available to fetch playlist items for {PlaylistId}", playlistId);
|
|
return (0, 0, 0);
|
|
}
|
|
|
|
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistId}/Items?UserId={userId}&Fields=Path";
|
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
|
|
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogWarning("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode);
|
|
return (0, 0, 0);
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
using var doc = JsonDocument.Parse(json);
|
|
|
|
var localTracks = 0;
|
|
var externalTracks = 0;
|
|
var externalAvailable = 0;
|
|
|
|
if (doc.RootElement.TryGetProperty("Items", out var items))
|
|
{
|
|
foreach (var item in items.EnumerateArray())
|
|
{
|
|
// Simpler detection: Check if Path exists and is not empty
|
|
// External tracks from allstarr won't have a Path property
|
|
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
|
|
pathProp.ValueKind == JsonValueKind.String &&
|
|
!string.IsNullOrEmpty(pathProp.GetString());
|
|
|
|
if (hasPath)
|
|
{
|
|
var pathStr = pathProp.GetString()!;
|
|
// Check if it's a real file path (not a URL)
|
|
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
|
|
{
|
|
localTracks++;
|
|
}
|
|
else
|
|
{
|
|
// It's a URL or external source
|
|
externalTracks++;
|
|
externalAvailable++;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// No path means it's external
|
|
externalTracks++;
|
|
externalAvailable++;
|
|
}
|
|
}
|
|
|
|
_logger.LogDebug("Playlist {PlaylistId} stats: {Local} local, {External} external",
|
|
playlistId, localTracks, externalTracks);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("No Items property in playlist response for {PlaylistId}", playlistId);
|
|
}
|
|
|
|
return (localTracks, externalTracks, externalAvailable);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to get track stats for playlist {PlaylistId}", playlistId);
|
|
return (0, 0, 0);
|
|
}
|
|
}
|
|
|
|
/// <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);
|
|
|
|
// Read current playlists from .env file (not in-memory config which is stale)
|
|
var currentPlaylists = await ReadPlaylistsFromEnvFile();
|
|
|
|
// Check if already configured by Jellyfin ID
|
|
var existingByJellyfinId = currentPlaylists
|
|
.FirstOrDefault(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (existingByJellyfinId != null)
|
|
{
|
|
return BadRequest(new { error = $"This Jellyfin playlist is already linked to '{existingByJellyfinId.Name}'" });
|
|
}
|
|
|
|
// Check if already configured by name
|
|
var existingByName = currentPlaylists
|
|
.FirstOrDefault(p => p.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (existingByName != null)
|
|
{
|
|
return BadRequest(new { error = $"Playlist name '{request.Name}' is already configured" });
|
|
}
|
|
|
|
// Add the playlist to configuration
|
|
currentPlaylists.Add(new SpotifyPlaylistConfig
|
|
{
|
|
Name = request.Name,
|
|
Id = request.SpotifyPlaylistId,
|
|
JellyfinId = jellyfinPlaylistId,
|
|
LocalTracksPosition = LocalTracksPosition.First // Use Spotify order
|
|
});
|
|
|
|
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
|
var playlistsJson = JsonSerializer.Serialize(
|
|
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, 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}\"";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read current playlists from .env file (not stale in-memory config)
|
|
/// </summary>
|
|
private async Task<List<SpotifyPlaylistConfig>> ReadPlaylistsFromEnvFile()
|
|
{
|
|
var playlists = new List<SpotifyPlaylistConfig>();
|
|
|
|
if (!System.IO.File.Exists(_envFilePath))
|
|
{
|
|
return playlists;
|
|
}
|
|
|
|
try
|
|
{
|
|
var lines = await System.IO.File.ReadAllLinesAsync(_envFilePath);
|
|
foreach (var line in lines)
|
|
{
|
|
if (line.TrimStart().StartsWith("SPOTIFY_IMPORT_PLAYLISTS="))
|
|
{
|
|
var value = line.Substring(line.IndexOf('=') + 1).Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(value) || value == "[]")
|
|
{
|
|
return playlists;
|
|
}
|
|
|
|
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
|
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
|
|
if (playlistArrays != null)
|
|
{
|
|
foreach (var arr in playlistArrays)
|
|
{
|
|
if (arr.Length >= 2)
|
|
{
|
|
playlists.Add(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
|
|
});
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to read playlists from .env file");
|
|
}
|
|
|
|
return playlists;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Export .env file for backup/transfer
|
|
/// </summary>
|
|
[HttpGet("export-env")]
|
|
public IActionResult ExportEnv()
|
|
{
|
|
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", details = ex.Message });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Import .env file from upload
|
|
/// </summary>
|
|
[HttpPost("import-env")]
|
|
public async Task<IActionResult> ImportEnv([FromForm] IFormFile file)
|
|
{
|
|
if (file == null || file.Length == 0)
|
|
{
|
|
return BadRequest(new { error = "No file provided" });
|
|
}
|
|
|
|
if (!file.FileName.EndsWith(".env"))
|
|
{
|
|
return BadRequest(new { error = "File must be a .env file" });
|
|
}
|
|
|
|
try
|
|
{
|
|
// Read uploaded file
|
|
using var reader = new StreamReader(file.OpenReadStream());
|
|
var content = await reader.ReadToEndAsync();
|
|
|
|
// Validate it's a valid .env file (basic check)
|
|
if (string.IsNullOrWhiteSpace(content))
|
|
{
|
|
return BadRequest(new { error = ".env file is empty" });
|
|
}
|
|
|
|
// Backup existing .env
|
|
if (System.IO.File.Exists(_envFilePath))
|
|
{
|
|
var backupPath = $"{_envFilePath}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}";
|
|
System.IO.File.Copy(_envFilePath, backupPath, true);
|
|
_logger.LogInformation("Backed up existing .env to {BackupPath}", backupPath);
|
|
}
|
|
|
|
// Write new .env file
|
|
await System.IO.File.WriteAllTextAsync(_envFilePath, content);
|
|
|
|
_logger.LogInformation(".env file imported successfully");
|
|
|
|
return Ok(new
|
|
{
|
|
success = true,
|
|
message = ".env file imported successfully. Restart the application for changes to take effect."
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to import .env file");
|
|
return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message });
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|