mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
61 Commits
bbb0d9bb73
...
4229924f61
| Author | SHA1 | Date | |
|---|---|---|---|
|
4229924f61
|
|||
|
a2a48f6ed9
|
|||
|
c7785b6488
|
|||
|
af03a53af5
|
|||
|
c1c2212b53
|
|||
|
17560f0d34
|
|||
|
6ab314f603
|
|||
|
64ac09becf
|
|||
|
a0bbb7cd4c
|
|||
|
4bd478e85c
|
|||
|
f7a88791e8
|
|||
|
9f8b3d65fb
|
|||
|
1a1f9e136f
|
|||
|
48f69b766d
|
|||
|
d619881b8e
|
|||
|
dccdb7b744
|
|||
|
f240423822
|
|||
|
1492778b14
|
|||
|
08af650d6c
|
|||
|
c44be48eb9
|
|||
|
b16d16c9c9
|
|||
|
e51d569d79
|
|||
|
363c9e6f1b
|
|||
|
f813fe9eeb
|
|||
|
ef0ee65160
|
|||
|
b3bfa16b93
|
|||
|
aa9b5c874d
|
|||
|
e3546425eb
|
|||
|
5646aa07ea
|
|||
|
7cdf7e3806
|
|||
|
fe9c1e17be
|
|||
|
63324def62
|
|||
|
ff72ae2395
|
|||
|
1a3134083b
|
|||
|
bd64f437cd
|
|||
|
5606706dc8
|
|||
|
79a9e4063d
|
|||
|
c33c85455f
|
|||
|
5af2bb1113
|
|||
|
2c1297ebec
|
|||
|
df7f11e769
|
|||
|
75c7acb745
|
|||
|
c7f6783fa2
|
|||
|
4c6406ef8f
|
|||
|
3ddf51924b
|
|||
|
3826f29019
|
|||
|
4036c739a3
|
|||
|
b7379e2fd4
|
|||
|
c9895f6d1a
|
|||
|
71c4241a8a
|
|||
|
ffed9a67f3
|
|||
|
a8d04b225b
|
|||
|
6abf0e0717
|
|||
|
8e7fc8b4ef
|
|||
|
b2c28d10f1
|
|||
|
a335997196
|
|||
|
590f8f76cb
|
|||
|
1532d74a20
|
|||
|
f5ce355747
|
|||
|
494b4bbbc2
|
|||
|
375e1894f3
|
71
.env.example
71
.env.example
@@ -126,26 +126,57 @@ SPOTIFY_IMPORT_SYNC_START_MINUTE=15
|
|||||||
# Example: If plugin runs at 4:15 PM and window is 2 hours, checks from 4:00 PM to 6:00 PM
|
# Example: If plugin runs at 4:15 PM and window is 2 hours, checks from 4:00 PM to 6:00 PM
|
||||||
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
|
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
|
||||||
|
|
||||||
# Playlists configuration (SIMPLE FORMAT - recommended for .env files)
|
# Playlists configuration (JSON ARRAY FORMAT - managed by web UI)
|
||||||
# Comma-separated lists - all three must have the same number of items
|
# Format: [["PlaylistName","SpotifyPlaylistId","first|last"],...]
|
||||||
|
# - PlaylistName: Name as it appears in Jellyfin
|
||||||
|
# - SpotifyPlaylistId: Get from Spotify URL (e.g., 37i9dQZF1DXcBWIGoYBM5M)
|
||||||
|
# Accepts: spotify:playlist:ID, full URL, or just the ID
|
||||||
|
# - first|last: Where to position local tracks (first=local tracks first, last=external tracks first)
|
||||||
#
|
#
|
||||||
# 1. Playlist IDs (get from Jellyfin playlist URL: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID)
|
# Example:
|
||||||
SPOTIFY_IMPORT_PLAYLIST_IDS=
|
# SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]]
|
||||||
#
|
#
|
||||||
# 2. Playlist names (as they appear in Jellyfin)
|
# RECOMMENDED: Use the web UI (Link Playlists tab) to manage playlists instead of editing this manually
|
||||||
SPOTIFY_IMPORT_PLAYLIST_NAMES=
|
SPOTIFY_IMPORT_PLAYLISTS=[]
|
||||||
|
|
||||||
|
# ===== SPOTIFY DIRECT API (RECOMMENDED - ENABLES TRACK ORDERING & LYRICS) =====
|
||||||
|
# This is the preferred method for Spotify playlist integration.
|
||||||
|
# Provides: Correct track ordering, ISRC-based exact matching, synchronized lyrics
|
||||||
|
# Does NOT require the Jellyfin Spotify Import plugin (can work standalone)
|
||||||
|
|
||||||
|
# Enable direct Spotify API access (default: false)
|
||||||
|
SPOTIFY_API_ENABLED=false
|
||||||
|
|
||||||
|
# Spotify Client ID from https://developer.spotify.com/dashboard
|
||||||
|
# Create an app in the Spotify Developer Dashboard to get this
|
||||||
|
SPOTIFY_API_CLIENT_ID=
|
||||||
|
|
||||||
|
# Spotify Client Secret (optional - only needed for certain OAuth flows)
|
||||||
|
SPOTIFY_API_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
|
||||||
|
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
|
||||||
|
# via session cookie because they're not accessible through the official API.
|
||||||
#
|
#
|
||||||
# 3. Local track positions (optional - defaults to "first" if not specified)
|
# To get your sp_dc cookie:
|
||||||
# - "first": Local tracks appear first, external tracks at the end
|
# 1. Open https://open.spotify.com in your browser and log in
|
||||||
# - "last": External tracks appear first, local tracks at the end
|
# 2. Open DevTools (F12) → Application → Cookies → https://open.spotify.com
|
||||||
SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS=
|
# 3. Find the cookie named "sp_dc" and copy its value
|
||||||
#
|
# 4. Note: This cookie expires periodically (typically every few months)
|
||||||
# Example with 4 playlists:
|
SPOTIFY_API_SESSION_COOKIE=
|
||||||
# SPOTIFY_IMPORT_PLAYLIST_IDS=4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0,8203ce3be9b0053b122190eb23bac7ea,7c2b218bd69b00e24c986363ba71852f
|
|
||||||
# SPOTIFY_IMPORT_PLAYLIST_NAMES=Discover Weekly,Release Radar,Today's Top Hits,On Repeat
|
# Date when the session cookie was set (ISO 8601 format)
|
||||||
# SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS=first,first,last,first
|
# Automatically set by the web UI when you update the cookie
|
||||||
#
|
# Used to track cookie age and warn when approaching expiration (~1 year)
|
||||||
# Advanced: JSON array format (use only if you can't use the simple format above)
|
SPOTIFY_API_SESSION_COOKIE_SET_DATE=
|
||||||
# Format: [["PlaylistName","JellyfinPlaylistId","first|last"],...]
|
|
||||||
# Note: This format may not work in .env files due to Docker Compose limitations
|
# Cache duration for playlist data in minutes (default: 60)
|
||||||
# SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","4383a46d8bcac3be2ef9385053ea18df","first"],["Release Radar","ba50e26c867ec9d57ab2f7bf24cfd6b0","last"]]
|
# Release Radar updates weekly, Discover Weekly updates Mondays
|
||||||
|
SPOTIFY_API_CACHE_DURATION_MINUTES=60
|
||||||
|
|
||||||
|
# Rate limit delay between API requests in milliseconds (default: 100)
|
||||||
|
SPOTIFY_API_RATE_LIMIT_DELAY_MS=100
|
||||||
|
|
||||||
|
# Prefer ISRC matching over fuzzy title/artist matching (default: true)
|
||||||
|
# ISRC provides exact track identification across different streaming services
|
||||||
|
SPOTIFY_API_PREFER_ISRC_MATCHING=true
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ RUN mkdir -p /app/downloads
|
|||||||
|
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
# Only expose the main proxy port (8080)
|
||||||
|
# Admin UI runs on 5275 but is NOT exposed - access via docker exec or SSH tunnel
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENV ASPNETCORE_URLS=http://+:8080
|
|
||||||
|
|
||||||
ENTRYPOINT ["dotnet", "allstarr.dll"]
|
ENTRYPOINT ["dotnet", "allstarr.dll"]
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -38,6 +38,46 @@ docker-compose logs -f
|
|||||||
|
|
||||||
The proxy will be available at `http://localhost:5274`.
|
The proxy will be available at `http://localhost:5274`.
|
||||||
|
|
||||||
|
## Web Dashboard
|
||||||
|
|
||||||
|
Allstarr includes a web-based dashboard for easy configuration and playlist management, accessible at `http://localhost:5275` (internal port, not exposed through reverse proxy).
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Real-time Status**: Monitor Spotify authentication, cookie age, and playlist sync status
|
||||||
|
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with a few clicks
|
||||||
|
- **Configuration Editor**: Update settings without manually editing .env files
|
||||||
|
- **Track Viewer**: Browse tracks in your configured playlists
|
||||||
|
- **Cache Management**: Clear cached data and restart the container
|
||||||
|
|
||||||
|
### Quick Setup with Web UI
|
||||||
|
|
||||||
|
1. **Access the dashboard** at `http://localhost:5275`
|
||||||
|
2. **Configure Spotify** (Configuration tab):
|
||||||
|
- Enable Spotify API
|
||||||
|
- Add your `sp_dc` cookie from Spotify (see instructions in UI)
|
||||||
|
- The cookie age is automatically tracked
|
||||||
|
3. **Link playlists** (Link Playlists tab):
|
||||||
|
- View all your Jellyfin playlists
|
||||||
|
- Click "Link to Spotify" on any playlist
|
||||||
|
- Paste the Spotify playlist ID, URL, or `spotify:playlist:` URI
|
||||||
|
- Accepts formats like:
|
||||||
|
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
|
||||||
|
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
|
||||||
|
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
|
||||||
|
4. **Restart** to apply changes (button in Configuration tab)
|
||||||
|
|
||||||
|
### Why Two Playlist Tabs?
|
||||||
|
|
||||||
|
- **Link Playlists**: Shows all Jellyfin playlists and lets you connect them to Spotify
|
||||||
|
- **Active Playlists**: Shows which Spotify playlists are currently being monitored and filled with tracks
|
||||||
|
|
||||||
|
### Configuration Persistence
|
||||||
|
|
||||||
|
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
|
||||||
|
|
||||||
|
**Recommended workflow**: Use the `sp_dc` cookie method (simpler and more reliable than the Jellyfin Spotify Import plugin).
|
||||||
|
|
||||||
### Nginx Proxy Setup (Required)
|
### Nginx Proxy Setup (Required)
|
||||||
|
|
||||||
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
|
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
|
||||||
|
|||||||
1512
allstarr/Controllers/AdminController.cs
Normal file
1512
allstarr/Controllers/AdminController.cs
Normal file
@@ -0,0 +1,1512 @@
|
|||||||
|
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 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,
|
||||||
|
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;
|
||||||
|
_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())
|
||||||
|
{
|
||||||
|
// Check if track has a real file path (local) or is external
|
||||||
|
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
|
||||||
|
pathProp.ValueKind == JsonValueKind.String &&
|
||||||
|
!string.IsNullOrEmpty(pathProp.GetString());
|
||||||
|
|
||||||
|
if (hasPath)
|
||||||
|
{
|
||||||
|
var pathStr = pathProp.GetString()!;
|
||||||
|
// Local tracks have filesystem paths starting with / or containing :\
|
||||||
|
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
|
||||||
|
{
|
||||||
|
localCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// External track (downloaded from Deezer/Qobuz/etc)
|
||||||
|
externalMatchedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No path means external
|
||||||
|
externalMatchedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// 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"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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, 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}"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var key in keysToDelete)
|
||||||
|
{
|
||||||
|
if (await _cache.DeleteAsync(key))
|
||||||
|
{
|
||||||
|
clearedRedisKeys++;
|
||||||
|
_logger.LogInformation("Cleared Redis cache key: {Key}", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys", clearedFiles, clearedRedisKeys);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -2,14 +2,17 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using allstarr.Models.Domain;
|
using allstarr.Models.Domain;
|
||||||
|
using allstarr.Models.Lyrics;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
|
using allstarr.Models.Spotify;
|
||||||
using allstarr.Services;
|
using allstarr.Services;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using allstarr.Services.Local;
|
using allstarr.Services.Local;
|
||||||
using allstarr.Services.Jellyfin;
|
using allstarr.Services.Jellyfin;
|
||||||
using allstarr.Services.Subsonic;
|
using allstarr.Services.Subsonic;
|
||||||
using allstarr.Services.Lyrics;
|
using allstarr.Services.Lyrics;
|
||||||
|
using allstarr.Services.Spotify;
|
||||||
using allstarr.Filters;
|
using allstarr.Filters;
|
||||||
|
|
||||||
namespace allstarr.Controllers;
|
namespace allstarr.Controllers;
|
||||||
@@ -24,6 +27,7 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly JellyfinSettings _settings;
|
private readonly JellyfinSettings _settings;
|
||||||
private readonly SpotifyImportSettings _spotifySettings;
|
private readonly SpotifyImportSettings _spotifySettings;
|
||||||
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||||
private readonly IMusicMetadataService _metadataService;
|
private readonly IMusicMetadataService _metadataService;
|
||||||
private readonly ILocalLibraryService _localLibraryService;
|
private readonly ILocalLibraryService _localLibraryService;
|
||||||
private readonly IDownloadService _downloadService;
|
private readonly IDownloadService _downloadService;
|
||||||
@@ -32,12 +36,15 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly JellyfinProxyService _proxyService;
|
private readonly JellyfinProxyService _proxyService;
|
||||||
private readonly JellyfinSessionManager _sessionManager;
|
private readonly JellyfinSessionManager _sessionManager;
|
||||||
private readonly PlaylistSyncService? _playlistSyncService;
|
private readonly PlaylistSyncService? _playlistSyncService;
|
||||||
|
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||||
|
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly ILogger<JellyfinController> _logger;
|
private readonly ILogger<JellyfinController> _logger;
|
||||||
|
|
||||||
public JellyfinController(
|
public JellyfinController(
|
||||||
IOptions<JellyfinSettings> settings,
|
IOptions<JellyfinSettings> settings,
|
||||||
IOptions<SpotifyImportSettings> spotifySettings,
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
|
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
ILocalLibraryService localLibraryService,
|
ILocalLibraryService localLibraryService,
|
||||||
IDownloadService downloadService,
|
IDownloadService downloadService,
|
||||||
@@ -47,10 +54,13 @@ public class JellyfinController : ControllerBase
|
|||||||
JellyfinSessionManager sessionManager,
|
JellyfinSessionManager sessionManager,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
ILogger<JellyfinController> logger,
|
ILogger<JellyfinController> logger,
|
||||||
PlaylistSyncService? playlistSyncService = null)
|
PlaylistSyncService? playlistSyncService = null,
|
||||||
|
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||||
|
SpotifyLyricsService? spotifyLyricsService = null)
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_spotifySettings = spotifySettings.Value;
|
_spotifySettings = spotifySettings.Value;
|
||||||
|
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||||
_metadataService = metadataService;
|
_metadataService = metadataService;
|
||||||
_localLibraryService = localLibraryService;
|
_localLibraryService = localLibraryService;
|
||||||
_downloadService = downloadService;
|
_downloadService = downloadService;
|
||||||
@@ -59,6 +69,8 @@ public class JellyfinController : ControllerBase
|
|||||||
_proxyService = proxyService;
|
_proxyService = proxyService;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
_playlistSyncService = playlistSyncService;
|
_playlistSyncService = playlistSyncService;
|
||||||
|
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||||
|
_spotifyLyricsService = spotifyLyricsService;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
@@ -111,10 +123,23 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
// Build the full endpoint path with query string
|
// Build the full endpoint path with query string
|
||||||
var endpoint = userId != null ? $"Users/{userId}/Items" : "Items";
|
var endpoint = userId != null ? $"Users/{userId}/Items" : "Items";
|
||||||
if (Request.QueryString.HasValue)
|
|
||||||
|
// Ensure MediaSources is included in Fields parameter for bitrate info
|
||||||
|
var queryString = Request.QueryString.Value ?? "";
|
||||||
|
if (!queryString.Contains("Fields=", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
// No Fields parameter, add MediaSources
|
||||||
|
queryString = string.IsNullOrEmpty(queryString)
|
||||||
|
? "?Fields=MediaSources"
|
||||||
|
: $"{queryString}&Fields=MediaSources";
|
||||||
}
|
}
|
||||||
|
else if (!queryString.Contains("MediaSources", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Fields parameter exists but doesn't include MediaSources, append it
|
||||||
|
queryString = $"{queryString},MediaSources";
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = $"{endpoint}{queryString}";
|
||||||
|
|
||||||
var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||||
|
|
||||||
@@ -928,24 +953,19 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (!isExternal)
|
if (!isExternal)
|
||||||
{
|
{
|
||||||
// Redirect to Jellyfin directly for local content images
|
// Proxy image from Jellyfin for local content
|
||||||
var queryString = new List<string>();
|
var (imageBytes, contentType) = await _proxyService.GetImageAsync(
|
||||||
if (maxWidth.HasValue) queryString.Add($"maxWidth={maxWidth.Value}");
|
itemId,
|
||||||
if (maxHeight.HasValue) queryString.Add($"maxHeight={maxHeight.Value}");
|
imageType,
|
||||||
|
maxWidth,
|
||||||
|
maxHeight);
|
||||||
|
|
||||||
var path = $"Items/{itemId}/Images/{imageType}";
|
if (imageBytes == null || contentType == null)
|
||||||
if (imageIndex > 0)
|
|
||||||
{
|
{
|
||||||
path = $"Items/{itemId}/Images/{imageType}/{imageIndex}";
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queryString.Any())
|
return File(imageBytes, contentType);
|
||||||
{
|
|
||||||
path = $"{path}?{string.Join("&", queryString)}";
|
|
||||||
}
|
|
||||||
|
|
||||||
var jellyfinUrl = $"{_settings.Url?.TrimEnd('/')}/{path}";
|
|
||||||
return Redirect(jellyfinUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get external cover art URL
|
// Get external cover art URL
|
||||||
@@ -988,6 +1008,7 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets lyrics for an item.
|
/// Gets lyrics for an item.
|
||||||
|
/// Priority: 1. Jellyfin embedded lyrics, 2. Spotify synced lyrics, 3. LRCLIB
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("Audio/{itemId}/Lyrics")]
|
[HttpGet("Audio/{itemId}/Lyrics")]
|
||||||
[HttpGet("Items/{itemId}/Lyrics")]
|
[HttpGet("Items/{itemId}/Lyrics")]
|
||||||
@@ -1010,19 +1031,21 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (jellyfinLyrics != null && statusCode == 200)
|
if (jellyfinLyrics != null && statusCode == 200)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✓ Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
|
_logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
|
||||||
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
|
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("No embedded lyrics found in Jellyfin, falling back to LRCLIB search");
|
_logger.LogInformation("No embedded lyrics found in Jellyfin, trying Spotify/LRCLIB");
|
||||||
}
|
}
|
||||||
|
|
||||||
// For external tracks or when Jellyfin doesn't have lyrics, search LRCLIB
|
// Get song metadata for lyrics search
|
||||||
Song? song = null;
|
Song? song = null;
|
||||||
|
string? spotifyTrackId = null;
|
||||||
|
|
||||||
if (isExternal)
|
if (isExternal)
|
||||||
{
|
{
|
||||||
song = await _metadataService.GetSongAsync(provider!, externalId!);
|
song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||||
|
// For Deezer tracks, we'll search Spotify by metadata
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1038,6 +1061,15 @@ public class JellyfinController : ControllerBase
|
|||||||
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
|
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
|
||||||
Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0
|
Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check for Spotify ID in provider IDs
|
||||||
|
if (item.RootElement.TryGetProperty("ProviderIds", out var providerIds))
|
||||||
|
{
|
||||||
|
if (providerIds.TryGetProperty("Spotify", out var spotifyId))
|
||||||
|
{
|
||||||
|
spotifyTrackId = spotifyId.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1046,21 +1078,54 @@ public class JellyfinController : ControllerBase
|
|||||||
return NotFound(new { error = "Song not found" });
|
return NotFound(new { error = "Song not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get lyrics from LRCLIB
|
LyricsInfo? lyrics = null;
|
||||||
|
|
||||||
|
// Try Spotify lyrics first (better synced lyrics quality)
|
||||||
|
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Trying Spotify lyrics for: {Artist} - {Title}", song.Artist, song.Title);
|
||||||
|
|
||||||
|
SpotifyLyricsResult? spotifyLyrics = null;
|
||||||
|
|
||||||
|
// If we have a Spotify track ID, use it directly
|
||||||
|
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||||
|
{
|
||||||
|
spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Search by metadata
|
||||||
|
spotifyLyrics = await _spotifyLyricsService.SearchAndGetLyricsAsync(
|
||||||
|
song.Title,
|
||||||
|
song.Artists.Count > 0 ? song.Artists[0] : song.Artist ?? "",
|
||||||
|
song.Album,
|
||||||
|
song.Duration.HasValue ? song.Duration.Value * 1000 : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
|
||||||
|
song.Artist, song.Title, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
||||||
|
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to LRCLIB if no Spotify lyrics
|
||||||
|
if (lyrics == null)
|
||||||
|
{
|
||||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
||||||
song.Artists.Count > 0 ? string.Join(", ", song.Artists) : song.Artist,
|
song.Artists.Count > 0 ? string.Join(", ", song.Artists) : song.Artist,
|
||||||
song.Title);
|
song.Title);
|
||||||
var lyricsService = HttpContext.RequestServices.GetService<LrclibService>();
|
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||||
if (lyricsService == null)
|
if (lrclibService != null)
|
||||||
{
|
{
|
||||||
return NotFound(new { error = "Lyrics service not available" });
|
lyrics = await lrclibService.GetLyricsAsync(
|
||||||
}
|
|
||||||
|
|
||||||
var lyrics = await lyricsService.GetLyricsAsync(
|
|
||||||
song.Title,
|
song.Title,
|
||||||
song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist ?? "" },
|
song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist ?? "" },
|
||||||
song.Album ?? "",
|
song.Album ?? "",
|
||||||
song.Duration ?? 0);
|
song.Duration ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (lyrics == null)
|
if (lyrics == null)
|
||||||
{
|
{
|
||||||
@@ -1947,20 +2012,48 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
// Parse the body to check if it's an external track
|
// Parse the body to check if it's an external track
|
||||||
var doc = JsonDocument.Parse(body);
|
var doc = JsonDocument.Parse(body);
|
||||||
|
string? itemId = null;
|
||||||
|
long? positionTicks = null;
|
||||||
|
|
||||||
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
|
||||||
{
|
{
|
||||||
var itemId = itemIdProp.GetString();
|
itemId = itemIdProp.GetString();
|
||||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId ?? "");
|
}
|
||||||
|
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
|
||||||
|
{
|
||||||
|
positionTicks = posProp.GetInt64();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(itemId))
|
||||||
|
{
|
||||||
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||||
|
|
||||||
if (isExternal)
|
if (isExternal)
|
||||||
{
|
{
|
||||||
// For external tracks, just acknowledge
|
// For external tracks, just acknowledge (no logging to avoid spam)
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log progress for local tracks (only every ~10 seconds to avoid spam)
|
||||||
|
if (positionTicks.HasValue)
|
||||||
|
{
|
||||||
|
var position = TimeSpan.FromTicks(positionTicks.Value);
|
||||||
|
// Only log at 10-second intervals
|
||||||
|
if (position.Seconds % 10 == 0 && position.Milliseconds < 500)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("▶️ Progress: {Position:mm\\:ss} for item {ItemId}", position, itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For local tracks, forward to Jellyfin
|
// For local tracks, forward to Jellyfin
|
||||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
|
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
|
||||||
|
|
||||||
|
if (statusCode != 204 && statusCode != 200)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("⚠️ Progress report returned {StatusCode} for item {ItemId}", statusCode, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -2256,9 +2349,18 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle non-JSON responses (robots.txt, etc.)
|
// Handle non-JSON responses (images, robots.txt, etc.)
|
||||||
if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ||
|
if (path.Contains("/Images/", StringComparison.OrdinalIgnoreCase) ||
|
||||||
path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
|
path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.EndsWith(".m3u8", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.EndsWith(".m3u", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
path.EndsWith(".ts", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var fullPath = path;
|
var fullPath = path;
|
||||||
if (Request.QueryString.HasValue)
|
if (Request.QueryString.HasValue)
|
||||||
@@ -2270,14 +2372,42 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await _proxyService.HttpClient.GetAsync(url);
|
// Forward authentication headers for image requests
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "text/plain";
|
|
||||||
return Content(content, contentType);
|
// Forward auth headers from client
|
||||||
|
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
|
||||||
|
}
|
||||||
|
else if (Request.Headers.TryGetValue("Authorization", out var auth))
|
||||||
|
{
|
||||||
|
var authValue = auth.ToString();
|
||||||
|
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
authValue.Contains("Token=", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authValue);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
request.Headers.TryAddWithoutValidation("Authorization", authValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await _proxyService.HttpClient.SendAsync(request);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return StatusCode((int)response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentBytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
|
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream";
|
||||||
|
return File(contentBytes, contentType);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to proxy non-JSON request for {Path}", path);
|
_logger.LogWarning(ex, "Failed to proxy binary request for {Path}", path);
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2511,18 +2641,28 @@ public class JellyfinController : ControllerBase
|
|||||||
if (playlistConfig != null)
|
if (playlistConfig != null)
|
||||||
{
|
{
|
||||||
var playlistName = playlistConfig.Name;
|
var playlistName = playlistConfig.Name;
|
||||||
var missingTracksKey = $"spotify:missing:{playlistName}";
|
|
||||||
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
|
|
||||||
|
|
||||||
_logger.LogInformation("Cache lookup for {Key}: {Count} tracks",
|
// Get matched external tracks (tracks that were successfully downloaded/matched)
|
||||||
missingTracksKey, missingTracks?.Count ?? 0);
|
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
||||||
|
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||||
|
|
||||||
// Fallback to file cache
|
_logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks",
|
||||||
if (missingTracks == null || missingTracks.Count == 0)
|
matchedTracksKey, matchedTracks?.Count ?? 0);
|
||||||
|
|
||||||
|
// Fallback to legacy cache format
|
||||||
|
if (matchedTracks == null || matchedTracks.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Trying file cache for {Name}", playlistName);
|
var legacyKey = $"spotify:matched:{playlistName}";
|
||||||
missingTracks = await LoadMissingTracksFromFile(playlistName);
|
var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
|
||||||
_logger.LogInformation("File cache result: {Count} tracks", missingTracks?.Count ?? 0);
|
if (legacySongs != null && legacySongs.Count > 0)
|
||||||
|
{
|
||||||
|
matchedTracks = legacySongs.Select((s, i) => new MatchedTrack
|
||||||
|
{
|
||||||
|
Position = i,
|
||||||
|
MatchedSong = s
|
||||||
|
}).ToList();
|
||||||
|
_logger.LogInformation("Loaded {Count} tracks from legacy cache", matchedTracks.Count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get local tracks count from Jellyfin
|
// Get local tracks count from Jellyfin
|
||||||
@@ -2538,7 +2678,7 @@ public class JellyfinController : ControllerBase
|
|||||||
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
||||||
{
|
{
|
||||||
localTracksCount = localItems.GetArrayLength();
|
localTracksCount = localItems.GetArrayLength();
|
||||||
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Name}",
|
_logger.LogInformation("Found {Count} total items in Jellyfin playlist {Name}",
|
||||||
localTracksCount, playlistName);
|
localTracksCount, playlistName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2547,26 +2687,28 @@ public class JellyfinController : ControllerBase
|
|||||||
_logger.LogWarning(ex, "Failed to get local tracks count for {Name}", playlistName);
|
_logger.LogWarning(ex, "Failed to get local tracks count for {Name}", playlistName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingTracks != null && missingTracks.Count > 0)
|
// Count external matched tracks (not local)
|
||||||
|
var externalMatchedCount = 0;
|
||||||
|
if (matchedTracks != null)
|
||||||
{
|
{
|
||||||
// Update ChildCount to show total tracks (local + external)
|
externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
|
||||||
var totalCount = localTracksCount + missingTracks.Count;
|
|
||||||
itemDict["ChildCount"] = totalCount;
|
|
||||||
modified = true;
|
|
||||||
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
|
|
||||||
playlistName, totalCount, localTracksCount, missingTracks.Count);
|
|
||||||
}
|
}
|
||||||
else if (localTracksCount > 0)
|
|
||||||
|
// Total available tracks = what's actually in Jellyfin (local + external matched)
|
||||||
|
// This is what clients should see as the track count
|
||||||
|
var totalAvailableCount = localTracksCount;
|
||||||
|
|
||||||
|
if (totalAvailableCount > 0)
|
||||||
{
|
{
|
||||||
// No external tracks, but we have local tracks
|
// Update ChildCount to show actual available tracks
|
||||||
itemDict["ChildCount"] = localTracksCount;
|
itemDict["ChildCount"] = totalAvailableCount;
|
||||||
modified = true;
|
modified = true;
|
||||||
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Count} (local only, no external tracks)",
|
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} (actual tracks in Jellyfin)",
|
||||||
playlistName, localTracksCount);
|
playlistName, totalAvailableCount);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No tracks found for {Name} (neither local nor external)", playlistName);
|
_logger.LogWarning("No tracks found in Jellyfin for {Name}", playlistName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2725,24 +2867,370 @@ public class JellyfinController : ControllerBase
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers
|
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers
|
||||||
/// and merging with existing local tracks from Jellyfin.
|
/// and merging with existing local tracks from Jellyfin.
|
||||||
|
///
|
||||||
|
/// Supports two modes:
|
||||||
|
/// 1. Direct Spotify API (new): Uses SpotifyPlaylistFetcher for ordered tracks with ISRC matching
|
||||||
|
/// 2. Jellyfin Plugin (legacy): Uses MissingTrack data from Jellyfin Spotify Import plugin
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
|
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
{
|
||||||
|
// Try ordered cache first (from direct Spotify API mode)
|
||||||
|
if (_spotifyApiSettings.Enabled && _spotifyPlaylistFetcher != null)
|
||||||
|
{
|
||||||
|
var orderedResult = await GetSpotifyPlaylistTracksOrderedAsync(spotifyPlaylistName, playlistId);
|
||||||
|
if (orderedResult != null) return orderedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to legacy unordered mode
|
||||||
|
return await GetSpotifyPlaylistTracksLegacyAsync(spotifyPlaylistName, playlistId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName);
|
||||||
|
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// New mode: Gets playlist tracks with correct ordering using direct Spotify API data.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
|
||||||
|
{
|
||||||
|
// Check for ordered matched tracks from SpotifyTrackMatchingService
|
||||||
|
var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}";
|
||||||
|
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
||||||
|
|
||||||
|
if (orderedTracks == null || orderedTracks.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
|
||||||
|
spotifyPlaylistName);
|
||||||
|
return null; // Fall back to legacy mode
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
||||||
|
orderedTracks.Count, spotifyPlaylistName);
|
||||||
|
|
||||||
|
// Get existing Jellyfin playlist items (tracks the Spotify Import plugin already found)
|
||||||
|
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||||
|
var userId = _settings.UserId;
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
_logger.LogError("❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI.");
|
||||||
|
return null; // Fall back to legacy mode
|
||||||
|
}
|
||||||
|
|
||||||
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
|
||||||
|
|
||||||
|
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
||||||
|
playlistId, userId);
|
||||||
|
|
||||||
|
var (existingTracksResponse, statusCode) = await _proxyService.GetJsonAsync(
|
||||||
|
playlistItemsUrl,
|
||||||
|
null,
|
||||||
|
Request.Headers);
|
||||||
|
|
||||||
|
if (statusCode != 200)
|
||||||
|
{
|
||||||
|
_logger.LogError("❌ Failed to fetch Jellyfin playlist items: HTTP {StatusCode}. Check JELLYFIN_USER_ID is correct.", statusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingTracks = new List<Song>();
|
||||||
|
|
||||||
|
if (existingTracksResponse != null &&
|
||||||
|
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||||
|
{
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
var song = _modelMapper.ParseSong(item);
|
||||||
|
existingTracks.Add(song);
|
||||||
|
_logger.LogDebug(" 📌 Local track: {Title} - {Artist}", song.Title, song.Artist);
|
||||||
|
}
|
||||||
|
_logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist - will match by name only",
|
||||||
|
existingTracks.Count);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("⚠️ No existing tracks found in Jellyfin playlist {PlaylistId} - playlist may be empty", playlistId);
|
||||||
|
// Don't return null - continue with external tracks only
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the full playlist from Spotify to know the correct order
|
||||||
|
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
|
||||||
|
if (spotifyTracks.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
|
||||||
|
return null; // Fall back to legacy
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the final track list in correct Spotify order
|
||||||
|
// STRATEGY: Match Jellyfin tracks to Spotify positions, then fill gaps with external
|
||||||
|
var finalTracks = new List<Song>();
|
||||||
|
var localUsedCount = 0;
|
||||||
|
var externalUsedCount = 0;
|
||||||
|
var skippedCount = 0;
|
||||||
|
|
||||||
|
_logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify positions...",
|
||||||
|
existingTracks.Count, spotifyTracks.Count);
|
||||||
|
|
||||||
|
// Step 1: Check for manual mappings first
|
||||||
|
var manualMappings = new Dictionary<string, string>(); // Spotify ID -> Jellyfin ID
|
||||||
|
foreach (var spotifyTrack in spotifyTracks)
|
||||||
|
{
|
||||||
|
var mappingKey = $"spotify:manual-map:{spotifyPlaylistName}:{spotifyTrack.SpotifyId}";
|
||||||
|
var jellyfinId = await _cache.GetAsync<string>(mappingKey);
|
||||||
|
if (!string.IsNullOrEmpty(jellyfinId))
|
||||||
|
{
|
||||||
|
manualMappings[spotifyTrack.SpotifyId] = jellyfinId;
|
||||||
|
_logger.LogInformation("📌 Manual mapping found: Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
||||||
|
spotifyTrack.SpotifyId, jellyfinId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: For each Spotify position, find the best matching Jellyfin track
|
||||||
|
var spotifyToJellyfinMap = new Dictionary<int, Song>(); // Spotify position -> Jellyfin track
|
||||||
|
var usedJellyfinTracks = new HashSet<string>(); // Track which Jellyfin tracks we've used
|
||||||
|
|
||||||
|
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||||
|
{
|
||||||
|
if (existingTracks.Count == 0) break;
|
||||||
|
|
||||||
|
// Check for manual mapping first
|
||||||
|
if (manualMappings.TryGetValue(spotifyTrack.SpotifyId, out var mappedJellyfinId))
|
||||||
|
{
|
||||||
|
var mappedTrack = existingTracks.FirstOrDefault(t => t.Id == mappedJellyfinId);
|
||||||
|
if (mappedTrack != null && !usedJellyfinTracks.Contains(mappedTrack.Id))
|
||||||
|
{
|
||||||
|
spotifyToJellyfinMap[spotifyTrack.Position] = mappedTrack;
|
||||||
|
usedJellyfinTracks.Add(mappedTrack.Id);
|
||||||
|
_logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' → LOCAL (manual): '{JellyfinTitle}'",
|
||||||
|
spotifyTrack.Position, spotifyTrack.Title, mappedTrack.Title);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find best matching Jellyfin track that hasn't been used yet
|
||||||
|
var bestMatch = existingTracks
|
||||||
|
.Where(song => !usedJellyfinTracks.Contains(song.Id))
|
||||||
|
.Select(song => new
|
||||||
|
{
|
||||||
|
Song = song,
|
||||||
|
TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title),
|
||||||
|
ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist)
|
||||||
|
})
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Song,
|
||||||
|
x.TitleScore,
|
||||||
|
x.ArtistScore,
|
||||||
|
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.TotalScore)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
// Use 70% threshold for matching
|
||||||
|
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
||||||
|
{
|
||||||
|
spotifyToJellyfinMap[spotifyTrack.Position] = bestMatch.Song;
|
||||||
|
usedJellyfinTracks.Add(bestMatch.Song.Id);
|
||||||
|
_logger.LogInformation("✅ Position #{Pos}: '{SpotifyTitle}' by {SpotifyArtist} → LOCAL: '{JellyfinTitle}' by {JellyfinArtist} (score: {Score:F1}%)",
|
||||||
|
spotifyTrack.Position,
|
||||||
|
spotifyTrack.Title,
|
||||||
|
spotifyTrack.PrimaryArtist,
|
||||||
|
bestMatch.Song.Title,
|
||||||
|
bestMatch.Song.Artist,
|
||||||
|
bestMatch.TotalScore);
|
||||||
|
}
|
||||||
|
else if (bestMatch != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(" ⚠️ Position #{Pos} '{SpotifyTitle}' - Best Jellyfin match too low: {Score:F1}% (need 70%)",
|
||||||
|
spotifyTrack.Position, spotifyTrack.Title, bestMatch.TotalScore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("📊 Matched {Matched}/{Total} Spotify positions to Jellyfin tracks ({Manual} manual)",
|
||||||
|
spotifyToJellyfinMap.Count, spotifyTracks.Count, manualMappings.Count);
|
||||||
|
|
||||||
|
// Step 3: Build final playlist in Spotify order
|
||||||
|
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||||
|
{
|
||||||
|
// Check if we have a Jellyfin track for this position
|
||||||
|
if (spotifyToJellyfinMap.TryGetValue(spotifyTrack.Position, out var jellyfinTrack))
|
||||||
|
{
|
||||||
|
finalTracks.Add(jellyfinTrack);
|
||||||
|
localUsedCount++;
|
||||||
|
continue; // Use local track, skip external search
|
||||||
|
}
|
||||||
|
|
||||||
|
// No local match - try to find external track
|
||||||
|
// First check pre-matched cache
|
||||||
|
var matched = orderedTracks?.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||||
|
if (matched != null)
|
||||||
|
{
|
||||||
|
finalTracks.Add(matched.MatchedSong);
|
||||||
|
externalUsedCount++;
|
||||||
|
_logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (cached): {Provider}/{Id}",
|
||||||
|
spotifyTrack.Position,
|
||||||
|
spotifyTrack.Title,
|
||||||
|
spotifyTrack.PrimaryArtist,
|
||||||
|
matched.MatchedSong.ExternalProvider,
|
||||||
|
matched.MatchedSong.ExternalId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No cached match - search external providers on-demand
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var query = $"{spotifyTrack.Title} {spotifyTrack.PrimaryArtist}";
|
||||||
|
var searchResults = await _metadataService.SearchSongsAsync(query, limit: 5);
|
||||||
|
|
||||||
|
if (searchResults.Count > 0)
|
||||||
|
{
|
||||||
|
// Fuzzy match to find best result
|
||||||
|
var bestExternalMatch = searchResults
|
||||||
|
.Select(song => new
|
||||||
|
{
|
||||||
|
Song = song,
|
||||||
|
TitleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, song.Title),
|
||||||
|
ArtistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, song.Artist)
|
||||||
|
})
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Song,
|
||||||
|
x.TitleScore,
|
||||||
|
x.ArtistScore,
|
||||||
|
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.TotalScore)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (bestExternalMatch != null && bestExternalMatch.TotalScore >= 60)
|
||||||
|
{
|
||||||
|
finalTracks.Add(bestExternalMatch.Song);
|
||||||
|
externalUsedCount++;
|
||||||
|
_logger.LogInformation("📥 Position #{Pos}: '{Title}' by {Artist} → EXTERNAL (on-demand): {Provider}/{Id} (score: {Score:F1}%)",
|
||||||
|
spotifyTrack.Position,
|
||||||
|
spotifyTrack.Title,
|
||||||
|
spotifyTrack.PrimaryArtist,
|
||||||
|
bestExternalMatch.Song.ExternalProvider,
|
||||||
|
bestExternalMatch.Song.ExternalId,
|
||||||
|
bestExternalMatch.TotalScore);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
skippedCount++;
|
||||||
|
_logger.LogWarning("❌ Position #{Pos}: '{Title}' by {Artist} → NO MATCH (best external score: {Score:F1}%, need 60%)",
|
||||||
|
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
||||||
|
bestExternalMatch?.TotalScore ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
skippedCount++;
|
||||||
|
_logger.LogWarning("❌ Position #{Pos}: '{Title}' by {Artist} → NO MATCH (no external results)",
|
||||||
|
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
skippedCount++;
|
||||||
|
_logger.LogError(ex, "❌ Position #{Pos}: '{Title}' by {Artist} → ERROR searching external providers",
|
||||||
|
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Add any unmatched Jellyfin tracks at the end
|
||||||
|
var unmatchedJellyfinTracks = existingTracks
|
||||||
|
.Where(song => !usedJellyfinTracks.Contains(song.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (unmatchedJellyfinTracks.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("📌 Adding {Count} unmatched Jellyfin tracks at the end (not in Spotify playlist)",
|
||||||
|
unmatchedJellyfinTracks.Count);
|
||||||
|
|
||||||
|
foreach (var track in unmatchedJellyfinTracks)
|
||||||
|
{
|
||||||
|
finalTracks.Add(track);
|
||||||
|
localUsedCount++;
|
||||||
|
_logger.LogInformation(" + '{Title}' by {Artist} (Jellyfin only)", track.Title, track.Artist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
|
||||||
|
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
||||||
|
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL, {Skipped} not available)",
|
||||||
|
spotifyPlaylistName,
|
||||||
|
finalTracks.Count,
|
||||||
|
localUsedCount,
|
||||||
|
externalUsedCount,
|
||||||
|
skippedCount);
|
||||||
|
|
||||||
|
if (localUsedCount == 0 && existingTracks.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("⚠️ WARNING: Found {Count} tracks in Jellyfin playlist but NONE matched by name!", existingTracks.Count);
|
||||||
|
_logger.LogWarning(" → Track names may be too different between Spotify and Jellyfin");
|
||||||
|
_logger.LogWarning(" → Check that the Jellyfin playlist has the correct tracks");
|
||||||
|
}
|
||||||
|
else if (localUsedCount > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✅ Successfully used {Local} LOCAL tracks from Jellyfin playlist", localUsedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _responseBuilder.CreateItemsResponse(finalTracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legacy mode: Gets playlist tracks without ordering (from Jellyfin Spotify Import plugin).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IActionResult> GetSpotifyPlaylistTracksLegacyAsync(string spotifyPlaylistName, string playlistId)
|
||||||
{
|
{
|
||||||
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
|
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
|
||||||
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
|
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
|
||||||
|
|
||||||
if (cachedTracks != null)
|
if (cachedTracks != null && cachedTracks.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Returning {Count} cached matched tracks for {Playlist}",
|
_logger.LogDebug("Returning {Count} cached matched tracks from Redis for {Playlist}",
|
||||||
cachedTracks.Count, spotifyPlaylistName);
|
cachedTracks.Count, spotifyPlaylistName);
|
||||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try file cache if Redis is empty
|
||||||
|
if (cachedTracks == null || cachedTracks.Count == 0)
|
||||||
|
{
|
||||||
|
cachedTracks = await LoadMatchedTracksFromFile(spotifyPlaylistName);
|
||||||
|
if (cachedTracks != null && cachedTracks.Count > 0)
|
||||||
|
{
|
||||||
|
// Restore to Redis with 1 hour TTL
|
||||||
|
await _cache.SetAsync(cacheKey, cachedTracks, TimeSpan.FromHours(1));
|
||||||
|
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist}",
|
||||||
|
cachedTracks.Count, spotifyPlaylistName);
|
||||||
|
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get existing Jellyfin playlist items (tracks the plugin already found)
|
// Get existing Jellyfin playlist items (tracks the plugin already found)
|
||||||
|
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||||
|
var userId = _settings.UserId;
|
||||||
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
playlistItemsUrl += $"?UserId={userId}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks");
|
||||||
|
}
|
||||||
|
|
||||||
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
|
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
|
||||||
$"Playlists/{playlistId}/Items",
|
playlistItemsUrl,
|
||||||
null,
|
null,
|
||||||
Request.Headers);
|
Request.Headers);
|
||||||
|
|
||||||
@@ -2766,9 +3254,13 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
_logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count);
|
_logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No existing tracks found in Jellyfin playlist - may need UserId parameter");
|
||||||
|
}
|
||||||
|
|
||||||
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
|
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
|
||||||
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
|
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
|
||||||
|
|
||||||
// Fallback to file cache if Redis is empty
|
// Fallback to file cache if Redis is empty
|
||||||
if (missingTracks == null || missingTracks.Count == 0)
|
if (missingTracks == null || missingTracks.Count == 0)
|
||||||
@@ -2877,6 +3369,9 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
||||||
|
|
||||||
|
// Also save to file cache for persistence across restarts
|
||||||
|
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
|
||||||
|
|
||||||
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})",
|
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})",
|
||||||
finalTracks.Count,
|
finalTracks.Count,
|
||||||
existingTracks.Count,
|
existingTracks.Count,
|
||||||
@@ -2885,12 +3380,6 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
return _responseBuilder.CreateItemsResponse(finalTracks);
|
return _responseBuilder.CreateItemsResponse(finalTracks);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName);
|
|
||||||
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Copies an external track to the kept folder when favorited.
|
/// Copies an external track to the kept folder when favorited.
|
||||||
@@ -2899,7 +3388,7 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get the song metadata
|
// Get the song metadata first to check if already in kept folder
|
||||||
var song = await _metadataService.GetSongAsync(provider, externalId);
|
var song = await _metadataService.GetSongAsync(provider, externalId);
|
||||||
if (song == null)
|
if (song == null)
|
||||||
{
|
{
|
||||||
@@ -2907,7 +3396,25 @@ public class JellyfinController : ControllerBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger download first
|
// Build kept folder path: /app/kept/Artist/Album/
|
||||||
|
var keptBasePath = "/app/kept";
|
||||||
|
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
||||||
|
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
||||||
|
|
||||||
|
// Check if track already exists in kept folder BEFORE downloading
|
||||||
|
// Look for any file matching the song title pattern (any extension)
|
||||||
|
if (Directory.Exists(keptAlbumPath))
|
||||||
|
{
|
||||||
|
var sanitizedTitle = PathHelper.SanitizeFileName(song.Title);
|
||||||
|
var existingFiles = Directory.GetFiles(keptAlbumPath, $"{sanitizedTitle}.*");
|
||||||
|
if (existingFiles.Length > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track not in kept folder - download it
|
||||||
_logger.LogInformation("Downloading track for kept folder: {ItemId}", itemId);
|
_logger.LogInformation("Downloading track for kept folder: {ItemId}", itemId);
|
||||||
string downloadPath;
|
string downloadPath;
|
||||||
|
|
||||||
@@ -2921,20 +3428,17 @@ public class JellyfinController : ControllerBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create kept folder structure: /app/kept/Artist/Album/
|
// Create the kept folder structure
|
||||||
var keptBasePath = "/app/kept";
|
|
||||||
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
|
|
||||||
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
|
|
||||||
|
|
||||||
Directory.CreateDirectory(keptAlbumPath);
|
Directory.CreateDirectory(keptAlbumPath);
|
||||||
|
|
||||||
// Copy file to kept folder
|
// Copy file to kept folder
|
||||||
var fileName = Path.GetFileName(downloadPath);
|
var fileName = Path.GetFileName(downloadPath);
|
||||||
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
|
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
|
||||||
|
|
||||||
|
// Double-check in case of race condition (multiple favorite clicks)
|
||||||
if (System.IO.File.Exists(keptFilePath))
|
if (System.IO.File.Exists(keptFilePath))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Track already exists in kept folder: {Path}", keptFilePath);
|
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2994,6 +3498,74 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads matched/combined tracks from file cache as fallback when Redis is empty.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<Song>?> LoadMatchedTracksFromFile(string playlistName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_matched.json");
|
||||||
|
|
||||||
|
if (!System.IO.File.Exists(filePath))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
|
||||||
|
|
||||||
|
// Check if cache is too old (more than 24 hours)
|
||||||
|
if (fileAge.TotalHours > 24)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Matched tracks file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
|
||||||
|
playlistName, fileAge.TotalHours);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
|
||||||
|
|
||||||
|
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||||
|
var tracks = JsonSerializer.Deserialize<List<Song>>(json);
|
||||||
|
|
||||||
|
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist} (age: {Age:F1}h)",
|
||||||
|
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to load matched tracks from file for {Playlist}", playlistName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves matched/combined tracks to file cache for persistence across restarts.
|
||||||
|
/// </summary>
|
||||||
|
private async Task SaveMatchedTracksToFile(string playlistName, List<Song> tracks)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cacheDir = "/app/cache/spotify";
|
||||||
|
Directory.CreateDirectory(cacheDir);
|
||||||
|
|
||||||
|
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||||
|
|
||||||
|
_logger.LogInformation("Saved {Count} matched tracks to file cache for {Playlist}",
|
||||||
|
tracks.Count, playlistName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
||||||
/// GET /spotify/sync?api_key=YOUR_KEY
|
/// GET /spotify/sync?api_key=YOUR_KEY
|
||||||
|
|||||||
28
allstarr/Filters/AdminPortFilter.cs
Normal file
28
allstarr/Filters/AdminPortFilter.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
|
namespace allstarr.Filters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filter that restricts access to admin endpoints to only the admin port (5275).
|
||||||
|
/// This prevents the admin API from being accessed through the main proxy port.
|
||||||
|
/// </summary>
|
||||||
|
public class AdminPortFilter : IActionFilter
|
||||||
|
{
|
||||||
|
private const int AdminPort = 5275;
|
||||||
|
|
||||||
|
public void OnActionExecuting(ActionExecutingContext context)
|
||||||
|
{
|
||||||
|
var requestPort = context.HttpContext.Connection.LocalPort;
|
||||||
|
|
||||||
|
if (requestPort != AdminPort)
|
||||||
|
{
|
||||||
|
context.Result = new NotFoundResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnActionExecuted(ActionExecutedContext context)
|
||||||
|
{
|
||||||
|
// No action needed after execution
|
||||||
|
}
|
||||||
|
}
|
||||||
76
allstarr/Middleware/AdminStaticFilesMiddleware.cs
Normal file
76
allstarr/Middleware/AdminStaticFilesMiddleware.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
using Microsoft.AspNetCore.StaticFiles;
|
||||||
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
|
||||||
|
namespace allstarr.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Middleware that only serves static files on the admin port (5275).
|
||||||
|
/// This keeps the admin UI isolated from the main proxy port.
|
||||||
|
/// </summary>
|
||||||
|
public class AdminStaticFilesMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly IWebHostEnvironment _env;
|
||||||
|
private const int AdminPort = 5275;
|
||||||
|
|
||||||
|
public AdminStaticFilesMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
IWebHostEnvironment env)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_env = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var port = context.Connection.LocalPort;
|
||||||
|
|
||||||
|
if (port == AdminPort)
|
||||||
|
{
|
||||||
|
var path = context.Request.Path.Value ?? "/";
|
||||||
|
|
||||||
|
// Serve index.html for root path
|
||||||
|
if (path == "/" || path == "/index.html")
|
||||||
|
{
|
||||||
|
var indexPath = Path.Combine(_env.WebRootPath, "index.html");
|
||||||
|
if (File.Exists(indexPath))
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "text/html";
|
||||||
|
await context.Response.SendFileAsync(indexPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to serve static file from wwwroot
|
||||||
|
var filePath = Path.Combine(_env.WebRootPath, path.TrimStart('/'));
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
{
|
||||||
|
var contentType = GetContentType(filePath);
|
||||||
|
context.Response.ContentType = contentType;
|
||||||
|
await context.Response.SendFileAsync(filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not admin port or file not found - continue pipeline
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetContentType(string filePath)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||||
|
return ext switch
|
||||||
|
{
|
||||||
|
".html" => "text/html",
|
||||||
|
".css" => "text/css",
|
||||||
|
".js" => "application/javascript",
|
||||||
|
".json" => "application/json",
|
||||||
|
".png" => "image/png",
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".gif" => "image/gif",
|
||||||
|
".svg" => "image/svg+xml",
|
||||||
|
".ico" => "image/x-icon",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Services.Jellyfin;
|
||||||
|
|
||||||
namespace allstarr.Middleware;
|
namespace allstarr.Middleware;
|
||||||
|
|
||||||
@@ -13,17 +14,20 @@ public class WebSocketProxyMiddleware
|
|||||||
private readonly RequestDelegate _next;
|
private readonly RequestDelegate _next;
|
||||||
private readonly JellyfinSettings _settings;
|
private readonly JellyfinSettings _settings;
|
||||||
private readonly ILogger<WebSocketProxyMiddleware> _logger;
|
private readonly ILogger<WebSocketProxyMiddleware> _logger;
|
||||||
|
private readonly JellyfinSessionManager _sessionManager;
|
||||||
|
|
||||||
public WebSocketProxyMiddleware(
|
public WebSocketProxyMiddleware(
|
||||||
RequestDelegate next,
|
RequestDelegate next,
|
||||||
IOptions<JellyfinSettings> settings,
|
IOptions<JellyfinSettings> settings,
|
||||||
ILogger<WebSocketProxyMiddleware> logger)
|
ILogger<WebSocketProxyMiddleware> logger,
|
||||||
|
JellyfinSessionManager sessionManager)
|
||||||
{
|
{
|
||||||
_next = next;
|
_next = next;
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
|
||||||
_logger.LogWarning("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
|
_logger.LogDebug("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
@@ -38,7 +42,7 @@ public class WebSocketProxyMiddleware
|
|||||||
isWebSocket ||
|
isWebSocket ||
|
||||||
context.Request.Headers.ContainsKey("Upgrade"))
|
context.Request.Headers.ContainsKey("Upgrade"))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("🔍 WEBSOCKET: Potential WebSocket request: Path={Path}, IsWs={IsWs}, Method={Method}, Upgrade={Upgrade}, Connection={Connection}",
|
_logger.LogDebug("🔍 WEBSOCKET: Potential WebSocket request: Path={Path}, IsWs={IsWs}, Method={Method}, Upgrade={Upgrade}, Connection={Connection}",
|
||||||
path,
|
path,
|
||||||
isWebSocket,
|
isWebSocket,
|
||||||
context.Request.Method,
|
context.Request.Method,
|
||||||
@@ -50,7 +54,7 @@ public class WebSocketProxyMiddleware
|
|||||||
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
|
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
|
||||||
context.WebSockets.IsWebSocketRequest)
|
context.WebSockets.IsWebSocketRequest)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
|
_logger.LogInformation("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
|
||||||
context.Connection.RemoteIpAddress);
|
context.Connection.RemoteIpAddress);
|
||||||
|
|
||||||
await HandleWebSocketProxyAsync(context);
|
await HandleWebSocketProxyAsync(context);
|
||||||
@@ -65,12 +69,34 @@ public class WebSocketProxyMiddleware
|
|||||||
{
|
{
|
||||||
ClientWebSocket? serverWebSocket = null;
|
ClientWebSocket? serverWebSocket = null;
|
||||||
WebSocket? clientWebSocket = null;
|
WebSocket? clientWebSocket = null;
|
||||||
|
string? deviceId = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Extract device ID from query string or headers for session tracking
|
||||||
|
deviceId = context.Request.Query["deviceId"].ToString();
|
||||||
|
if (string.IsNullOrEmpty(deviceId))
|
||||||
|
{
|
||||||
|
// Try to extract from X-Emby-Authorization header
|
||||||
|
if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var authHeader))
|
||||||
|
{
|
||||||
|
var authValue = authHeader.ToString();
|
||||||
|
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match(authValue, @"DeviceId=""([^""]+)""");
|
||||||
|
if (deviceIdMatch.Success)
|
||||||
|
{
|
||||||
|
deviceId = deviceIdMatch.Groups[1].Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(deviceId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("🔍 WEBSOCKET: Client WebSocket for device {DeviceId}", deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
// Accept the WebSocket connection from the client
|
// Accept the WebSocket connection from the client
|
||||||
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
|
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
|
||||||
_logger.LogWarning("✓ WEBSOCKET: Client WebSocket accepted");
|
_logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted");
|
||||||
|
|
||||||
// Build Jellyfin WebSocket URL
|
// Build Jellyfin WebSocket URL
|
||||||
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
|
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
|
||||||
@@ -84,7 +110,7 @@ public class WebSocketProxyMiddleware
|
|||||||
jellyfinWsUrl += context.Request.QueryString.Value;
|
jellyfinWsUrl += context.Request.QueryString.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogWarning("🔗 WEBSOCKET: Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl);
|
_logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl);
|
||||||
|
|
||||||
// Connect to Jellyfin WebSocket
|
// Connect to Jellyfin WebSocket
|
||||||
serverWebSocket = new ClientWebSocket();
|
serverWebSocket = new ClientWebSocket();
|
||||||
@@ -94,21 +120,21 @@ public class WebSocketProxyMiddleware
|
|||||||
if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
|
if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
|
||||||
{
|
{
|
||||||
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString());
|
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString());
|
||||||
_logger.LogWarning("🔑 WEBSOCKET: Forwarded X-Emby-Authorization header");
|
_logger.LogDebug("🔑 WEBSOCKET: Forwarded X-Emby-Authorization header");
|
||||||
}
|
}
|
||||||
else if (context.Request.Headers.TryGetValue("Authorization", out var authHeader))
|
else if (context.Request.Headers.TryGetValue("Authorization", out var authHeader2))
|
||||||
{
|
{
|
||||||
var authValue = authHeader.ToString();
|
var authValue = authHeader2.ToString();
|
||||||
// If it's a MediaBrowser auth header, use X-Emby-Authorization
|
// If it's a MediaBrowser auth header, use X-Emby-Authorization
|
||||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
|
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
|
||||||
_logger.LogWarning("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization header");
|
_logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization header");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
serverWebSocket.Options.SetRequestHeader("Authorization", authValue);
|
serverWebSocket.Options.SetRequestHeader("Authorization", authValue);
|
||||||
_logger.LogWarning("🔑 WEBSOCKET: Forwarded Authorization header");
|
_logger.LogDebug("🔑 WEBSOCKET: Forwarded Authorization header");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +142,7 @@ public class WebSocketProxyMiddleware
|
|||||||
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
|
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
|
||||||
|
|
||||||
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
||||||
_logger.LogWarning("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
||||||
|
|
||||||
// Start bidirectional proxying
|
// Start bidirectional proxying
|
||||||
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
||||||
@@ -125,7 +151,7 @@ public class WebSocketProxyMiddleware
|
|||||||
// Wait for either direction to complete
|
// Wait for either direction to complete
|
||||||
await Task.WhenAny(clientToServer, serverToClient);
|
await Task.WhenAny(clientToServer, serverToClient);
|
||||||
|
|
||||||
_logger.LogWarning("🔌 WEBSOCKET: WebSocket proxy connection closed");
|
_logger.LogDebug("🔌 WEBSOCKET: WebSocket proxy connection closed");
|
||||||
}
|
}
|
||||||
catch (WebSocketException wsEx)
|
catch (WebSocketException wsEx)
|
||||||
{
|
{
|
||||||
@@ -165,7 +191,14 @@ public class WebSocketProxyMiddleware
|
|||||||
clientWebSocket?.Dispose();
|
clientWebSocket?.Dispose();
|
||||||
serverWebSocket?.Dispose();
|
serverWebSocket?.Dispose();
|
||||||
|
|
||||||
_logger.LogWarning("🧹 WEBSOCKET: WebSocket connections cleaned up");
|
// CRITICAL: Notify session manager that client disconnected
|
||||||
|
if (!string.IsNullOrEmpty(deviceId))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
||||||
|
await _sessionManager.RemoveSessionAsync(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("🧹 WEBSOCKET: WebSocket connections cleaned up");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +219,7 @@ public class WebSocketProxyMiddleware
|
|||||||
|
|
||||||
if (result.MessageType == WebSocketMessageType.Close)
|
if (result.MessageType == WebSocketMessageType.Close)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("🔌 WEBSOCKET {Direction}: Close message received", direction);
|
_logger.LogDebug("🔌 WEBSOCKET {Direction}: Close message received", direction);
|
||||||
await destination.CloseAsync(
|
await destination.CloseAsync(
|
||||||
result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
|
result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
|
||||||
result.CloseStatusDescription,
|
result.CloseStatusDescription,
|
||||||
@@ -226,11 +259,11 @@ public class WebSocketProxyMiddleware
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ WEBSOCKET {Direction}: Operation cancelled", direction);
|
_logger.LogDebug("⚠️ WEBSOCKET {Direction}: Operation cancelled", direction);
|
||||||
}
|
}
|
||||||
catch (WebSocketException wsEx) when (wsEx.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
|
catch (WebSocketException wsEx) when (wsEx.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ WEBSOCKET {Direction}: Connection closed prematurely", direction);
|
_logger.LogDebug("⚠️ WEBSOCKET {Direction}: Connection closed prematurely", direction);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -99,4 +99,10 @@ public class Song
|
|||||||
/// 0 = Naturally clean, 1 = Explicit, 2 = Not applicable, 3 = Clean/edited version, 6/7 = Unknown
|
/// 0 = Naturally clean, 1 = Explicit, 2 = Not applicable, 3 = Clean/edited version, 6/7 = Unknown
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? ExplicitContentLyrics { get; set; }
|
public int? ExplicitContentLyrics { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raw Jellyfin metadata (MediaSources, etc.) for local tracks
|
||||||
|
/// Preserved to maintain bitrate and other technical details
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, object?>? JellyfinMetadata { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
72
allstarr/Models/Settings/SpotifyApiSettings.cs
Normal file
72
allstarr/Models/Settings/SpotifyApiSettings.cs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
namespace allstarr.Models.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for direct Spotify API access.
|
||||||
|
/// This enables fetching playlist data directly from Spotify rather than relying on the Jellyfin plugin.
|
||||||
|
///
|
||||||
|
/// Benefits over Jellyfin plugin approach:
|
||||||
|
/// - Track ordering is preserved (critical for playlists like Release Radar)
|
||||||
|
/// - ISRC codes available for exact matching
|
||||||
|
/// - Real-time data without waiting for plugin sync
|
||||||
|
/// - Full track metadata (duration, release date, etc.)
|
||||||
|
/// </summary>
|
||||||
|
public class SpotifyApiSettings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Enable direct Spotify API integration.
|
||||||
|
/// When enabled, playlists will be fetched directly from Spotify instead of the Jellyfin plugin.
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spotify Client ID from https://developer.spotify.com/dashboard
|
||||||
|
/// Used for OAuth token refresh and API access.
|
||||||
|
/// </summary>
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spotify Client Secret from https://developer.spotify.com/dashboard
|
||||||
|
/// Optional - only needed for certain OAuth flows.
|
||||||
|
/// </summary>
|
||||||
|
public string ClientSecret { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spotify session cookie (sp_dc).
|
||||||
|
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.
|
||||||
|
/// These playlists are not available via the official API.
|
||||||
|
///
|
||||||
|
/// To get this cookie:
|
||||||
|
/// 1. Log into open.spotify.com in your browser
|
||||||
|
/// 2. Open DevTools (F12) > Application > Cookies > https://open.spotify.com
|
||||||
|
/// 3. Copy the value of the "sp_dc" cookie
|
||||||
|
///
|
||||||
|
/// Note: This cookie expires periodically and will need to be refreshed.
|
||||||
|
/// </summary>
|
||||||
|
public string SessionCookie { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cache duration in minutes for playlist data.
|
||||||
|
/// Playlists like Release Radar only update weekly, so caching is beneficial.
|
||||||
|
/// Default: 60 minutes
|
||||||
|
/// </summary>
|
||||||
|
public int CacheDurationMinutes { get; set; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rate limit delay between Spotify API requests in milliseconds.
|
||||||
|
/// Default: 100ms (Spotify allows ~100 requests per minute)
|
||||||
|
/// </summary>
|
||||||
|
public int RateLimitDelayMs { get; set; } = 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether to prefer ISRC matching over fuzzy title/artist matching when ISRC is available.
|
||||||
|
/// ISRC provides exact track identification across services.
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool PreferIsrcMatching { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ISO date string of when the session cookie was last set/updated.
|
||||||
|
/// Used to track cookie age and warn when it's approaching expiration (~1 year).
|
||||||
|
/// </summary>
|
||||||
|
public string? SessionCookieSetDate { get; set; }
|
||||||
|
}
|
||||||
@@ -28,11 +28,19 @@ public class SpotifyPlaylistConfig
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Jellyfin playlist ID (get from playlist URL)
|
/// Spotify playlist ID (get from Spotify playlist URL)
|
||||||
/// Example: "4383a46d8bcac3be2ef9385053ea18df"
|
/// Example: "37i9dQZF1DXcBWIGoYBM5M" (from open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M)
|
||||||
|
/// Required for personalized playlists like Discover Weekly, Release Radar, etc.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Id { get; set; } = string.Empty;
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Jellyfin playlist ID (internal Jellyfin GUID)
|
||||||
|
/// Example: "4383a46d8bcac3be2ef9385053ea18df"
|
||||||
|
/// This is the ID Jellyfin uses when requesting playlist tracks
|
||||||
|
/// </summary>
|
||||||
|
public string JellyfinId { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Where to position local tracks: "first" or "last"
|
/// Where to position local tracks: "first" or "last"
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -107,6 +115,12 @@ public class SpotifyImportSettings
|
|||||||
public SpotifyPlaylistConfig? GetPlaylistById(string playlistId) =>
|
public SpotifyPlaylistConfig? GetPlaylistById(string playlistId) =>
|
||||||
Playlists.FirstOrDefault(p => p.Id.Equals(playlistId, StringComparison.OrdinalIgnoreCase));
|
Playlists.FirstOrDefault(p => p.Id.Equals(playlistId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the playlist configuration by Jellyfin playlist ID.
|
||||||
|
/// </summary>
|
||||||
|
public SpotifyPlaylistConfig? GetPlaylistByJellyfinId(string jellyfinPlaylistId) =>
|
||||||
|
Playlists.FirstOrDefault(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the playlist configuration by name.
|
/// Gets the playlist configuration by name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -114,8 +128,8 @@ public class SpotifyImportSettings
|
|||||||
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if a playlist ID is configured for Spotify import.
|
/// Checks if a Jellyfin playlist ID is configured for Spotify import.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsSpotifyPlaylist(string playlistId) =>
|
public bool IsSpotifyPlaylist(string jellyfinPlaylistId) =>
|
||||||
Playlists.Any(p => p.Id.Equals(playlistId, StringComparison.OrdinalIgnoreCase));
|
Playlists.Any(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|||||||
231
allstarr/Models/Spotify/SpotifyPlaylistTrack.cs
Normal file
231
allstarr/Models/Spotify/SpotifyPlaylistTrack.cs
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
using allstarr.Models.Domain;
|
||||||
|
|
||||||
|
namespace allstarr.Models.Spotify;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a track from a Spotify playlist with full metadata including position.
|
||||||
|
/// This model preserves track ordering which is critical for playlists like Release Radar.
|
||||||
|
/// </summary>
|
||||||
|
public class SpotifyPlaylistTrack
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL")
|
||||||
|
/// </summary>
|
||||||
|
public string SpotifyId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Track's position in the playlist (0-based index).
|
||||||
|
/// This is critical for maintaining correct playlist order.
|
||||||
|
/// </summary>
|
||||||
|
public int Position { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Track title
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Album name
|
||||||
|
/// </summary>
|
||||||
|
public string Album { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Album Spotify ID
|
||||||
|
/// </summary>
|
||||||
|
public string AlbumId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of artist names
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Artists { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of artist Spotify IDs
|
||||||
|
/// </summary>
|
||||||
|
public List<string> ArtistIds { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ISRC (International Standard Recording Code) for exact track identification.
|
||||||
|
/// This enables precise matching across different streaming services.
|
||||||
|
/// </summary>
|
||||||
|
public string? Isrc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Track duration in milliseconds
|
||||||
|
/// </summary>
|
||||||
|
public int DurationMs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the track contains explicit content
|
||||||
|
/// </summary>
|
||||||
|
public bool Explicit { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Track's popularity score (0-100)
|
||||||
|
/// </summary>
|
||||||
|
public int Popularity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Preview URL for 30-second audio clip (may be null)
|
||||||
|
/// </summary>
|
||||||
|
public string? PreviewUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Album artwork URL (largest available)
|
||||||
|
/// </summary>
|
||||||
|
public string? AlbumArtUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Release date of the album (format varies: YYYY, YYYY-MM, or YYYY-MM-DD)
|
||||||
|
/// </summary>
|
||||||
|
public string? ReleaseDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When this track was added to the playlist
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? AddedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disc number within the album
|
||||||
|
/// </summary>
|
||||||
|
public int DiscNumber { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Track number within the disc
|
||||||
|
/// </summary>
|
||||||
|
public int TrackNumber { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Primary (first) artist name
|
||||||
|
/// </summary>
|
||||||
|
public string PrimaryArtist => Artists.FirstOrDefault() ?? string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All artists as a comma-separated string
|
||||||
|
/// </summary>
|
||||||
|
public string AllArtists => string.Join(", ", Artists);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Track duration as TimeSpan
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan Duration => TimeSpan.FromMilliseconds(DurationMs);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts to the legacy MissingTrack format for compatibility with existing matching logic.
|
||||||
|
/// </summary>
|
||||||
|
public MissingTrack ToMissingTrack() => new()
|
||||||
|
{
|
||||||
|
SpotifyId = SpotifyId,
|
||||||
|
Title = Title,
|
||||||
|
Album = Album,
|
||||||
|
Artists = Artists
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a Spotify playlist with its tracks in order.
|
||||||
|
/// </summary>
|
||||||
|
public class SpotifyPlaylist
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Spotify playlist ID
|
||||||
|
/// </summary>
|
||||||
|
public string SpotifyId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playlist name
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playlist description
|
||||||
|
/// </summary>
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playlist owner's display name
|
||||||
|
/// </summary>
|
||||||
|
public string? OwnerName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playlist owner's Spotify ID
|
||||||
|
/// </summary>
|
||||||
|
public string? OwnerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Total number of tracks in the playlist
|
||||||
|
/// </summary>
|
||||||
|
public int TotalTracks { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playlist cover image URL
|
||||||
|
/// </summary>
|
||||||
|
public string? ImageUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this is a collaborative playlist
|
||||||
|
/// </summary>
|
||||||
|
public bool Collaborative { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this playlist is public
|
||||||
|
/// </summary>
|
||||||
|
public bool Public { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks in the playlist, ordered by position
|
||||||
|
/// </summary>
|
||||||
|
public List<SpotifyPlaylistTrack> Tracks { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When this data was fetched from Spotify
|
||||||
|
/// </summary>
|
||||||
|
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot ID for change detection (Spotify's playlist version identifier)
|
||||||
|
/// </summary>
|
||||||
|
public string? SnapshotId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a Spotify track that has been matched to an external provider track.
|
||||||
|
/// Preserves position for correct playlist ordering.
|
||||||
|
/// </summary>
|
||||||
|
public class MatchedTrack
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Position in the original Spotify playlist (0-based)
|
||||||
|
/// </summary>
|
||||||
|
public int Position { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Original Spotify track ID
|
||||||
|
/// </summary>
|
||||||
|
public string SpotifyId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Original Spotify track title (for debugging/logging)
|
||||||
|
/// </summary>
|
||||||
|
public string SpotifyTitle { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Original Spotify artist (for debugging/logging)
|
||||||
|
/// </summary>
|
||||||
|
public string SpotifyArtist { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ISRC used for matching (if available)
|
||||||
|
/// </summary>
|
||||||
|
public string? Isrc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How the match was made: "isrc" or "fuzzy"
|
||||||
|
/// </summary>
|
||||||
|
public string MatchType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The matched song from the external provider
|
||||||
|
/// </summary>
|
||||||
|
public Song MatchedSong { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -43,11 +43,18 @@ static List<string> DecodeSquidWtfUrls()
|
|||||||
var backendType = builder.Configuration.GetValue<BackendType>("Backend:Type");
|
var backendType = builder.Configuration.GetValue<BackendType>("Backend:Type");
|
||||||
|
|
||||||
// Configure Kestrel for large responses over VPN/Tailscale
|
// Configure Kestrel for large responses over VPN/Tailscale
|
||||||
|
// Also configure admin port on 5275 (internal only, not exposed)
|
||||||
builder.WebHost.ConfigureKestrel(serverOptions =>
|
builder.WebHost.ConfigureKestrel(serverOptions =>
|
||||||
{
|
{
|
||||||
serverOptions.Limits.MaxResponseBufferSize = null; // Disable response buffering limit
|
serverOptions.Limits.MaxResponseBufferSize = null; // Disable response buffering limit
|
||||||
serverOptions.Limits.MaxRequestBodySize = null; // Allow large request bodies
|
serverOptions.Limits.MaxRequestBodySize = null; // Allow large request bodies
|
||||||
serverOptions.Limits.MinResponseDataRate = null; // Disable minimum data rate for slow connections
|
serverOptions.Limits.MinResponseDataRate = null; // Disable minimum data rate for slow connections
|
||||||
|
|
||||||
|
// Main proxy port (exposed)
|
||||||
|
serverOptions.ListenAnyIP(8080);
|
||||||
|
|
||||||
|
// Admin UI port (internal only - do NOT expose through reverse proxy)
|
||||||
|
serverOptions.ListenAnyIP(5275);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add response compression for large JSON responses (helps with Tailscale/VPN MTU issues)
|
// Add response compression for large JSON responses (helps with Tailscale/VPN MTU issues)
|
||||||
@@ -99,6 +106,9 @@ builder.Services.AddHttpContextAccessor();
|
|||||||
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
|
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||||
builder.Services.AddProblemDetails();
|
builder.Services.AddProblemDetails();
|
||||||
|
|
||||||
|
// Admin port filter (restricts admin API to port 5275)
|
||||||
|
builder.Services.AddScoped<allstarr.Filters.AdminPortFilter>();
|
||||||
|
|
||||||
// Configuration - register both settings, active one determined by backend type
|
// Configuration - register both settings, active one determined by backend type
|
||||||
builder.Services.Configure<SubsonicSettings>(
|
builder.Services.Configure<SubsonicSettings>(
|
||||||
builder.Configuration.GetSection("Subsonic"));
|
builder.Configuration.GetSection("Subsonic"));
|
||||||
@@ -119,11 +129,13 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
|||||||
|
|
||||||
// Debug: Check what Bind() populated
|
// Debug: Check what Bind() populated
|
||||||
Console.WriteLine($"DEBUG: After Bind(), Playlists.Count = {options.Playlists.Count}");
|
Console.WriteLine($"DEBUG: After Bind(), Playlists.Count = {options.Playlists.Count}");
|
||||||
|
#pragma warning disable CS0618 // Type or member is obsolete
|
||||||
Console.WriteLine($"DEBUG: After Bind(), PlaylistIds.Count = {options.PlaylistIds.Count}");
|
Console.WriteLine($"DEBUG: After Bind(), PlaylistIds.Count = {options.PlaylistIds.Count}");
|
||||||
Console.WriteLine($"DEBUG: After Bind(), PlaylistNames.Count = {options.PlaylistNames.Count}");
|
Console.WriteLine($"DEBUG: After Bind(), PlaylistNames.Count = {options.PlaylistNames.Count}");
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
|
||||||
// Parse SPOTIFY_IMPORT_PLAYLISTS env var (JSON array format)
|
// Parse SPOTIFY_IMPORT_PLAYLISTS env var (JSON array format)
|
||||||
// Format: [["Name","Id","first|last"],["Name2","Id2","first|last"]]
|
// Format: [["Name","SpotifyId","JellyfinId","first|last"],["Name2","SpotifyId2","JellyfinId2","first|last"]]
|
||||||
var playlistsEnv = builder.Configuration.GetValue<string>("SpotifyImport:Playlists");
|
var playlistsEnv = builder.Configuration.GetValue<string>("SpotifyImport:Playlists");
|
||||||
if (!string.IsNullOrWhiteSpace(playlistsEnv))
|
if (!string.IsNullOrWhiteSpace(playlistsEnv))
|
||||||
{
|
{
|
||||||
@@ -134,6 +146,9 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
|||||||
var playlistArrays = System.Text.Json.JsonSerializer.Deserialize<string[][]>(playlistsEnv);
|
var playlistArrays = System.Text.Json.JsonSerializer.Deserialize<string[][]>(playlistsEnv);
|
||||||
if (playlistArrays != null && playlistArrays.Length > 0)
|
if (playlistArrays != null && playlistArrays.Length > 0)
|
||||||
{
|
{
|
||||||
|
// Clear any playlists that Bind() may have incorrectly populated
|
||||||
|
options.Playlists.Clear();
|
||||||
|
|
||||||
Console.WriteLine($"Parsed {playlistArrays.Length} playlists from JSON format");
|
Console.WriteLine($"Parsed {playlistArrays.Length} playlists from JSON format");
|
||||||
foreach (var arr in playlistArrays)
|
foreach (var arr in playlistArrays)
|
||||||
{
|
{
|
||||||
@@ -143,13 +158,14 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
|||||||
{
|
{
|
||||||
Name = arr[0].Trim(),
|
Name = arr[0].Trim(),
|
||||||
Id = arr[1].Trim(),
|
Id = arr[1].Trim(),
|
||||||
LocalTracksPosition = arr.Length >= 3 &&
|
JellyfinId = arr.Length >= 3 ? arr[2].Trim() : "",
|
||||||
arr[2].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
LocalTracksPosition = arr.Length >= 4 &&
|
||||||
|
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
||||||
? LocalTracksPosition.Last
|
? LocalTracksPosition.Last
|
||||||
: LocalTracksPosition.First
|
: LocalTracksPosition.First
|
||||||
};
|
};
|
||||||
options.Playlists.Add(config);
|
options.Playlists.Add(config);
|
||||||
Console.WriteLine($" Added: {config.Name} (ID: {config.Id}, Position: {config.LocalTracksPosition})");
|
Console.WriteLine($" Added: {config.Name} (Spotify: {config.Id}, Jellyfin: {config.JellyfinId}, Position: {config.LocalTracksPosition})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,7 +177,7 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
|||||||
catch (System.Text.Json.JsonException ex)
|
catch (System.Text.Json.JsonException ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Warning: Failed to parse SPOTIFY_IMPORT_PLAYLISTS: {ex.Message}");
|
Console.WriteLine($"Warning: Failed to parse SPOTIFY_IMPORT_PLAYLISTS: {ex.Message}");
|
||||||
Console.WriteLine("Expected format: [[\"Name\",\"Id\",\"first|last\"],[\"Name2\",\"Id2\",\"first|last\"]]");
|
Console.WriteLine("Expected format: [[\"Name\",\"SpotifyId\",\"JellyfinId\",\"first|last\"],[\"Name2\",\"SpotifyId2\",\"JellyfinId2\",\"first|last\"]]");
|
||||||
Console.WriteLine("Will try legacy format instead");
|
Console.WriteLine("Will try legacy format instead");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,14 +273,14 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
|
|||||||
// Clear it and re-parse properly
|
// Clear it and re-parse properly
|
||||||
Console.WriteLine($"DEBUG: Bind() incorrectly populated {options.Playlists.Count} playlists, clearing and re-parsing...");
|
Console.WriteLine($"DEBUG: Bind() incorrectly populated {options.Playlists.Count} playlists, clearing and re-parsing...");
|
||||||
options.Playlists.Clear();
|
options.Playlists.Clear();
|
||||||
|
|
||||||
|
#pragma warning disable CS0618 // Type or member is obsolete
|
||||||
options.PlaylistIds.Clear();
|
options.PlaylistIds.Clear();
|
||||||
options.PlaylistNames.Clear();
|
options.PlaylistNames.Clear();
|
||||||
options.PlaylistLocalTracksPositions.Clear();
|
options.PlaylistLocalTracksPositions.Clear();
|
||||||
|
|
||||||
Console.WriteLine("Parsing legacy Spotify playlist format...");
|
Console.WriteLine("Parsing legacy Spotify playlist format...");
|
||||||
|
|
||||||
#pragma warning disable CS0618 // Type or member is obsolete
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(playlistIdsEnv))
|
if (!string.IsNullOrWhiteSpace(playlistIdsEnv))
|
||||||
{
|
{
|
||||||
options.PlaylistIds = playlistIdsEnv
|
options.PlaylistIds = playlistIdsEnv
|
||||||
@@ -463,11 +479,79 @@ builder.Services.AddHostedService<StartupValidationOrchestrator>();
|
|||||||
// Register cache cleanup service (only runs when StorageMode is Cache)
|
// Register cache cleanup service (only runs when StorageMode is Cache)
|
||||||
builder.Services.AddHostedService<CacheCleanupService>();
|
builder.Services.AddHostedService<CacheCleanupService>();
|
||||||
|
|
||||||
// Register Spotify missing tracks fetcher (only runs when SpotifyImport is enabled)
|
// Register Spotify API client, lyrics service, and settings for direct API access
|
||||||
|
// Configure from environment variables with SPOTIFY_API_ prefix
|
||||||
|
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
|
||||||
|
{
|
||||||
|
builder.Configuration.GetSection("SpotifyApi").Bind(options);
|
||||||
|
|
||||||
|
// Override from environment variables
|
||||||
|
var enabled = builder.Configuration.GetValue<string>("SpotifyApi:Enabled");
|
||||||
|
if (!string.IsNullOrEmpty(enabled))
|
||||||
|
{
|
||||||
|
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientId = builder.Configuration.GetValue<string>("SpotifyApi:ClientId");
|
||||||
|
if (!string.IsNullOrEmpty(clientId))
|
||||||
|
{
|
||||||
|
options.ClientId = clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientSecret = builder.Configuration.GetValue<string>("SpotifyApi:ClientSecret");
|
||||||
|
if (!string.IsNullOrEmpty(clientSecret))
|
||||||
|
{
|
||||||
|
options.ClientSecret = clientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
|
||||||
|
if (!string.IsNullOrEmpty(sessionCookie))
|
||||||
|
{
|
||||||
|
options.SessionCookie = sessionCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionCookieSetDate = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookieSetDate");
|
||||||
|
if (!string.IsNullOrEmpty(sessionCookieSetDate))
|
||||||
|
{
|
||||||
|
options.SessionCookieSetDate = sessionCookieSetDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheDuration = builder.Configuration.GetValue<int?>("SpotifyApi:CacheDurationMinutes");
|
||||||
|
if (cacheDuration.HasValue)
|
||||||
|
{
|
||||||
|
options.CacheDurationMinutes = cacheDuration.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var preferIsrc = builder.Configuration.GetValue<string>("SpotifyApi:PreferIsrcMatching");
|
||||||
|
if (!string.IsNullOrEmpty(preferIsrc))
|
||||||
|
{
|
||||||
|
options.PreferIsrcMatching = preferIsrc.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log configuration (mask sensitive values)
|
||||||
|
Console.WriteLine($"SpotifyApi Configuration:");
|
||||||
|
Console.WriteLine($" Enabled: {options.Enabled}");
|
||||||
|
Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}");
|
||||||
|
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
|
||||||
|
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
|
||||||
|
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");
|
||||||
|
Console.WriteLine($" PreferIsrcMatching: {options.PreferIsrcMatching}");
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyApiClient>();
|
||||||
|
|
||||||
|
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
|
||||||
|
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
||||||
|
|
||||||
|
// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled)
|
||||||
|
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyPlaylistFetcher>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyPlaylistFetcher>());
|
||||||
|
|
||||||
|
// Register Spotify missing tracks fetcher (legacy - only runs when SpotifyImport is enabled and SpotifyApi is disabled)
|
||||||
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>();
|
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>();
|
||||||
|
|
||||||
// Register Spotify track matching service (pre-matches tracks with rate limiting)
|
// Register Spotify track matching service (pre-matches tracks with rate limiting)
|
||||||
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
|
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
@@ -505,6 +589,9 @@ if (app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
// Serve static files only on admin port (5275)
|
||||||
|
app.UseMiddleware<allstarr.Middleware.AdminStaticFilesMiddleware>();
|
||||||
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
@@ -534,6 +621,9 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
|
|||||||
var isController = base.IsController(typeInfo);
|
var isController = base.IsController(typeInfo);
|
||||||
if (!isController) return false;
|
if (!isController) return false;
|
||||||
|
|
||||||
|
// AdminController should always be registered (for web UI)
|
||||||
|
if (typeInfo.Name == "AdminController") return true;
|
||||||
|
|
||||||
// Only register the controller matching the configured backend type
|
// Only register the controller matching the configured backend type
|
||||||
return _backendType switch
|
return _backendType switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -187,6 +187,14 @@ public class JellyfinModelMapper
|
|||||||
// Cover art URL construction
|
// Cover art URL construction
|
||||||
song.CoverArtUrl = $"/Items/{id}/Images/Primary";
|
song.CoverArtUrl = $"/Items/{id}/Images/Primary";
|
||||||
|
|
||||||
|
// Preserve Jellyfin metadata (MediaSources, etc.) for local tracks
|
||||||
|
// This ensures bitrate and other technical details are maintained
|
||||||
|
song.JellyfinMetadata = new Dictionary<string, object?>();
|
||||||
|
if (item.TryGetProperty("MediaSources", out var mediaSources))
|
||||||
|
{
|
||||||
|
song.JellyfinMetadata["MediaSources"] = JsonSerializer.Deserialize<object>(mediaSources.GetRawText());
|
||||||
|
}
|
||||||
|
|
||||||
return song;
|
return song;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -284,33 +284,11 @@ public class JellyfinProxyService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle special case for playback endpoints - Jellyfin expects wrapped body
|
// Handle special case for playback endpoints
|
||||||
|
// NOTE: Jellyfin API expects PlaybackStartInfo/PlaybackProgressInfo/PlaybackStopInfo
|
||||||
|
// DIRECTLY as the body, NOT wrapped in a field. Do NOT wrap the body.
|
||||||
var bodyToSend = body;
|
var bodyToSend = body;
|
||||||
if (!string.IsNullOrWhiteSpace(body))
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
{
|
|
||||||
// Check if this is a playback progress endpoint
|
|
||||||
if (endpoint.Contains("Sessions/Playing/Progress", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
// Wrap the body in playbackProgressInfo field
|
|
||||||
bodyToSend = $"{{\"playbackProgressInfo\":{body}}}";
|
|
||||||
_logger.LogDebug("Wrapped body for playback progress endpoint");
|
|
||||||
}
|
|
||||||
else if (endpoint.Contains("Sessions/Playing/Stopped", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
// Wrap the body in playbackStopInfo field
|
|
||||||
bodyToSend = $"{{\"playbackStopInfo\":{body}}}";
|
|
||||||
_logger.LogDebug("Wrapped body for playback stopped endpoint");
|
|
||||||
}
|
|
||||||
else if (endpoint.Contains("Sessions/Playing", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
!endpoint.Contains("Progress", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
!endpoint.Contains("Stopped", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
// Wrap the body in playbackStartInfo field for /Sessions/Playing
|
|
||||||
bodyToSend = $"{{\"playbackStartInfo\":{body}}}";
|
|
||||||
_logger.LogDebug("Wrapped body for playback start endpoint");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
bodyToSend = "{}";
|
bodyToSend = "{}";
|
||||||
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
|
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
|
||||||
|
|||||||
@@ -231,10 +231,17 @@ public class JellyfinResponseBuilder
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, object?> ConvertSongToJellyfinItem(Song song)
|
public Dictionary<string, object?> ConvertSongToJellyfinItem(Song song)
|
||||||
{
|
{
|
||||||
|
// Add " [S]" suffix to external song titles (S = streaming source)
|
||||||
|
var songTitle = song.Title;
|
||||||
|
if (!song.IsLocal)
|
||||||
|
{
|
||||||
|
songTitle = $"{song.Title} [S]";
|
||||||
|
}
|
||||||
|
|
||||||
var item = new Dictionary<string, object?>
|
var item = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = song.Id,
|
["Id"] = song.Id,
|
||||||
["Name"] = song.Title,
|
["Name"] = songTitle,
|
||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Type"] = "Audio",
|
["Type"] = "Audio",
|
||||||
["MediaType"] = "Audio",
|
["MediaType"] = "Audio",
|
||||||
@@ -316,6 +323,11 @@ public class JellyfinResponseBuilder
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if (song.IsLocal && song.JellyfinMetadata != null && song.JellyfinMetadata.ContainsKey("MediaSources"))
|
||||||
|
{
|
||||||
|
// Use preserved Jellyfin metadata for local tracks to maintain bitrate info
|
||||||
|
item["MediaSources"] = song.JellyfinMetadata["MediaSources"];
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(song.Genre))
|
if (!string.IsNullOrEmpty(song.Genre))
|
||||||
{
|
{
|
||||||
@@ -330,11 +342,11 @@ public class JellyfinResponseBuilder
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, object?> ConvertAlbumToJellyfinItem(Album album)
|
public Dictionary<string, object?> ConvertAlbumToJellyfinItem(Album album)
|
||||||
{
|
{
|
||||||
// Add " - S" suffix to external album names (S = SquidWTF)
|
// Add " [S]" suffix to external album names (S = streaming source)
|
||||||
var albumName = album.Title;
|
var albumName = album.Title;
|
||||||
if (!album.IsLocal)
|
if (!album.IsLocal)
|
||||||
{
|
{
|
||||||
albumName = $"{album.Title} - S";
|
albumName = $"{album.Title} [S]";
|
||||||
}
|
}
|
||||||
|
|
||||||
var item = new Dictionary<string, object?>
|
var item = new Dictionary<string, object?>
|
||||||
@@ -397,11 +409,11 @@ public class JellyfinResponseBuilder
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, object?> ConvertArtistToJellyfinItem(Artist artist)
|
public Dictionary<string, object?> ConvertArtistToJellyfinItem(Artist artist)
|
||||||
{
|
{
|
||||||
// Add " - S" suffix to external artist names (S = SquidWTF)
|
// Add " [S]" suffix to external artist names (S = streaming source)
|
||||||
var artistName = artist.Name;
|
var artistName = artist.Name;
|
||||||
if (!artist.IsLocal)
|
if (!artist.IsLocal)
|
||||||
{
|
{
|
||||||
artistName = $"{artist.Name} - S";
|
artistName = $"{artist.Name} [S]";
|
||||||
}
|
}
|
||||||
|
|
||||||
var item = new Dictionary<string, object?>
|
var item = new Dictionary<string, object?>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
// Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity)
|
// Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity)
|
||||||
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
|
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
_logger.LogWarning("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
|
_logger.LogDebug("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -51,17 +51,17 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
||||||
{
|
{
|
||||||
existingSession.LastActivity = DateTime.UtcNow;
|
existingSession.LastActivity = DateTime.UtcNow;
|
||||||
_logger.LogWarning("✓ SESSION: Session already exists for device {DeviceId}", deviceId);
|
_logger.LogDebug("✓ SESSION: Session already exists for device {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Refresh capabilities to keep session alive
|
// Refresh capabilities to keep session alive
|
||||||
await PostCapabilitiesAsync(headers);
|
await PostCapabilitiesAsync(headers);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogWarning("🔧 SESSION: Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
|
_logger.LogInformation("🔧 SESSION: Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
|
||||||
|
|
||||||
// Log the headers we received for debugging
|
// Log the headers we received for debugging
|
||||||
_logger.LogWarning("🔍 SESSION: Headers received for session creation: {Headers}",
|
_logger.LogDebug("🔍 SESSION: Headers received for session creation: {Headers}",
|
||||||
string.Join(", ", headers.Select(h => $"{h.Key}={h.Value.ToString().Substring(0, Math.Min(30, h.Value.ToString().Length))}...")));
|
string.Join(", ", headers.Select(h => $"{h.Key}={h.Value.ToString().Substring(0, Math.Min(30, h.Value.ToString().Length))}...")));
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -69,7 +69,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
// Post session capabilities to Jellyfin - this creates the session
|
// Post session capabilities to Jellyfin - this creates the session
|
||||||
await PostCapabilitiesAsync(headers);
|
await PostCapabilitiesAsync(headers);
|
||||||
|
|
||||||
_logger.LogWarning("✓ SESSION: Session created for {DeviceId}", deviceId);
|
_logger.LogInformation("✓ SESSION: Session created for {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Track this session
|
// Track this session
|
||||||
_sessions[deviceId] = new SessionInfo
|
_sessions[deviceId] = new SessionInfo
|
||||||
@@ -118,11 +118,12 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
|
|
||||||
if (statusCode == 204 || statusCode == 200)
|
if (statusCode == 204 || statusCode == 200)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("✓ SESSION: Posted capabilities successfully ({StatusCode})", statusCode);
|
_logger.LogDebug("✓ SESSION: Posted capabilities successfully ({StatusCode})", statusCode);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ SESSION: Failed to post capabilities - status {StatusCode}", statusCode);
|
// 401 is common when cached headers have expired - not a critical error
|
||||||
|
_logger.LogDebug("SESSION: Capabilities post returned {StatusCode} (may be expected if token expired)", statusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,11 +135,11 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
if (_sessions.TryGetValue(deviceId, out var session))
|
if (_sessions.TryGetValue(deviceId, out var session))
|
||||||
{
|
{
|
||||||
session.LastActivity = DateTime.UtcNow;
|
session.LastActivity = DateTime.UtcNow;
|
||||||
_logger.LogWarning("🔄 SESSION: Updated activity for {DeviceId}", deviceId);
|
_logger.LogDebug("🔄 SESSION: Updated activity for {DeviceId}", deviceId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
|
_logger.LogDebug("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
{
|
{
|
||||||
if (_sessions.TryRemove(deviceId, out var session))
|
if (_sessions.TryRemove(deviceId, out var session))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
|
_logger.LogInformation("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Close WebSocket if it exists
|
// Close WebSocket if it exists
|
||||||
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
|
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
|
||||||
@@ -157,7 +158,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None);
|
await session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None);
|
||||||
_logger.LogWarning("🔌 WEBSOCKET: Closed WebSocket for device {DeviceId}", deviceId);
|
_logger.LogDebug("🔌 WEBSOCKET: Closed WebSocket for device {DeviceId}", deviceId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -177,7 +178,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "⚠️ SESSION: Error removing session for {DeviceId}", deviceId);
|
_logger.LogWarning("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +191,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
{
|
{
|
||||||
if (!_sessions.TryGetValue(deviceId, out var session))
|
if (!_sessions.TryGetValue(deviceId, out var session))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
|
_logger.LogDebug("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +213,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
session.WebSocket = webSocket;
|
session.WebSocket = webSocket;
|
||||||
|
|
||||||
// Log available headers for debugging
|
// Log available headers for debugging
|
||||||
_logger.LogWarning("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
|
_logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
|
||||||
deviceId, string.Join(", ", headers.Keys));
|
deviceId, string.Join(", ", headers.Keys));
|
||||||
|
|
||||||
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
|
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
|
||||||
@@ -220,7 +221,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
if (headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
if (headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
||||||
{
|
{
|
||||||
webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString());
|
webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString());
|
||||||
_logger.LogWarning("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
|
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
|
||||||
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
|
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
|
||||||
authFound = true;
|
authFound = true;
|
||||||
}
|
}
|
||||||
@@ -230,14 +231,14 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
webSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
|
webSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
|
||||||
_logger.LogWarning("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization for {DeviceId}: {Auth}",
|
_logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization for {DeviceId}: {Auth}",
|
||||||
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
|
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
|
||||||
authFound = true;
|
authFound = true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
webSocket.Options.SetRequestHeader("Authorization", authValue);
|
webSocket.Options.SetRequestHeader("Authorization", authValue);
|
||||||
_logger.LogWarning("🔑 WEBSOCKET: Using Authorization for {DeviceId}: {Auth}",
|
_logger.LogDebug("🔑 WEBSOCKET: Using Authorization for {DeviceId}: {Auth}",
|
||||||
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
|
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
|
||||||
authFound = true;
|
authFound = true;
|
||||||
}
|
}
|
||||||
@@ -257,14 +258,14 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogWarning("🔗 WEBSOCKET: Connecting to Jellyfin for device {DeviceId}: {Url}", deviceId, jellyfinWsUrl);
|
_logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin for device {DeviceId}: {Url}", deviceId, jellyfinWsUrl);
|
||||||
|
|
||||||
// Set user agent
|
// Set user agent
|
||||||
webSocket.Options.SetRequestHeader("User-Agent", $"Allstarr-Proxy/{session.Client}");
|
webSocket.Options.SetRequestHeader("User-Agent", $"Allstarr-Proxy/{session.Client}");
|
||||||
|
|
||||||
// Connect to Jellyfin
|
// Connect to Jellyfin
|
||||||
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
|
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
|
||||||
_logger.LogWarning("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
|
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
|
||||||
|
|
||||||
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
|
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
|
||||||
// This tells Jellyfin to create/show the session in the dashboard
|
// This tells Jellyfin to create/show the session in the dashboard
|
||||||
@@ -272,13 +273,13 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}";
|
var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}";
|
||||||
var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
|
var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
|
||||||
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||||
_logger.LogWarning("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
|
_logger.LogDebug("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Also send SessionsStart to subscribe to session updates
|
// Also send SessionsStart to subscribe to session updates
|
||||||
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
|
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
|
||||||
messageBytes = Encoding.UTF8.GetBytes(sessionsStartMessage);
|
messageBytes = Encoding.UTF8.GetBytes(sessionsStartMessage);
|
||||||
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||||
_logger.LogWarning("📤 WEBSOCKET: Sent SessionsStart for {DeviceId}", deviceId);
|
_logger.LogDebug("📤 WEBSOCKET: Sent SessionsStart for {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Keep the WebSocket alive by reading messages and sending periodic keep-alive
|
// Keep the WebSocket alive by reading messages and sending periodic keep-alive
|
||||||
var buffer = new byte[1024 * 4];
|
var buffer = new byte[1024 * 4];
|
||||||
@@ -299,21 +300,30 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
|
|
||||||
if (result.MessageType == WebSocketMessageType.Close)
|
if (result.MessageType == WebSocketMessageType.Close)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("🔌 WEBSOCKET: Jellyfin closed WebSocket for device {DeviceId}", deviceId);
|
_logger.LogDebug("🔌 WEBSOCKET: Jellyfin closed WebSocket for device {DeviceId}", deviceId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log received messages for debugging
|
// Log received messages for debugging (only non-routine messages)
|
||||||
if (result.MessageType == WebSocketMessageType.Text)
|
if (result.MessageType == WebSocketMessageType.Text)
|
||||||
{
|
{
|
||||||
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||||
_logger.LogWarning("📥 WEBSOCKET: Received from Jellyfin for {DeviceId}: {Message}",
|
|
||||||
deviceId, message.Length > 100 ? message[..100] + "..." : message);
|
|
||||||
|
|
||||||
// Respond to KeepAlive requests from Jellyfin
|
// Respond to KeepAlive requests from Jellyfin
|
||||||
if (message.Contains("\"MessageType\":\"KeepAlive\""))
|
if (message.Contains("\"MessageType\":\"KeepAlive\""))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("💓 WEBSOCKET: Received KeepAlive from Jellyfin for {DeviceId}", deviceId);
|
_logger.LogDebug("💓 WEBSOCKET: Received KeepAlive from Jellyfin for {DeviceId}", deviceId);
|
||||||
|
}
|
||||||
|
else if (message.Contains("\"MessageType\":\"Sessions\""))
|
||||||
|
{
|
||||||
|
// Session updates are routine, log at debug level
|
||||||
|
_logger.LogDebug("📥 WEBSOCKET: Session update for {DeviceId}", deviceId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Log other message types at info level
|
||||||
|
_logger.LogInformation("📥 WEBSOCKET: {DeviceId}: {Message}",
|
||||||
|
deviceId, message.Length > 100 ? message[..100] + "..." : message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +338,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
var keepAliveMsg = "{\"MessageType\":\"KeepAlive\"}";
|
var keepAliveMsg = "{\"MessageType\":\"KeepAlive\"}";
|
||||||
var keepAliveBytes = Encoding.UTF8.GetBytes(keepAliveMsg);
|
var keepAliveBytes = Encoding.UTF8.GetBytes(keepAliveMsg);
|
||||||
await webSocket.SendAsync(new ArraySegment<byte>(keepAliveBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
await webSocket.SendAsync(new ArraySegment<byte>(keepAliveBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||||
_logger.LogWarning("💓 WEBSOCKET: Sent KeepAlive for {DeviceId}", deviceId);
|
_logger.LogDebug("💓 WEBSOCKET: Sent KeepAlive for {DeviceId}", deviceId);
|
||||||
lastKeepAlive = DateTime.UtcNow;
|
lastKeepAlive = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,7 +366,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
webSocket.Dispose();
|
webSocket.Dispose();
|
||||||
_logger.LogWarning("🧹 WEBSOCKET: Cleaned up WebSocket for device {DeviceId}", deviceId);
|
_logger.LogDebug("🧹 WEBSOCKET: Cleaned up WebSocket for device {DeviceId}", deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear WebSocket reference from session
|
// Clear WebSocket reference from session
|
||||||
@@ -369,6 +379,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Periodically pings Jellyfin to keep sessions alive.
|
/// Periodically pings Jellyfin to keep sessions alive.
|
||||||
|
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async void KeepSessionsAlive(object? state)
|
private async void KeepSessionsAlive(object? state)
|
||||||
{
|
{
|
||||||
@@ -380,19 +391,20 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogWarning("💓 SESSION: Keeping {Count} sessions alive", activeSessions.Count);
|
_logger.LogDebug("💓 SESSION: Keeping {Count} sessions alive", activeSessions.Count);
|
||||||
|
|
||||||
foreach (var session in activeSessions)
|
foreach (var session in activeSessions)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Post capabilities again to keep session alive
|
// Post capabilities again to keep session alive
|
||||||
|
// Note: This may fail with 401 if the client's token has expired
|
||||||
|
// That's okay - the WebSocket connection keeps the session alive anyway
|
||||||
await PostCapabilitiesAsync(session.Headers);
|
await PostCapabilitiesAsync(session.Headers);
|
||||||
_logger.LogWarning("✓ SESSION: Kept session alive for {DeviceId}", session.DeviceId);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "⚠️ SESSION: Error keeping session alive for {DeviceId}", session.DeviceId);
|
_logger.LogDebug(ex, "SESSION: Error keeping session alive for {DeviceId} (WebSocket still active)", session.DeviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +412,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(10)).ToList();
|
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(10)).ToList();
|
||||||
foreach (var stale in staleSessions)
|
foreach (var stale in staleSessions)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("🧹 SESSION: Removing stale session for {DeviceId}", stale.Key);
|
_logger.LogInformation("🧹 SESSION: Removing stale session for {DeviceId}", stale.Key);
|
||||||
_sessions.TryRemove(stale.Key, out _);
|
_sessions.TryRemove(stale.Key, out _);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
474
allstarr/Services/Lyrics/SpotifyLyricsService.cs
Normal file
474
allstarr/Services/Lyrics/SpotifyLyricsService.cs
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
using allstarr.Models.Lyrics;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
using allstarr.Services.Spotify;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Lyrics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for fetching synchronized lyrics from Spotify's internal color-lyrics API.
|
||||||
|
///
|
||||||
|
/// Spotify's lyrics API provides:
|
||||||
|
/// - Line-by-line synchronized lyrics with precise timestamps
|
||||||
|
/// - Word-level timing for karaoke-style display (syllable sync)
|
||||||
|
/// - Background color suggestions based on album art
|
||||||
|
/// - Support for multiple languages and translations
|
||||||
|
///
|
||||||
|
/// This requires the sp_dc session cookie for authentication.
|
||||||
|
/// </summary>
|
||||||
|
public class SpotifyLyricsService
|
||||||
|
{
|
||||||
|
private readonly ILogger<SpotifyLyricsService> _logger;
|
||||||
|
private readonly SpotifyApiSettings _settings;
|
||||||
|
private readonly SpotifyApiClient _spotifyClient;
|
||||||
|
private readonly RedisCacheService _cache;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
private const string LyricsApiBase = "https://spclient.wg.spotify.com/color-lyrics/v2/track";
|
||||||
|
|
||||||
|
public SpotifyLyricsService(
|
||||||
|
ILogger<SpotifyLyricsService> logger,
|
||||||
|
IOptions<SpotifyApiSettings> settings,
|
||||||
|
SpotifyApiClient spotifyClient,
|
||||||
|
RedisCacheService cache,
|
||||||
|
IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_settings = settings.Value;
|
||||||
|
_spotifyClient = spotifyClient;
|
||||||
|
_cache = cache;
|
||||||
|
|
||||||
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0");
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("App-Platform", "WebPlayer");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets synchronized lyrics for a Spotify track by its ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="spotifyTrackId">Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL")</param>
|
||||||
|
/// <returns>Lyrics info with synced lyrics in LRC format, or null if not available</returns>
|
||||||
|
public async Task<SpotifyLyricsResult?> GetLyricsByTrackIdAsync(string spotifyTrackId)
|
||||||
|
{
|
||||||
|
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Spotify API not enabled or no session cookie configured");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize track ID (remove URI prefix if present)
|
||||||
|
spotifyTrackId = ExtractTrackId(spotifyTrackId);
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
var cacheKey = $"spotify:lyrics:{spotifyTrackId}";
|
||||||
|
var cached = await _cache.GetAsync<SpotifyLyricsResult>(cacheKey);
|
||||||
|
if (cached != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Returning cached Spotify lyrics for track {TrackId}", spotifyTrackId);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get access token
|
||||||
|
var token = await _spotifyClient.GetWebAccessTokenAsync();
|
||||||
|
if (string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Could not get Spotify access token for lyrics");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request lyrics from Spotify's color-lyrics API
|
||||||
|
var url = $"{LyricsApiBase}/{spotifyTrackId}?format=json&vocalRemoval=false&market=from_token";
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
request.Headers.Add("Accept", "application/json");
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No lyrics found on Spotify for track {TrackId}", spotifyTrackId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Spotify lyrics API returned {StatusCode} for track {TrackId}",
|
||||||
|
response.StatusCode, spotifyTrackId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var result = ParseLyricsResponse(json, spotifyTrackId);
|
||||||
|
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
// Cache for 30 days (lyrics don't change)
|
||||||
|
await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30));
|
||||||
|
_logger.LogInformation("Cached Spotify lyrics for track {TrackId} ({LineCount} lines)",
|
||||||
|
spotifyTrackId, result.Lines.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching Spotify lyrics for track {TrackId}", spotifyTrackId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches for a track on Spotify and returns its lyrics.
|
||||||
|
/// Useful when you have track metadata but not a Spotify ID.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SpotifyLyricsResult?> SearchAndGetLyricsAsync(
|
||||||
|
string trackName,
|
||||||
|
string artistName,
|
||||||
|
string? albumName = null,
|
||||||
|
int? durationMs = null)
|
||||||
|
{
|
||||||
|
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = await _spotifyClient.GetWebAccessTokenAsync();
|
||||||
|
if (string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for the track
|
||||||
|
var query = $"track:{trackName} artist:{artistName}";
|
||||||
|
if (!string.IsNullOrEmpty(albumName))
|
||||||
|
{
|
||||||
|
query += $" album:{albumName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchUrl = $"https://api.spotify.com/v1/search?q={Uri.EscapeDataString(query)}&type=track&limit=5";
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, searchUrl);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
if (!root.TryGetProperty("tracks", out var tracks) ||
|
||||||
|
!tracks.TryGetProperty("items", out var items) ||
|
||||||
|
items.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find best match considering duration if provided
|
||||||
|
string? bestMatchId = null;
|
||||||
|
var bestScore = 0;
|
||||||
|
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
|
||||||
|
if (string.IsNullOrEmpty(id)) continue;
|
||||||
|
|
||||||
|
var score = 100; // Base score
|
||||||
|
|
||||||
|
// Check duration match
|
||||||
|
if (durationMs.HasValue && item.TryGetProperty("duration_ms", out var durProp))
|
||||||
|
{
|
||||||
|
var trackDuration = durProp.GetInt32();
|
||||||
|
var durationDiff = Math.Abs(trackDuration - durationMs.Value);
|
||||||
|
if (durationDiff < 2000) score += 50; // Within 2 seconds
|
||||||
|
else if (durationDiff < 5000) score += 25; // Within 5 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > bestScore)
|
||||||
|
{
|
||||||
|
bestScore = score;
|
||||||
|
bestMatchId = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(bestMatchId))
|
||||||
|
{
|
||||||
|
return await GetLyricsByTrackIdAsync(bestMatchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error searching Spotify for lyrics: {Track} - {Artist}", trackName, artistName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts Spotify lyrics to LRCLIB-compatible LyricsInfo format.
|
||||||
|
/// </summary>
|
||||||
|
public LyricsInfo? ToLyricsInfo(SpotifyLyricsResult spotifyLyrics)
|
||||||
|
{
|
||||||
|
if (spotifyLyrics.Lines.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build synced lyrics in LRC format
|
||||||
|
var lrcLines = new List<string>();
|
||||||
|
foreach (var line in spotifyLyrics.Lines)
|
||||||
|
{
|
||||||
|
var timestamp = TimeSpan.FromMilliseconds(line.StartTimeMs);
|
||||||
|
var mm = (int)timestamp.TotalMinutes;
|
||||||
|
var ss = timestamp.Seconds;
|
||||||
|
var ms = timestamp.Milliseconds / 10; // LRC uses centiseconds
|
||||||
|
|
||||||
|
lrcLines.Add($"[{mm:D2}:{ss:D2}.{ms:D2}]{line.Words}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LyricsInfo
|
||||||
|
{
|
||||||
|
TrackName = spotifyLyrics.TrackName ?? "",
|
||||||
|
ArtistName = spotifyLyrics.ArtistName ?? "",
|
||||||
|
AlbumName = spotifyLyrics.AlbumName ?? "",
|
||||||
|
Duration = (int)(spotifyLyrics.DurationMs / 1000),
|
||||||
|
Instrumental = spotifyLyrics.Lines.Count == 0,
|
||||||
|
SyncedLyrics = string.Join("\n", lrcLines),
|
||||||
|
PlainLyrics = string.Join("\n", spotifyLyrics.Lines.Select(l => l.Words))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpotifyLyricsResult? ParseLyricsResponse(string json, string trackId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var result = new SpotifyLyricsResult
|
||||||
|
{
|
||||||
|
SpotifyTrackId = trackId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse lyrics lines
|
||||||
|
if (root.TryGetProperty("lyrics", out var lyrics))
|
||||||
|
{
|
||||||
|
// Check sync type
|
||||||
|
if (lyrics.TryGetProperty("syncType", out var syncType))
|
||||||
|
{
|
||||||
|
result.SyncType = syncType.GetString() ?? "LINE_SYNCED";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse lines
|
||||||
|
if (lyrics.TryGetProperty("lines", out var lines))
|
||||||
|
{
|
||||||
|
foreach (var line in lines.EnumerateArray())
|
||||||
|
{
|
||||||
|
var lyricsLine = new SpotifyLyricsLine
|
||||||
|
{
|
||||||
|
StartTimeMs = line.TryGetProperty("startTimeMs", out var start)
|
||||||
|
? long.Parse(start.GetString() ?? "0") : 0,
|
||||||
|
Words = line.TryGetProperty("words", out var words)
|
||||||
|
? words.GetString() ?? "" : "",
|
||||||
|
EndTimeMs = line.TryGetProperty("endTimeMs", out var end)
|
||||||
|
? long.Parse(end.GetString() ?? "0") : 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse syllables if available (for word-level sync)
|
||||||
|
if (line.TryGetProperty("syllables", out var syllables))
|
||||||
|
{
|
||||||
|
foreach (var syllable in syllables.EnumerateArray())
|
||||||
|
{
|
||||||
|
lyricsLine.Syllables.Add(new SpotifyLyricsSyllable
|
||||||
|
{
|
||||||
|
StartTimeMs = syllable.TryGetProperty("startTimeMs", out var sStart)
|
||||||
|
? long.Parse(sStart.GetString() ?? "0") : 0,
|
||||||
|
Text = syllable.TryGetProperty("charsIndex", out var text)
|
||||||
|
? text.GetString() ?? "" : ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Lines.Add(lyricsLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse color information
|
||||||
|
if (lyrics.TryGetProperty("colors", out var colors))
|
||||||
|
{
|
||||||
|
result.Colors = new SpotifyLyricsColors
|
||||||
|
{
|
||||||
|
Background = colors.TryGetProperty("background", out var bg)
|
||||||
|
? ParseColorValue(bg) : null,
|
||||||
|
Text = colors.TryGetProperty("text", out var txt)
|
||||||
|
? ParseColorValue(txt) : null,
|
||||||
|
HighlightText = colors.TryGetProperty("highlightText", out var ht)
|
||||||
|
? ParseColorValue(ht) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language
|
||||||
|
if (lyrics.TryGetProperty("language", out var lang))
|
||||||
|
{
|
||||||
|
result.Language = lang.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider info
|
||||||
|
if (lyrics.TryGetProperty("provider", out var provider))
|
||||||
|
{
|
||||||
|
result.Provider = provider.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display info
|
||||||
|
if (lyrics.TryGetProperty("providerDisplayName", out var providerDisplay))
|
||||||
|
{
|
||||||
|
result.ProviderDisplayName = providerDisplay.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error parsing Spotify lyrics response");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ParseColorValue(JsonElement element)
|
||||||
|
{
|
||||||
|
if (element.ValueKind == JsonValueKind.Number)
|
||||||
|
{
|
||||||
|
return element.GetInt32();
|
||||||
|
}
|
||||||
|
if (element.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var str = element.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(str) && int.TryParse(str, out var val))
|
||||||
|
{
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractTrackId(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(input)) return input;
|
||||||
|
|
||||||
|
// Handle spotify:track:xxxxx format
|
||||||
|
if (input.StartsWith("spotify:track:"))
|
||||||
|
{
|
||||||
|
return input.Substring("spotify:track:".Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle https://open.spotify.com/track/xxxxx format
|
||||||
|
if (input.Contains("open.spotify.com/track/"))
|
||||||
|
{
|
||||||
|
var start = input.IndexOf("/track/") + "/track/".Length;
|
||||||
|
var end = input.IndexOf('?', start);
|
||||||
|
return end > 0 ? input.Substring(start, end - start) : input.Substring(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result from Spotify's color-lyrics API.
|
||||||
|
/// </summary>
|
||||||
|
public class SpotifyLyricsResult
|
||||||
|
{
|
||||||
|
public string SpotifyTrackId { get; set; } = string.Empty;
|
||||||
|
public string? TrackName { get; set; }
|
||||||
|
public string? ArtistName { get; set; }
|
||||||
|
public string? AlbumName { get; set; }
|
||||||
|
public long DurationMs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sync type: "LINE_SYNCED", "SYLLABLE_SYNCED", or "UNSYNCED"
|
||||||
|
/// </summary>
|
||||||
|
public string SyncType { get; set; } = "LINE_SYNCED";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Language code (e.g., "en", "es", "ja")
|
||||||
|
/// </summary>
|
||||||
|
public string? Language { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lyrics provider (e.g., "MusixMatch", "Spotify")
|
||||||
|
/// </summary>
|
||||||
|
public string? Provider { get; set; }
|
||||||
|
|
||||||
|
public string? ProviderDisplayName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lyrics lines in order
|
||||||
|
/// </summary>
|
||||||
|
public List<SpotifyLyricsLine> Lines { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Color suggestions based on album art
|
||||||
|
/// </summary>
|
||||||
|
public SpotifyLyricsColors? Colors { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SpotifyLyricsLine
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Start time in milliseconds
|
||||||
|
/// </summary>
|
||||||
|
public long StartTimeMs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End time in milliseconds
|
||||||
|
/// </summary>
|
||||||
|
public long EndTimeMs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The lyrics text for this line
|
||||||
|
/// </summary>
|
||||||
|
public string Words { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Syllable-level timing for karaoke display (if available)
|
||||||
|
/// </summary>
|
||||||
|
public List<SpotifyLyricsSyllable> Syllables { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SpotifyLyricsSyllable
|
||||||
|
{
|
||||||
|
public long StartTimeMs { get; set; }
|
||||||
|
public string Text { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SpotifyLyricsColors
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Suggested background color (ARGB integer)
|
||||||
|
/// </summary>
|
||||||
|
public int? Background { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggested text color (ARGB integer)
|
||||||
|
/// </summary>
|
||||||
|
public int? Text { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggested highlight/active text color (ARGB integer)
|
||||||
|
/// </summary>
|
||||||
|
public int? HighlightText { get; set; }
|
||||||
|
}
|
||||||
900
allstarr/Services/Spotify/SpotifyApiClient.cs
Normal file
900
allstarr/Services/Spotify/SpotifyApiClient.cs
Normal file
@@ -0,0 +1,900 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Models.Spotify;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using OtpNet;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client for accessing Spotify's APIs directly.
|
||||||
|
///
|
||||||
|
/// Supports two modes:
|
||||||
|
/// 1. Official API - For public playlists and standard operations
|
||||||
|
/// 2. Web API (with session cookie) - For editorial/personalized playlists like Release Radar, Discover Weekly
|
||||||
|
///
|
||||||
|
/// The session cookie (sp_dc) is required because Spotify's official API doesn't expose
|
||||||
|
/// algorithmically generated "Made For You" playlists.
|
||||||
|
///
|
||||||
|
/// Uses TOTP-based authentication similar to the Jellyfin Spotify Import plugin.
|
||||||
|
/// </summary>
|
||||||
|
public class SpotifyApiClient : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILogger<SpotifyApiClient> _logger;
|
||||||
|
private readonly SpotifyApiSettings _settings;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly HttpClient _webApiClient;
|
||||||
|
private readonly CookieContainer _cookieContainer;
|
||||||
|
|
||||||
|
// Spotify API endpoints
|
||||||
|
private const string OfficialApiBase = "https://api.spotify.com/v1";
|
||||||
|
private const string WebApiBase = "https://api-partner.spotify.com/pathfinder/v1";
|
||||||
|
private const string SpotifyBaseUrl = "https://open.spotify.com";
|
||||||
|
private const string TokenEndpoint = "https://open.spotify.com/api/token";
|
||||||
|
|
||||||
|
// URL for pre-scraped TOTP secrets (same as Jellyfin plugin uses)
|
||||||
|
private const string TotpSecretsUrl = "https://raw.githubusercontent.com/xyloflake/spot-secrets-go/refs/heads/main/secrets/secretBytes.json";
|
||||||
|
|
||||||
|
// Web API access token (obtained via session cookie)
|
||||||
|
private string? _webAccessToken;
|
||||||
|
private DateTime _webTokenExpiry = DateTime.MinValue;
|
||||||
|
private readonly SemaphoreSlim _tokenLock = new(1, 1);
|
||||||
|
|
||||||
|
// Cached TOTP secrets
|
||||||
|
private TotpSecret? _cachedTotpSecret;
|
||||||
|
private DateTime _totpSecretFetchedAt = DateTime.MinValue;
|
||||||
|
|
||||||
|
public SpotifyApiClient(
|
||||||
|
ILogger<SpotifyApiClient> logger,
|
||||||
|
IOptions<SpotifyApiSettings> settings)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_settings = settings.Value;
|
||||||
|
|
||||||
|
// Client for official API
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(OfficialApiBase),
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Client for web API (requires session cookie)
|
||||||
|
_cookieContainer = new CookieContainer();
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
UseCookies = true,
|
||||||
|
CookieContainer = _cookieContainer
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(_settings.SessionCookie))
|
||||||
|
{
|
||||||
|
_cookieContainer.SetCookies(
|
||||||
|
new Uri(SpotifyBaseUrl),
|
||||||
|
$"sp_dc={_settings.SessionCookie}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_webApiClient = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common headers for web API
|
||||||
|
_webApiClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36");
|
||||||
|
_webApiClient.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||||
|
_webApiClient.DefaultRequestHeaders.Add("Accept-Language", "en-US");
|
||||||
|
_webApiClient.DefaultRequestHeaders.Add("app-platform", "WebPlayer");
|
||||||
|
_webApiClient.DefaultRequestHeaders.Add("spotify-app-version", "1.2.46.25.g7f189073");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an access token using the session cookie and TOTP authentication.
|
||||||
|
/// This token can be used for both the official API and web API.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> GetWebAccessTokenAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_settings.SessionCookie))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No Spotify session cookie configured");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _tokenLock.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Return cached token if still valid
|
||||||
|
if (!string.IsNullOrEmpty(_webAccessToken) && DateTime.UtcNow < _webTokenExpiry)
|
||||||
|
{
|
||||||
|
return _webAccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Fetching new Spotify web access token using TOTP authentication");
|
||||||
|
|
||||||
|
// Fetch TOTP secrets if needed
|
||||||
|
var totpSecret = await GetTotpSecretAsync(cancellationToken);
|
||||||
|
if (totpSecret == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to get TOTP secrets");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate TOTP
|
||||||
|
var totpResult = await GenerateTotpAsync(totpSecret, cancellationToken);
|
||||||
|
if (totpResult == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to generate TOTP");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (otp, serverTime) = totpResult.Value;
|
||||||
|
var clientTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
// Build token URL with TOTP parameters
|
||||||
|
var tokenUrl = $"{TokenEndpoint}?reason=init&productType=web-player&totp={otp}&totpServer={otp}&totpVer={totpSecret.Version}&sTime={serverTime}&cTime={clientTime}";
|
||||||
|
|
||||||
|
_logger.LogDebug("Requesting token from: {Url}", tokenUrl.Replace(otp, "***"));
|
||||||
|
|
||||||
|
var response = await _webApiClient.GetAsync(tokenUrl, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
_logger.LogError("Failed to get Spotify access token: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var tokenResponse = JsonSerializer.Deserialize<SpotifyTokenResponse>(json);
|
||||||
|
|
||||||
|
if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken))
|
||||||
|
{
|
||||||
|
_logger.LogError("No access token in Spotify response: {Json}", json);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenResponse.IsAnonymous)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Spotify returned anonymous token - session cookie may be invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
_webAccessToken = tokenResponse.AccessToken;
|
||||||
|
|
||||||
|
// Token typically expires in 1 hour, but we'll refresh early
|
||||||
|
if (tokenResponse.ExpirationTimestampMs > 0)
|
||||||
|
{
|
||||||
|
_webTokenExpiry = DateTimeOffset.FromUnixTimeMilliseconds(tokenResponse.ExpirationTimestampMs).UtcDateTime;
|
||||||
|
// Refresh 5 minutes early
|
||||||
|
_webTokenExpiry = _webTokenExpiry.AddMinutes(-5);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_webTokenExpiry = DateTime.UtcNow.AddMinutes(55);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}, anonymous: {IsAnonymous}",
|
||||||
|
_webTokenExpiry, tokenResponse.IsAnonymous);
|
||||||
|
return _webAccessToken;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error getting Spotify web access token");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_tokenLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches TOTP secrets from the pre-scraped secrets repository.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<TotpSecret?> GetTotpSecretAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Return cached secret if fresh (cache for 1 hour)
|
||||||
|
if (_cachedTotpSecret != null && DateTime.UtcNow - _totpSecretFetchedAt < TimeSpan.FromHours(1))
|
||||||
|
{
|
||||||
|
return _cachedTotpSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Fetching TOTP secrets from {Url}", TotpSecretsUrl);
|
||||||
|
|
||||||
|
var response = await _webApiClient.GetAsync(TotpSecretsUrl, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to fetch TOTP secrets: {StatusCode}", response.StatusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var secrets = JsonSerializer.Deserialize<TotpSecret[]>(json);
|
||||||
|
|
||||||
|
if (secrets == null || secrets.Length == 0)
|
||||||
|
{
|
||||||
|
_logger.LogError("No TOTP secrets found in response");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the newest version
|
||||||
|
_cachedTotpSecret = secrets.OrderByDescending(s => s.Version).First();
|
||||||
|
_totpSecretFetchedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
_logger.LogDebug("Got TOTP secret version {Version}", _cachedTotpSecret.Version);
|
||||||
|
return _cachedTotpSecret;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching TOTP secrets");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a TOTP code using the secret and server time.
|
||||||
|
/// Based on the Jellyfin plugin implementation.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(string Otp, long ServerTime)?> GenerateTotpAsync(TotpSecret secret, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get server time from Spotify via HEAD request
|
||||||
|
var headRequest = new HttpRequestMessage(HttpMethod.Head, SpotifyBaseUrl);
|
||||||
|
var response = await _webApiClient.SendAsync(headRequest, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to get Spotify server time: {StatusCode}", response.StatusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var serverTime = response.Headers.Date?.ToUnixTimeSeconds();
|
||||||
|
if (serverTime == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("No Date header in Spotify response");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute secret from cipher bytes
|
||||||
|
// The secret bytes need to be transformed: XOR each byte with ((index % 33) + 9)
|
||||||
|
var cipherBytes = secret.Secret.ToArray();
|
||||||
|
var transformedBytes = cipherBytes.Select((b, i) => (byte)(b ^ ((i % 33) + 9))).ToArray();
|
||||||
|
|
||||||
|
// Convert to UTF-8 string representation then back to bytes for TOTP
|
||||||
|
var transformedString = string.Join("", transformedBytes.Select(b => b.ToString()));
|
||||||
|
var utf8Bytes = Encoding.UTF8.GetBytes(transformedString);
|
||||||
|
|
||||||
|
// Generate TOTP
|
||||||
|
var totp = new Totp(utf8Bytes, step: 30, totpSize: 6);
|
||||||
|
var otp = totp.ComputeTotp(DateTime.UnixEpoch.AddSeconds(serverTime.Value));
|
||||||
|
|
||||||
|
_logger.LogDebug("Generated TOTP for server time {ServerTime}", serverTime.Value);
|
||||||
|
return (otp, serverTime.Value);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error generating TOTP");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches a playlist with all its tracks from Spotify using the GraphQL API.
|
||||||
|
/// This matches the approach used by the Jellyfin Spotify Import plugin.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="playlistId">Spotify playlist ID or URI</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token</param>
|
||||||
|
/// <returns>Playlist with tracks in correct order, or null if not found</returns>
|
||||||
|
public async Task<SpotifyPlaylist?> GetPlaylistAsync(string playlistId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Extract ID from URI if needed (spotify:playlist:xxxxx or https://open.spotify.com/playlist/xxxxx)
|
||||||
|
playlistId = ExtractPlaylistId(playlistId);
|
||||||
|
|
||||||
|
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||||
|
if (string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
_logger.LogError("Cannot fetch playlist without access token");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Use GraphQL API (same as Jellyfin plugin) - more reliable and less rate-limited
|
||||||
|
return await FetchPlaylistViaGraphQLAsync(playlistId, token, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching playlist {PlaylistId}", playlistId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetch playlist using Spotify's GraphQL API (api-partner.spotify.com/pathfinder/v1/query)
|
||||||
|
/// This is the same approach used by the Jellyfin Spotify Import plugin
|
||||||
|
/// </summary>
|
||||||
|
private async Task<SpotifyPlaylist?> FetchPlaylistViaGraphQLAsync(
|
||||||
|
string playlistId,
|
||||||
|
string token,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const int pageLimit = 50;
|
||||||
|
var offset = 0;
|
||||||
|
var totalTrackCount = pageLimit;
|
||||||
|
var tracks = new List<SpotifyPlaylistTrack>();
|
||||||
|
|
||||||
|
SpotifyPlaylist? playlist = null;
|
||||||
|
|
||||||
|
while (tracks.Count < totalTrackCount && offset < totalTrackCount)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
// Build GraphQL query URL (same as Jellyfin plugin)
|
||||||
|
var queryParams = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "operationName", "fetchPlaylist" },
|
||||||
|
{ "variables", $"{{\"uri\":\"spotify:playlist:{playlistId}\",\"offset\":{offset},\"limit\":{pageLimit}}}" },
|
||||||
|
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"19ff1327c29e99c208c86d7a9d8f1929cfdf3d3202a0ff4253c821f1901aa94d\"}}" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
||||||
|
var url = $"{WebApiBase}/query?{queryString}";
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
if (!doc.RootElement.TryGetProperty("data", out var data) ||
|
||||||
|
!data.TryGetProperty("playlistV2", out var playlistV2))
|
||||||
|
{
|
||||||
|
_logger.LogError("Invalid GraphQL response structure");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse playlist metadata on first iteration
|
||||||
|
if (playlist == null)
|
||||||
|
{
|
||||||
|
playlist = ParseGraphQLPlaylist(playlistV2, playlistId);
|
||||||
|
if (playlist == null) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse tracks from this page
|
||||||
|
if (playlistV2.TryGetProperty("content", out var content))
|
||||||
|
{
|
||||||
|
if (content.TryGetProperty("totalCount", out var totalCount))
|
||||||
|
{
|
||||||
|
totalTrackCount = totalCount.GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.TryGetProperty("items", out var items))
|
||||||
|
{
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
var track = ParseGraphQLTrack(item, offset + tracks.Count);
|
||||||
|
if (track != null)
|
||||||
|
{
|
||||||
|
tracks.Add(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += pageLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlist != null)
|
||||||
|
{
|
||||||
|
playlist.Tracks = tracks;
|
||||||
|
playlist.TotalTracks = tracks.Count;
|
||||||
|
_logger.LogInformation("Fetched playlist '{Name}' with {Count} tracks via GraphQL", playlist.Name, tracks.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpotifyPlaylist? ParseGraphQLPlaylist(JsonElement playlistV2, string playlistId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var name = playlistV2.TryGetProperty("name", out var n) ? n.GetString() : "Unknown Playlist";
|
||||||
|
var description = playlistV2.TryGetProperty("description", out var d) ? d.GetString() : null;
|
||||||
|
|
||||||
|
string? ownerName = null;
|
||||||
|
if (playlistV2.TryGetProperty("ownerV2", out var owner) &&
|
||||||
|
owner.TryGetProperty("data", out var ownerData) &&
|
||||||
|
ownerData.TryGetProperty("name", out var ownerNameProp))
|
||||||
|
{
|
||||||
|
ownerName = ownerNameProp.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SpotifyPlaylist
|
||||||
|
{
|
||||||
|
SpotifyId = playlistId,
|
||||||
|
Name = name ?? "Unknown Playlist",
|
||||||
|
Description = description,
|
||||||
|
OwnerName = ownerName,
|
||||||
|
FetchedAt = DateTime.UtcNow,
|
||||||
|
Tracks = new List<SpotifyPlaylistTrack>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to parse GraphQL playlist metadata");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpotifyPlaylistTrack? ParseGraphQLTrack(JsonElement item, int position)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!item.TryGetProperty("itemV2", out var itemV2) ||
|
||||||
|
!itemV2.TryGetProperty("data", out var data))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trackId = data.TryGetProperty("uri", out var uri) ? uri.GetString()?.Replace("spotify:track:", "") : null;
|
||||||
|
var name = data.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(trackId) || string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse artists
|
||||||
|
var artists = new List<string>();
|
||||||
|
if (data.TryGetProperty("artists", out var artistsObj) &&
|
||||||
|
artistsObj.TryGetProperty("items", out var artistItems))
|
||||||
|
{
|
||||||
|
foreach (var artist in artistItems.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (artist.TryGetProperty("profile", out var profile) &&
|
||||||
|
profile.TryGetProperty("name", out var artistName))
|
||||||
|
{
|
||||||
|
var artistNameStr = artistName.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(artistNameStr))
|
||||||
|
{
|
||||||
|
artists.Add(artistNameStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse album
|
||||||
|
string? albumName = null;
|
||||||
|
if (data.TryGetProperty("albumOfTrack", out var album) &&
|
||||||
|
album.TryGetProperty("name", out var albumNameProp))
|
||||||
|
{
|
||||||
|
albumName = albumNameProp.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse duration
|
||||||
|
int durationMs = 0;
|
||||||
|
if (data.TryGetProperty("trackDuration", out var duration) &&
|
||||||
|
duration.TryGetProperty("totalMilliseconds", out var durationMsProp))
|
||||||
|
{
|
||||||
|
durationMs = durationMsProp.GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse album art
|
||||||
|
string? albumArtUrl = null;
|
||||||
|
if (data.TryGetProperty("albumOfTrack", out var albumOfTrack) &&
|
||||||
|
albumOfTrack.TryGetProperty("coverArt", out var coverArt) &&
|
||||||
|
coverArt.TryGetProperty("sources", out var sources) &&
|
||||||
|
sources.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var firstSource = sources[0];
|
||||||
|
if (firstSource.TryGetProperty("url", out var urlProp))
|
||||||
|
{
|
||||||
|
albumArtUrl = urlProp.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SpotifyPlaylistTrack
|
||||||
|
{
|
||||||
|
SpotifyId = trackId,
|
||||||
|
Title = name,
|
||||||
|
Artists = artists,
|
||||||
|
Album = albumName ?? string.Empty,
|
||||||
|
DurationMs = durationMs,
|
||||||
|
Position = position,
|
||||||
|
AlbumArtUrl = albumArtUrl,
|
||||||
|
Isrc = null // GraphQL doesn't return ISRC, we'll fetch it separately if needed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to parse GraphQL track");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SpotifyPlaylist?> FetchPlaylistMetadataAsync(
|
||||||
|
string playlistId,
|
||||||
|
string token,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var url = $"{OfficialApiBase}/playlists/{playlistId}?fields=id,name,description,owner(display_name,id),images,collaborative,public,snapshot_id,tracks.total";
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to fetch playlist metadata: {StatusCode}", response.StatusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var playlist = new SpotifyPlaylist
|
||||||
|
{
|
||||||
|
SpotifyId = root.GetProperty("id").GetString() ?? playlistId,
|
||||||
|
Name = root.GetProperty("name").GetString() ?? "Unknown Playlist",
|
||||||
|
Description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||||
|
SnapshotId = root.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null,
|
||||||
|
Collaborative = root.TryGetProperty("collaborative", out var collab) && collab.GetBoolean(),
|
||||||
|
Public = root.TryGetProperty("public", out var pub) && pub.ValueKind != JsonValueKind.Null && pub.GetBoolean(),
|
||||||
|
FetchedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
if (root.TryGetProperty("owner", out var owner))
|
||||||
|
{
|
||||||
|
playlist.OwnerName = owner.TryGetProperty("display_name", out var dn) ? dn.GetString() : null;
|
||||||
|
playlist.OwnerId = owner.TryGetProperty("id", out var oid) ? oid.GetString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.TryGetProperty("images", out var images) && images.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
playlist.ImageUrl = images[0].GetProperty("url").GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("total", out var total))
|
||||||
|
{
|
||||||
|
playlist.TotalTracks = total.GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<SpotifyPlaylistTrack>> FetchAllPlaylistTracksAsync(
|
||||||
|
string playlistId,
|
||||||
|
string token,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var allTracks = new List<SpotifyPlaylistTrack>();
|
||||||
|
var offset = 0;
|
||||||
|
const int limit = 100; // Spotify's max
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var tracks = await FetchPlaylistTracksPageAsync(playlistId, token, offset, limit, cancellationToken);
|
||||||
|
if (tracks == null || tracks.Count == 0) break;
|
||||||
|
|
||||||
|
allTracks.AddRange(tracks);
|
||||||
|
|
||||||
|
if (tracks.Count < limit) break;
|
||||||
|
|
||||||
|
offset += limit;
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
if (_settings.RateLimitDelayMs > 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<SpotifyPlaylistTrack>?> FetchPlaylistTracksPageAsync(
|
||||||
|
string playlistId,
|
||||||
|
string token,
|
||||||
|
int offset,
|
||||||
|
int limit,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Request fields needed for matching and ordering
|
||||||
|
var fields = "items(added_at,track(id,name,album(id,name,images,release_date),artists(id,name),duration_ms,explicit,popularity,preview_url,disc_number,track_number,external_ids))";
|
||||||
|
var url = $"{OfficialApiBase}/playlists/{playlistId}/tracks?offset={offset}&limit={limit}&fields={fields}";
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to fetch playlist tracks: {StatusCode}", response.StatusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
if (!root.TryGetProperty("items", out var items))
|
||||||
|
{
|
||||||
|
return new List<SpotifyPlaylistTrack>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tracks = new List<SpotifyPlaylistTrack>();
|
||||||
|
var position = offset;
|
||||||
|
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
// Skip null tracks (can happen with deleted/unavailable tracks)
|
||||||
|
if (!item.TryGetProperty("track", out var trackElement) ||
|
||||||
|
trackElement.ValueKind == JsonValueKind.Null)
|
||||||
|
{
|
||||||
|
position++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var track = ParseTrack(trackElement, position);
|
||||||
|
|
||||||
|
// Parse added_at timestamp
|
||||||
|
if (item.TryGetProperty("added_at", out var addedAt) &&
|
||||||
|
addedAt.ValueKind != JsonValueKind.Null)
|
||||||
|
{
|
||||||
|
var addedAtStr = addedAt.GetString();
|
||||||
|
if (DateTime.TryParse(addedAtStr, out var addedAtDate))
|
||||||
|
{
|
||||||
|
track.AddedAt = addedAtDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks.Add(track);
|
||||||
|
position++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpotifyPlaylistTrack ParseTrack(JsonElement track, int position)
|
||||||
|
{
|
||||||
|
var result = new SpotifyPlaylistTrack
|
||||||
|
{
|
||||||
|
Position = position,
|
||||||
|
SpotifyId = track.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "",
|
||||||
|
Title = track.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "",
|
||||||
|
DurationMs = track.TryGetProperty("duration_ms", out var dur) ? dur.GetInt32() : 0,
|
||||||
|
Explicit = track.TryGetProperty("explicit", out var exp) && exp.GetBoolean(),
|
||||||
|
Popularity = track.TryGetProperty("popularity", out var pop) ? pop.GetInt32() : 0,
|
||||||
|
PreviewUrl = track.TryGetProperty("preview_url", out var prev) && prev.ValueKind != JsonValueKind.Null
|
||||||
|
? prev.GetString() : null,
|
||||||
|
DiscNumber = track.TryGetProperty("disc_number", out var disc) ? disc.GetInt32() : 1,
|
||||||
|
TrackNumber = track.TryGetProperty("track_number", out var tn) ? tn.GetInt32() : 1
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse album
|
||||||
|
if (track.TryGetProperty("album", out var album))
|
||||||
|
{
|
||||||
|
result.Album = album.TryGetProperty("name", out var albumName)
|
||||||
|
? albumName.GetString() ?? "" : "";
|
||||||
|
result.AlbumId = album.TryGetProperty("id", out var albumId)
|
||||||
|
? albumId.GetString() ?? "" : "";
|
||||||
|
result.ReleaseDate = album.TryGetProperty("release_date", out var rd)
|
||||||
|
? rd.GetString() : null;
|
||||||
|
|
||||||
|
if (album.TryGetProperty("images", out var images) && images.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
result.AlbumArtUrl = images[0].GetProperty("url").GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse artists
|
||||||
|
if (track.TryGetProperty("artists", out var artists))
|
||||||
|
{
|
||||||
|
foreach (var artist in artists.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (artist.TryGetProperty("name", out var artistName))
|
||||||
|
{
|
||||||
|
result.Artists.Add(artistName.GetString() ?? "");
|
||||||
|
}
|
||||||
|
if (artist.TryGetProperty("id", out var artistId))
|
||||||
|
{
|
||||||
|
result.ArtistIds.Add(artistId.GetString() ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse ISRC from external_ids
|
||||||
|
if (track.TryGetProperty("external_ids", out var externalIds) &&
|
||||||
|
externalIds.TryGetProperty("isrc", out var isrc))
|
||||||
|
{
|
||||||
|
result.Isrc = isrc.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches for a user's playlists by name.
|
||||||
|
/// Useful for finding playlists like "Release Radar" or "Discover Weekly" by their names.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
||||||
|
string searchName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||||
|
if (string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
return new List<SpotifyPlaylist>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var playlists = new List<SpotifyPlaylist>();
|
||||||
|
var offset = 0;
|
||||||
|
const int limit = 50;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}";
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode) break;
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||||
|
|
||||||
|
// Check if name matches (case-insensitive)
|
||||||
|
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
playlists.Add(new SpotifyPlaylist
|
||||||
|
{
|
||||||
|
SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "",
|
||||||
|
Name = itemName,
|
||||||
|
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||||
|
TotalTracks = item.TryGetProperty("tracks", out var tracks) &&
|
||||||
|
tracks.TryGetProperty("total", out var total)
|
||||||
|
? total.GetInt32() : 0,
|
||||||
|
SnapshotId = item.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.GetArrayLength() < limit) break;
|
||||||
|
offset += limit;
|
||||||
|
|
||||||
|
if (_settings.RateLimitDelayMs > 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlists;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error searching user playlists for '{SearchName}'", searchName);
|
||||||
|
return new List<SpotifyPlaylist>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current user's profile to verify authentication is working.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(bool Success, string? UserId, string? DisplayName)> GetCurrentUserAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||||
|
if (string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
return (false, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, $"{OfficialApiBase}/me");
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
_logger.LogWarning("Spotify /me endpoint returned {StatusCode}: {Body}", response.StatusCode, errorBody);
|
||||||
|
return (false, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var userId = root.TryGetProperty("id", out var id) ? id.GetString() : null;
|
||||||
|
var displayName = root.TryGetProperty("display_name", out var dn) ? dn.GetString() : null;
|
||||||
|
|
||||||
|
return (true, userId, displayName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error getting current Spotify user");
|
||||||
|
return (false, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractPlaylistId(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(input)) return input;
|
||||||
|
|
||||||
|
// Handle spotify:playlist:xxxxx format
|
||||||
|
if (input.StartsWith("spotify:playlist:"))
|
||||||
|
{
|
||||||
|
return input.Substring("spotify:playlist:".Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle https://open.spotify.com/playlist/xxxxx format
|
||||||
|
if (input.Contains("open.spotify.com/playlist/"))
|
||||||
|
{
|
||||||
|
var start = input.IndexOf("/playlist/") + "/playlist/".Length;
|
||||||
|
var end = input.IndexOf('?', start);
|
||||||
|
return end > 0 ? input.Substring(start, end - start) : input.Substring(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_httpClient.Dispose();
|
||||||
|
_webApiClient.Dispose();
|
||||||
|
_tokenLock.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal classes for JSON deserialization
|
||||||
|
private class SpotifyTokenResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("accessToken")]
|
||||||
|
public string AccessToken { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("accessTokenExpirationTimestampMs")]
|
||||||
|
public long ExpirationTimestampMs { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("isAnonymous")]
|
||||||
|
public bool IsAnonymous { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("clientId")]
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TotpSecret
|
||||||
|
{
|
||||||
|
[JsonPropertyName("version")]
|
||||||
|
public int Version { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("secret")]
|
||||||
|
public List<byte> Secret { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ namespace allstarr.Services.Spotify;
|
|||||||
public class SpotifyMissingTracksFetcher : BackgroundService
|
public class SpotifyMissingTracksFetcher : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
|
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
|
||||||
|
private readonly IOptions<SpotifyApiSettings> _spotifyApiSettings;
|
||||||
private readonly IOptions<JellyfinSettings> _jellyfinSettings;
|
private readonly IOptions<JellyfinSettings> _jellyfinSettings;
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
@@ -21,6 +22,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
|
|
||||||
public SpotifyMissingTracksFetcher(
|
public SpotifyMissingTracksFetcher(
|
||||||
IOptions<SpotifyImportSettings> spotifySettings,
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
|
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||||
IOptions<JellyfinSettings> jellyfinSettings,
|
IOptions<JellyfinSettings> jellyfinSettings,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
@@ -28,6 +30,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
ILogger<SpotifyMissingTracksFetcher> logger)
|
ILogger<SpotifyMissingTracksFetcher> logger)
|
||||||
{
|
{
|
||||||
_spotifySettings = spotifySettings;
|
_spotifySettings = spotifySettings;
|
||||||
|
_spotifyApiSettings = spotifyApiSettings;
|
||||||
_jellyfinSettings = jellyfinSettings;
|
_jellyfinSettings = jellyfinSettings;
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
@@ -52,6 +55,16 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
// Ensure cache directory exists
|
// Ensure cache directory exists
|
||||||
Directory.CreateDirectory(CacheDirectory);
|
Directory.CreateDirectory(CacheDirectory);
|
||||||
|
|
||||||
|
// Check if SpotifyApi is enabled with a valid session cookie
|
||||||
|
// If so, SpotifyPlaylistFetcher will handle everything - we don't need to scrape Jellyfin
|
||||||
|
if (_spotifyApiSettings.Value.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.Value.SessionCookie))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("SpotifyApi is enabled with session cookie - using direct Spotify API instead of Jellyfin scraping");
|
||||||
|
_logger.LogInformation("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists.");
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!_spotifySettings.Value.Enabled)
|
if (!_spotifySettings.Value.Enabled)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Spotify playlist injection is DISABLED");
|
_logger.LogInformation("Spotify playlist injection is DISABLED");
|
||||||
|
|||||||
336
allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs
Normal file
336
allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Models.Spotify;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background service that fetches playlist tracks directly from Spotify's API.
|
||||||
|
///
|
||||||
|
/// This replaces the Jellyfin Spotify Import plugin dependency with key advantages:
|
||||||
|
/// - Track ordering is preserved (critical for playlists like Release Radar)
|
||||||
|
/// - ISRC codes available for exact matching
|
||||||
|
/// - Real-time data without waiting for plugin sync schedules
|
||||||
|
/// - Full track metadata (duration, release date, etc.)
|
||||||
|
/// </summary>
|
||||||
|
public class SpotifyPlaylistFetcher : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<SpotifyPlaylistFetcher> _logger;
|
||||||
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||||
|
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||||
|
private readonly SpotifyApiClient _spotifyClient;
|
||||||
|
private readonly RedisCacheService _cache;
|
||||||
|
|
||||||
|
private const string CacheDirectory = "/app/cache/spotify";
|
||||||
|
private const string CacheKeyPrefix = "spotify:playlist:";
|
||||||
|
|
||||||
|
// Track Spotify playlist IDs after discovery
|
||||||
|
private readonly Dictionary<string, string> _playlistNameToSpotifyId = new();
|
||||||
|
|
||||||
|
public SpotifyPlaylistFetcher(
|
||||||
|
ILogger<SpotifyPlaylistFetcher> logger,
|
||||||
|
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||||
|
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||||
|
SpotifyApiClient spotifyClient,
|
||||||
|
RedisCacheService cache)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||||
|
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||||
|
_spotifyClient = spotifyClient;
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Spotify playlist tracks in order, using cache if available.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
||||||
|
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
||||||
|
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(string playlistName)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{playlistName}";
|
||||||
|
|
||||||
|
// Try Redis cache first
|
||||||
|
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||||
|
if (cached != null && cached.Tracks.Count > 0)
|
||||||
|
{
|
||||||
|
var age = DateTime.UtcNow - cached.FetchedAt;
|
||||||
|
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
|
||||||
|
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
||||||
|
return cached.Tracks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try file cache
|
||||||
|
var filePath = GetCacheFilePath(playlistName);
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(filePath);
|
||||||
|
var filePlaylist = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
|
||||||
|
if (filePlaylist != null && filePlaylist.Tracks.Count > 0)
|
||||||
|
{
|
||||||
|
var age = DateTime.UtcNow - filePlaylist.FetchedAt;
|
||||||
|
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Using file-cached playlist '{Name}' ({Count} tracks)",
|
||||||
|
playlistName, filePlaylist.Tracks.Count);
|
||||||
|
return filePlaylist.Tracks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to read file cache for '{Name}'", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to fetch fresh - try to use cached or configured Spotify playlist ID
|
||||||
|
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
||||||
|
{
|
||||||
|
// Check if we have a configured Spotify ID for this playlist
|
||||||
|
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||||
|
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
|
||||||
|
{
|
||||||
|
// Use the configured Spotify playlist ID directly
|
||||||
|
spotifyId = playlistConfig.Id;
|
||||||
|
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||||
|
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No configured ID, try searching by name (works for public/followed playlists)
|
||||||
|
_logger.LogDebug("No configured Spotify ID for '{Name}', searching...", playlistName);
|
||||||
|
var playlists = await _spotifyClient.SearchUserPlaylistsAsync(playlistName);
|
||||||
|
|
||||||
|
var exactMatch = playlists.FirstOrDefault(p =>
|
||||||
|
p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (exactMatch == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
|
||||||
|
|
||||||
|
// Return file cache even if expired, as a fallback
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(filePath);
|
||||||
|
var fallback = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
|
||||||
|
if (fallback != null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Using expired file cache as fallback for '{Name}'", playlistName);
|
||||||
|
return fallback.Tracks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new List<SpotifyPlaylistTrack>();
|
||||||
|
}
|
||||||
|
|
||||||
|
spotifyId = exactMatch.SpotifyId;
|
||||||
|
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||||
|
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the full playlist
|
||||||
|
var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId);
|
||||||
|
if (playlist == null || playlist.Tracks.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Failed to fetch playlist '{Name}' from Spotify", playlistName);
|
||||||
|
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
|
||||||
|
await SaveToFileCacheAsync(playlistName, playlist);
|
||||||
|
|
||||||
|
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
|
||||||
|
playlistName, playlist.Tracks.Count);
|
||||||
|
|
||||||
|
return playlist.Tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets missing tracks for a playlist (tracks not found in Jellyfin library).
|
||||||
|
/// This provides compatibility with the existing SpotifyMissingTracksFetcher interface.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="playlistName">Playlist name</param>
|
||||||
|
/// <param name="jellyfinTrackIds">Set of Spotify IDs that exist in Jellyfin library</param>
|
||||||
|
/// <returns>List of missing tracks with position preserved</returns>
|
||||||
|
public async Task<List<SpotifyPlaylistTrack>> GetMissingTracksAsync(
|
||||||
|
string playlistName,
|
||||||
|
HashSet<string> jellyfinTrackIds)
|
||||||
|
{
|
||||||
|
var allTracks = await GetPlaylistTracksAsync(playlistName);
|
||||||
|
|
||||||
|
// Filter to only tracks not in Jellyfin, preserving order
|
||||||
|
return allTracks
|
||||||
|
.Where(t => !jellyfinTrackIds.Contains(t.SpotifyId))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manual trigger to refresh a specific playlist.
|
||||||
|
/// </summary>
|
||||||
|
public async Task RefreshPlaylistAsync(string playlistName)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
|
||||||
|
|
||||||
|
// Clear cache to force refresh
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{playlistName}";
|
||||||
|
await _cache.DeleteAsync(cacheKey);
|
||||||
|
|
||||||
|
// Re-fetch
|
||||||
|
await GetPlaylistTracksAsync(playlistName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manual trigger to refresh all configured playlists.
|
||||||
|
/// </summary>
|
||||||
|
public async Task TriggerFetchAsync()
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Manual fetch triggered for all playlists");
|
||||||
|
|
||||||
|
foreach (var config in _spotifyImportSettings.Playlists)
|
||||||
|
{
|
||||||
|
await RefreshPlaylistAsync(config.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
|
_logger.LogInformation("SpotifyPlaylistFetcher: Starting up...");
|
||||||
|
|
||||||
|
// Ensure cache directory exists
|
||||||
|
Directory.CreateDirectory(CacheDirectory);
|
||||||
|
|
||||||
|
if (!_spotifyApiSettings.Enabled)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Spotify API integration is DISABLED");
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Spotify session cookie not configured - cannot access editorial playlists");
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we can get an access token (the most reliable auth check)
|
||||||
|
_logger.LogInformation("Attempting Spotify authentication...");
|
||||||
|
var token = await _spotifyClient.GetWebAccessTokenAsync(stoppingToken);
|
||||||
|
if (string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to get Spotify access token - check session cookie");
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Spotify API ENABLED");
|
||||||
|
_logger.LogInformation("Authenticated via sp_dc session cookie");
|
||||||
|
_logger.LogInformation("Cache duration: {Minutes} minutes", _spotifyApiSettings.CacheDurationMinutes);
|
||||||
|
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
||||||
|
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
||||||
|
|
||||||
|
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(" - {Name}", playlist.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
|
|
||||||
|
// Initial fetch of all playlists
|
||||||
|
await FetchAllPlaylistsAsync(stoppingToken);
|
||||||
|
|
||||||
|
// Periodic refresh loop
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await FetchAllPlaylistsAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during periodic playlist refresh");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FetchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("=== FETCHING SPOTIFY PLAYLISTS ===");
|
||||||
|
|
||||||
|
foreach (var config in _spotifyImportSettings.Playlists)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tracks = await GetPlaylistTracksAsync(config.Name);
|
||||||
|
_logger.LogInformation(" {Name}: {Count} tracks", config.Name, tracks.Count);
|
||||||
|
|
||||||
|
// Log sample of track order for debugging
|
||||||
|
if (tracks.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(" First track: #{Position} {Title} - {Artist}",
|
||||||
|
tracks[0].Position, tracks[0].Title, tracks[0].PrimaryArtist);
|
||||||
|
|
||||||
|
if (tracks.Count > 1)
|
||||||
|
{
|
||||||
|
var last = tracks[^1];
|
||||||
|
_logger.LogDebug(" Last track: #{Position} {Title} - {Artist}",
|
||||||
|
last.Position, last.Title, last.PrimaryArtist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching playlist '{Name}'", config.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting between playlists - Spotify is VERY aggressive with rate limiting
|
||||||
|
// Wait 3 seconds between each playlist to avoid 429 TooManyRequests errors
|
||||||
|
if (config != _spotifyImportSettings.Playlists.Last())
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("=== FINISHED FETCHING SPOTIFY PLAYLISTS ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetCacheFilePath(string playlistName)
|
||||||
|
{
|
||||||
|
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
return Path.Combine(CacheDirectory, $"{safeName}_spotify.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveToFileCacheAsync(string playlistName, SpotifyPlaylist playlist)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var filePath = GetCacheFilePath(playlistName);
|
||||||
|
var json = JsonSerializer.Serialize(playlist, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
});
|
||||||
|
await File.WriteAllTextAsync(filePath, json);
|
||||||
|
_logger.LogDebug("Saved playlist '{Name}' to file cache", playlistName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to save file cache for '{Name}'", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,30 +2,40 @@ using allstarr.Models.Domain;
|
|||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Models.Spotify;
|
using allstarr.Models.Spotify;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
|
using allstarr.Services.Jellyfin;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace allstarr.Services.Spotify;
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Background service that pre-matches Spotify missing tracks with external providers.
|
/// Background service that pre-matches Spotify tracks with external providers.
|
||||||
/// Runs after SpotifyMissingTracksFetcher completes to avoid rate limiting during playlist loading.
|
///
|
||||||
|
/// Supports two modes:
|
||||||
|
/// 1. Legacy mode: Uses MissingTrack from Jellyfin plugin (no ISRC, no ordering)
|
||||||
|
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
||||||
|
///
|
||||||
|
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SpotifyTrackMatchingService : BackgroundService
|
public class SpotifyTrackMatchingService : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
|
private readonly SpotifyImportSettings _spotifySettings;
|
||||||
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly ILogger<SpotifyTrackMatchingService> _logger;
|
private readonly ILogger<SpotifyTrackMatchingService> _logger;
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
||||||
|
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
||||||
|
|
||||||
public SpotifyTrackMatchingService(
|
public SpotifyTrackMatchingService(
|
||||||
IOptions<SpotifyImportSettings> spotifySettings,
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
|
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
ILogger<SpotifyTrackMatchingService> logger)
|
ILogger<SpotifyTrackMatchingService> logger)
|
||||||
{
|
{
|
||||||
_spotifySettings = spotifySettings;
|
_spotifySettings = spotifySettings.Value;
|
||||||
|
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -35,12 +45,16 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||||
|
|
||||||
if (!_spotifySettings.Value.Enabled)
|
if (!_spotifySettings.Enabled)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||||
|
? "ISRC-preferred" : "fuzzy";
|
||||||
|
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
||||||
|
|
||||||
// Wait a bit for the fetcher to run first
|
// Wait a bit for the fetcher to run first
|
||||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||||
|
|
||||||
@@ -73,19 +87,67 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Public method to trigger matching manually (called from controller).
|
/// Public method to trigger matching manually for all playlists (called from controller).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task TriggerMatchingAsync()
|
public async Task TriggerMatchingAsync()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Manual track matching triggered");
|
_logger.LogInformation("Manual track matching triggered for all playlists");
|
||||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public method to trigger matching for a specific playlist (called from controller).
|
||||||
|
/// </summary>
|
||||||
|
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
|
||||||
|
|
||||||
|
var playlist = _spotifySettings.Playlists
|
||||||
|
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (playlist == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Playlist {Playlist} not found in configuration", playlistName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||||
|
|
||||||
|
// Check if we should use the new SpotifyPlaylistFetcher
|
||||||
|
SpotifyPlaylistFetcher? playlistFetcher = null;
|
||||||
|
if (_spotifyApiSettings.Enabled)
|
||||||
|
{
|
||||||
|
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (playlistFetcher != null)
|
||||||
|
{
|
||||||
|
// Use new direct API mode with ISRC support
|
||||||
|
await MatchPlaylistTracksWithIsrcAsync(
|
||||||
|
playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fall back to legacy mode
|
||||||
|
await MatchPlaylistTracksLegacyAsync(
|
||||||
|
playlist.Name, metadataService, CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
||||||
|
|
||||||
var playlists = _spotifySettings.Value.Playlists;
|
var playlists = _spotifySettings.Playlists;
|
||||||
if (playlists.Count == 0)
|
if (playlists.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No playlists configured for matching");
|
_logger.LogInformation("No playlists configured for matching");
|
||||||
@@ -95,13 +157,31 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||||
|
|
||||||
|
// Check if we should use the new SpotifyPlaylistFetcher
|
||||||
|
SpotifyPlaylistFetcher? playlistFetcher = null;
|
||||||
|
if (_spotifyApiSettings.Enabled)
|
||||||
|
{
|
||||||
|
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var playlist in playlists)
|
foreach (var playlist in playlists)
|
||||||
{
|
{
|
||||||
if (cancellationToken.IsCancellationRequested) break;
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await MatchPlaylistTracksAsync(playlist.Name, metadataService, cancellationToken);
|
if (playlistFetcher != null)
|
||||||
|
{
|
||||||
|
// Use new direct API mode with ISRC support
|
||||||
|
await MatchPlaylistTracksWithIsrcAsync(
|
||||||
|
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fall back to legacy mode
|
||||||
|
await MatchPlaylistTracksLegacyAsync(
|
||||||
|
playlist.Name, metadataService, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -112,7 +192,319 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MatchPlaylistTracksAsync(
|
/// <summary>
|
||||||
|
/// New matching mode that uses ISRC when available for exact matches.
|
||||||
|
/// Preserves track position for correct playlist ordering.
|
||||||
|
/// Only matches tracks that aren't already in the Jellyfin playlist.
|
||||||
|
/// </summary>
|
||||||
|
private async Task MatchPlaylistTracksWithIsrcAsync(
|
||||||
|
string playlistName,
|
||||||
|
SpotifyPlaylistFetcher playlistFetcher,
|
||||||
|
IMusicMetadataService metadataService,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
||||||
|
|
||||||
|
// Get playlist tracks with full metadata including ISRC and position
|
||||||
|
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||||
|
if (spotifyTracks.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No tracks found for {Playlist}, skipping matching", playlistName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the Jellyfin playlist ID to check which tracks already exist
|
||||||
|
var playlistConfig = _spotifySettings.Playlists
|
||||||
|
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
HashSet<string> existingSpotifyIds = new();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
||||||
|
{
|
||||||
|
// Get existing tracks from Jellyfin playlist to avoid re-matching
|
||||||
|
using var scope = _serviceProvider.CreateScope();
|
||||||
|
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||||
|
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
|
||||||
|
|
||||||
|
if (proxyService != null && jellyfinSettings != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||||
|
var userId = jellyfinSettings.UserId;
|
||||||
|
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
playlistItemsUrl += $"?UserId={userId}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
|
||||||
|
}
|
||||||
|
|
||||||
|
var (existingTracksResponse, _) = await proxyService.GetJsonAsync(
|
||||||
|
playlistItemsUrl,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
|
||||||
|
if (existingTracksResponse != null &&
|
||||||
|
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||||
|
{
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
|
||||||
|
providerIds.TryGetProperty("Spotify", out var spotifyId))
|
||||||
|
{
|
||||||
|
var id = spotifyId.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(id))
|
||||||
|
{
|
||||||
|
existingSpotifyIds.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_logger.LogInformation("Found {Count} tracks already in Jellyfin playlist {Playlist}, will skip matching these",
|
||||||
|
existingSpotifyIds.Count, playlistName);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No Items found in Jellyfin playlist response for {Playlist} - may need UserId parameter", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not fetch existing Jellyfin tracks for {Playlist}, will match all tracks", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only tracks not already in Jellyfin
|
||||||
|
var tracksToMatch = spotifyTracks
|
||||||
|
.Where(t => !existingSpotifyIds.Contains(t.SpotifyId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (tracksToMatch.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching",
|
||||||
|
spotifyTracks.Count, playlistName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled})",
|
||||||
|
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
|
||||||
|
|
||||||
|
// Check cache - use snapshot/timestamp to detect changes
|
||||||
|
var existingMatched = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||||
|
if (existingMatched != null && existingMatched.Count >= tracksToMatch.Count)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
||||||
|
playlistName, existingMatched.Count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedTracks = new List<MatchedTrack>();
|
||||||
|
var isrcMatches = 0;
|
||||||
|
var fuzzyMatches = 0;
|
||||||
|
var noMatch = 0;
|
||||||
|
|
||||||
|
// Process tracks in batches for parallel searching
|
||||||
|
var orderedTracks = tracksToMatch.OrderBy(t => t.Position).ToList();
|
||||||
|
for (int i = 0; i < orderedTracks.Count; i += BatchSize)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
var batch = orderedTracks.Skip(i).Take(BatchSize).ToList();
|
||||||
|
_logger.LogDebug("Processing batch {Start}-{End} of {Total}",
|
||||||
|
i + 1, Math.Min(i + BatchSize, orderedTracks.Count), orderedTracks.Count);
|
||||||
|
|
||||||
|
// Process all tracks in this batch in parallel
|
||||||
|
var batchTasks = batch.Select(async spotifyTrack =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Song? matchedSong = null;
|
||||||
|
var matchType = "none";
|
||||||
|
|
||||||
|
// Try ISRC match first if available and enabled
|
||||||
|
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||||
|
{
|
||||||
|
matchedSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||||
|
if (matchedSong != null)
|
||||||
|
{
|
||||||
|
matchType = "isrc";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to fuzzy matching
|
||||||
|
if (matchedSong == null)
|
||||||
|
{
|
||||||
|
matchedSong = await TryMatchByFuzzyAsync(
|
||||||
|
spotifyTrack.Title,
|
||||||
|
spotifyTrack.Artists,
|
||||||
|
metadataService);
|
||||||
|
|
||||||
|
if (matchedSong != null)
|
||||||
|
{
|
||||||
|
matchType = "fuzzy";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedSong != null)
|
||||||
|
{
|
||||||
|
var matched = new MatchedTrack
|
||||||
|
{
|
||||||
|
Position = spotifyTrack.Position,
|
||||||
|
SpotifyId = spotifyTrack.SpotifyId,
|
||||||
|
SpotifyTitle = spotifyTrack.Title,
|
||||||
|
SpotifyArtist = spotifyTrack.PrimaryArtist,
|
||||||
|
Isrc = spotifyTrack.Isrc,
|
||||||
|
MatchType = matchType,
|
||||||
|
MatchedSong = matchedSong
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match: {MatchedTitle}",
|
||||||
|
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
||||||
|
matchType, matchedSong.Title);
|
||||||
|
|
||||||
|
return (matched, matchType);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
||||||
|
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||||
|
return (null, "none");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||||
|
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||||
|
return (null, "none");
|
||||||
|
}
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Wait for all tracks in this batch to complete
|
||||||
|
var batchResults = await Task.WhenAll(batchTasks);
|
||||||
|
|
||||||
|
// Collect results
|
||||||
|
foreach (var (matched, matchType) in batchResults)
|
||||||
|
{
|
||||||
|
if (matched != null)
|
||||||
|
{
|
||||||
|
matchedTracks.Add(matched);
|
||||||
|
if (matchType == "isrc") isrcMatches++;
|
||||||
|
else if (matchType == "fuzzy") fuzzyMatches++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
noMatch++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting between batches (not between individual tracks)
|
||||||
|
if (i + BatchSize < orderedTracks.Count)
|
||||||
|
{
|
||||||
|
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedTracks.Count > 0)
|
||||||
|
{
|
||||||
|
// Cache matched tracks with position data
|
||||||
|
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
||||||
|
|
||||||
|
// Also update legacy cache for backward compatibility
|
||||||
|
var legacyKey = $"spotify:matched:{playlistName}";
|
||||||
|
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||||
|
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"✓ Cached {Matched}/{Total} tracks for {Playlist} (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch})",
|
||||||
|
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No tracks matched for {Playlist}", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to match a track by ISRC using provider search.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Search by ISRC directly - most providers support this
|
||||||
|
var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1);
|
||||||
|
if (results.Count > 0 && results[0].Isrc == isrc)
|
||||||
|
{
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some providers may not support isrc: prefix, try without
|
||||||
|
results = await metadataService.SearchSongsAsync(isrc, limit: 5);
|
||||||
|
var exactMatch = results.FirstOrDefault(r =>
|
||||||
|
!string.IsNullOrEmpty(r.Isrc) &&
|
||||||
|
r.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
return exactMatch;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to match a track by title and artist using fuzzy matching.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Song?> TryMatchByFuzzyAsync(
|
||||||
|
string title,
|
||||||
|
List<string> artists,
|
||||||
|
IMusicMetadataService metadataService)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||||
|
var query = $"{title} {primaryArtist}";
|
||||||
|
var results = await metadataService.SearchSongsAsync(query, limit: 5);
|
||||||
|
|
||||||
|
if (results.Count == 0) return null;
|
||||||
|
|
||||||
|
var bestMatch = results
|
||||||
|
.Select(song => new
|
||||||
|
{
|
||||||
|
Song = song,
|
||||||
|
TitleScore = FuzzyMatcher.CalculateSimilarity(title, song.Title),
|
||||||
|
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||||
|
})
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Song,
|
||||||
|
x.TitleScore,
|
||||||
|
x.ArtistScore,
|
||||||
|
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
|
||||||
|
})
|
||||||
|
.OrderByDescending(x => x.TotalScore)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (bestMatch != null && bestMatch.TotalScore >= 60)
|
||||||
|
{
|
||||||
|
return bestMatch.Song;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
|
||||||
|
/// </summary>
|
||||||
|
private async Task MatchPlaylistTracksLegacyAsync(
|
||||||
string playlistName,
|
string playlistName,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||||
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
||||||
|
|||||||
@@ -10,17 +10,6 @@
|
|||||||
"SyncStartHour": 16,
|
"SyncStartHour": 16,
|
||||||
"SyncStartMinute": 15,
|
"SyncStartMinute": 15,
|
||||||
"SyncWindowHours": 2,
|
"SyncWindowHours": 2,
|
||||||
"Playlists": [
|
"Playlists": []
|
||||||
{
|
|
||||||
"Name": "Release Radar",
|
|
||||||
"SpotifyName": "Release Radar",
|
|
||||||
"Enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "Discover Weekly",
|
|
||||||
"SpotifyName": "Discover Weekly",
|
|
||||||
"Enabled": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,17 +48,15 @@
|
|||||||
"SyncStartHour": 16,
|
"SyncStartHour": 16,
|
||||||
"SyncStartMinute": 15,
|
"SyncStartMinute": 15,
|
||||||
"SyncWindowHours": 2,
|
"SyncWindowHours": 2,
|
||||||
"Playlists": [
|
"Playlists": []
|
||||||
{
|
|
||||||
"Name": "Release Radar",
|
|
||||||
"SpotifyName": "Release Radar",
|
|
||||||
"Enabled": true
|
|
||||||
},
|
},
|
||||||
{
|
"SpotifyApi": {
|
||||||
"Name": "Discover Weekly",
|
"Enabled": false,
|
||||||
"SpotifyName": "Discover Weekly",
|
"ClientId": "",
|
||||||
"Enabled": true
|
"ClientSecret": "",
|
||||||
}
|
"SessionCookie": "",
|
||||||
]
|
"CacheDurationMinutes": 60,
|
||||||
|
"RateLimitDelayMs": 100,
|
||||||
|
"PreferIsrcMatching": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1848
allstarr/wwwroot/index.html
Normal file
1848
allstarr/wwwroot/index.html
Normal file
@@ -0,0 +1,1848 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Allstarr Dashboard</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0d1117;
|
||||||
|
--bg-secondary: #161b22;
|
||||||
|
--bg-tertiary: #21262d;
|
||||||
|
--text-primary: #f0f6fc;
|
||||||
|
--text-secondary: #8b949e;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--accent-hover: #79c0ff;
|
||||||
|
--success: #3fb950;
|
||||||
|
--warning: #d29922;
|
||||||
|
--error: #f85149;
|
||||||
|
--border: #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 .version {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.success { background: rgba(63, 185, 80, 0.2); color: var(--success); }
|
||||||
|
.status-badge.warning { background: rgba(210, 153, 34, 0.2); color: var(--warning); }
|
||||||
|
.status-badge.error { background: rgba(248, 81, 73, 0.2); color: var(--error); }
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 .actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value.success { color: var(--success); }
|
||||||
|
.stat-value.warning { color: var(--warning); }
|
||||||
|
.stat-value.error { color: var(--error); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: rgba(248, 81, 73, 0.15);
|
||||||
|
border-color: var(--error);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background: rgba(248, 81, 73, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-table th,
|
||||||
|
.playlist-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-table th {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-table tr:hover td {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-table .track-count {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-table .cache-age {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 120px auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr auto;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item .label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item .value {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success { border-color: var(--success); }
|
||||||
|
.toast.error { border-color: var(--error); }
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
z-index: 9999;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-overlay .spinner-large {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-overlay h2 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-overlay p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--warning);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
padding: 12px 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 9998;
|
||||||
|
display: none;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-banner.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-banner button {
|
||||||
|
margin-left: 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restart-banner button:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content .form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content .form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content .form-group input,
|
||||||
|
.modal-content .form-group select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 12px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracks-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-position {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info h4 {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info .artists {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-meta {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Restart Required Banner -->
|
||||||
|
<div class="restart-banner" id="restart-banner">
|
||||||
|
⚠️ Configuration changed. Restart required to apply changes.
|
||||||
|
<button onclick="restartContainer()">Restart Now</button>
|
||||||
|
<button onclick="dismissRestartBanner()" style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>
|
||||||
|
Allstarr <span class="version" id="version">v1.0.0</span>
|
||||||
|
</h1>
|
||||||
|
<div id="status-indicator">
|
||||||
|
<span class="status-badge" id="spotify-status">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span>Loading...</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||||
|
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||||
|
<div class="tab" data-tab="playlists">Active Playlists</div>
|
||||||
|
<div class="tab" data-tab="config">Configuration</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Tab -->
|
||||||
|
<div class="tab-content active" id="tab-dashboard">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Spotify API</h2>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Status</span>
|
||||||
|
<span class="stat-value" id="spotify-auth-status">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">User</span>
|
||||||
|
<span class="stat-value" id="spotify-user">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Cookie Age</span>
|
||||||
|
<span class="stat-value" id="spotify-cookie-age">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Cache Duration</span>
|
||||||
|
<span class="stat-value" id="cache-duration">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">ISRC Matching</span>
|
||||||
|
<span class="stat-value" id="isrc-matching">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Jellyfin</h2>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Backend</span>
|
||||||
|
<span class="stat-value" id="backend-type">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">URL</span>
|
||||||
|
<span class="stat-value" id="jellyfin-url">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span class="stat-label">Playlists</span>
|
||||||
|
<span class="stat-value" id="playlist-count">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
Quick Actions
|
||||||
|
</h2>
|
||||||
|
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
|
||||||
|
<button onclick="clearCache()">Clear Cache</button>
|
||||||
|
<button onclick="openAddPlaylist()">Add Playlist</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Link Playlists Tab -->
|
||||||
|
<div class="tab-content" id="tab-jellyfin-playlists">
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
Link Jellyfin Playlists to Spotify
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
|
||||||
|
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more reliable.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
|
||||||
|
<div class="form-group" style="margin: 0; flex: 1; min-width: 200px;">
|
||||||
|
<label style="display: block; margin-bottom: 4px; color: var(--text-secondary); font-size: 0.85rem;">User</label>
|
||||||
|
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()" style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
||||||
|
<option value="">All Users</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="playlist-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Local</th>
|
||||||
|
<th>External</th>
|
||||||
|
<th>Linked Spotify ID</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="jellyfin-playlist-table-body">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="loading">
|
||||||
|
<span class="spinner"></span> Loading Jellyfin playlists...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Playlists Tab -->
|
||||||
|
<div class="tab-content" id="tab-playlists">
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
Active Spotify Playlists
|
||||||
|
<div class="actions">
|
||||||
|
<button onclick="matchAllPlaylists()">Match All Tracks</button>
|
||||||
|
<button onclick="refreshPlaylists()">Refresh All</button>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||||
|
These are the Spotify playlists currently being monitored and filled with tracks from your music service.
|
||||||
|
</p>
|
||||||
|
<table class="playlist-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Spotify ID</th>
|
||||||
|
<th>Tracks</th>
|
||||||
|
<th>Completion</th>
|
||||||
|
<th>Cache Age</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="playlist-table-body">
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="loading">
|
||||||
|
<span class="spinner"></span> Loading playlists...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Tab -->
|
||||||
|
<div class="tab-content" id="tab-config">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Spotify API Settings</h2>
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">API Enabled</span>
|
||||||
|
<span class="value" id="config-spotify-enabled">-</span>
|
||||||
|
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Session Cookie (sp_dc)</span>
|
||||||
|
<span class="value" id="config-spotify-cookie">-</span>
|
||||||
|
<button onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item" style="grid-template-columns: 200px 1fr;">
|
||||||
|
<span class="label">Cookie Age</span>
|
||||||
|
<span class="value" id="config-cookie-age">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Cache Duration</span>
|
||||||
|
<span class="value" id="config-cache-duration">-</span>
|
||||||
|
<button onclick="openEditSetting('SPOTIFY_API_CACHE_DURATION_MINUTES', 'Cache Duration (minutes)', 'number')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">ISRC Matching</span>
|
||||||
|
<span class="value" id="config-isrc-matching">-</span>
|
||||||
|
<button onclick="openEditSetting('SPOTIFY_API_PREFER_ISRC_MATCHING', 'Prefer ISRC Matching', 'toggle')">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Deezer Settings</h2>
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">ARL Token</span>
|
||||||
|
<span class="value" id="config-deezer-arl">-</span>
|
||||||
|
<button onclick="openEditSetting('DEEZER_ARL', 'Deezer ARL Token', 'password', 'Get from browser cookies while logged into Deezer')">Update</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Quality</span>
|
||||||
|
<span class="value" id="config-deezer-quality">-</span>
|
||||||
|
<button onclick="openEditSetting('DEEZER_QUALITY', 'Deezer Quality', 'select', '', ['FLAC', 'MP3_320', 'MP3_128'])">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>SquidWTF / Tidal Settings</h2>
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Quality</span>
|
||||||
|
<span class="value" id="config-squid-quality">-</span>
|
||||||
|
<button onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', '', ['LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Qobuz Settings</h2>
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">User Auth Token</span>
|
||||||
|
<span class="value" id="config-qobuz-token">-</span>
|
||||||
|
<button onclick="openEditSetting('QOBUZ_USER_AUTH_TOKEN', 'Qobuz User Auth Token', 'password', 'Get from browser while logged into Qobuz')">Update</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Quality</span>
|
||||||
|
<span class="value" id="config-qobuz-quality">-</span>
|
||||||
|
<button onclick="openEditSetting('QOBUZ_QUALITY', 'Qobuz Quality', 'select', '', ['FLAC_24_192', 'FLAC_24_96', 'FLAC_16_44', 'MP3_320'])">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Jellyfin Settings</h2>
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">URL</span>
|
||||||
|
<span class="value" id="config-jellyfin-url">-</span>
|
||||||
|
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">API Key</span>
|
||||||
|
<span class="value" id="config-jellyfin-api-key">-</span>
|
||||||
|
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">User ID</span>
|
||||||
|
<span class="value" id="config-jellyfin-user-id">-</span>
|
||||||
|
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Library ID</span>
|
||||||
|
<span class="value" id="config-jellyfin-library-id">-</span>
|
||||||
|
<button onclick="openEditSetting('JELLYFIN_LIBRARY_ID', 'Jellyfin Library ID', 'text')">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Sync Schedule</h2>
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Sync Start Time</span>
|
||||||
|
<span class="value" id="config-sync-time">-</span>
|
||||||
|
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_START_HOUR', 'Sync Start Hour (0-23)', 'number')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Sync Window</span>
|
||||||
|
<span class="value" id="config-sync-window">-</span>
|
||||||
|
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_WINDOW_HOURS', 'Sync Window (hours)', 'number')">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
|
||||||
|
<h2 style="color: var(--error);">Danger Zone</h2>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
These actions can affect your data. Use with caution.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<button class="danger" onclick="clearCache()">Clear All Cache</button>
|
||||||
|
<button class="danger" onclick="restartContainer()">Restart Container</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Playlist Modal -->
|
||||||
|
<div class="modal" id="add-playlist-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Add Playlist</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Playlist Name</label>
|
||||||
|
<input type="text" id="new-playlist-name" placeholder="e.g., Release Radar">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Spotify Playlist ID</label>
|
||||||
|
<input type="text" id="new-playlist-id" placeholder="Get from Spotify Import plugin">
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button onclick="closeModal('add-playlist-modal')">Cancel</button>
|
||||||
|
<button class="primary" onclick="addPlaylist()">Add Playlist</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Setting Modal -->
|
||||||
|
<div class="modal" id="edit-setting-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3 id="edit-setting-title">Edit Setting</h3>
|
||||||
|
<p id="edit-setting-help" style="color: var(--text-secondary); margin-bottom: 16px; display: none;"></p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="edit-setting-label">Value</label>
|
||||||
|
<div id="edit-setting-input-container">
|
||||||
|
<input type="text" id="edit-setting-value" placeholder="Enter value">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button onclick="closeModal('edit-setting-modal')">Cancel</button>
|
||||||
|
<button class="primary" onclick="saveEditSetting()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Track List Modal -->
|
||||||
|
<div class="modal" id="tracks-modal">
|
||||||
|
<div class="modal-content" style="max-width: 700px;">
|
||||||
|
<h3 id="tracks-modal-title">Playlist Tracks</h3>
|
||||||
|
<div class="tracks-list" id="tracks-list">
|
||||||
|
<div class="loading">
|
||||||
|
<span class="spinner"></span> Loading tracks...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button onclick="closeModal('tracks-modal')">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Track Mapping Modal -->
|
||||||
|
<div class="modal" id="manual-map-modal">
|
||||||
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
|
<h3>Map Track to Local File</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
This track is currently using an external provider. Search for and select the local Jellyfin track to use instead.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Spotify Track (Position <span id="map-position"></span>)</label>
|
||||||
|
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||||
|
<strong id="map-spotify-title"></strong><br>
|
||||||
|
<span style="color: var(--text-secondary);" id="map-spotify-artist"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Search Jellyfin Tracks</label>
|
||||||
|
<input type="text" id="map-search-query" placeholder="Search by title or artist..." oninput="searchJellyfinTracks()">
|
||||||
|
</div>
|
||||||
|
<div id="map-search-results" style="max-height: 300px; overflow-y: auto; margin-top: 12px;">
|
||||||
|
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">
|
||||||
|
Type to search for local tracks...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="map-playlist-name">
|
||||||
|
<input type="hidden" id="map-spotify-id">
|
||||||
|
<input type="hidden" id="map-selected-jellyfin-id">
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button onclick="closeModal('manual-map-modal')">Cancel</button>
|
||||||
|
<button class="primary" onclick="saveManualMapping()" id="map-save-btn" disabled>Save Mapping</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Link Playlist Modal -->
|
||||||
|
<div class="modal" id="link-playlist-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Link to Spotify Playlist</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
Enter the Spotify playlist ID or URL. Allstarr will automatically download missing tracks from your configured music service.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Jellyfin Playlist</label>
|
||||||
|
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
|
||||||
|
<input type="hidden" id="link-jellyfin-id">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Spotify Playlist ID or URL</label>
|
||||||
|
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
|
||||||
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||||
|
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
||||||
|
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restart Overlay -->
|
||||||
|
<div class="restart-overlay" id="restart-overlay">
|
||||||
|
<div class="spinner-large"></div>
|
||||||
|
<h2>Restarting Container</h2>
|
||||||
|
<p id="restart-status">Applying configuration changes...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Current edit setting state
|
||||||
|
let currentEditKey = null;
|
||||||
|
let currentEditType = null;
|
||||||
|
let currentEditOptions = null;
|
||||||
|
|
||||||
|
// Track if we've already initialized the cookie date to prevent infinite loop
|
||||||
|
let cookieDateInitialized = false;
|
||||||
|
|
||||||
|
// Track if restart is required
|
||||||
|
let restartRequired = false;
|
||||||
|
|
||||||
|
function showRestartBanner() {
|
||||||
|
restartRequired = true;
|
||||||
|
document.getElementById('restart-banner').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissRestartBanner() {
|
||||||
|
document.getElementById('restart-banner').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab switching with URL hash support
|
||||||
|
function switchTab(tabName) {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
|
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
||||||
|
const content = document.getElementById('tab-' + tabName);
|
||||||
|
|
||||||
|
if (tab && content) {
|
||||||
|
tab.classList.add('active');
|
||||||
|
content.classList.add('active');
|
||||||
|
window.location.hash = tabName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
switchTab(tab.dataset.tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore tab from URL hash on page load
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const hash = window.location.hash.substring(1);
|
||||||
|
if (hash) {
|
||||||
|
switchTab(hash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toast notification
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast ' + type;
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal helpers
|
||||||
|
function openModal(id) {
|
||||||
|
document.getElementById(id).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(id) {
|
||||||
|
document.getElementById(id).classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals on backdrop click
|
||||||
|
document.querySelectorAll('.modal').forEach(modal => {
|
||||||
|
modal.addEventListener('click', e => {
|
||||||
|
if (e.target === modal) closeModal(modal.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format cookie age with color coding
|
||||||
|
function formatCookieAge(setDateStr, hasCookie = false) {
|
||||||
|
if (!setDateStr) {
|
||||||
|
if (hasCookie) {
|
||||||
|
return { text: 'Unknown age', class: 'warning', detail: 'Cookie date not tracked', needsInit: true };
|
||||||
|
}
|
||||||
|
return { text: 'No cookie', class: '', detail: '', needsInit: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDate = new Date(setDateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now - setDate;
|
||||||
|
const daysAgo = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
const monthsAgo = daysAgo / 30;
|
||||||
|
|
||||||
|
let status = 'success'; // green: < 6 months
|
||||||
|
if (monthsAgo >= 10) status = 'error'; // red: > 10 months
|
||||||
|
else if (monthsAgo >= 6) status = 'warning'; // yellow: 6-10 months
|
||||||
|
|
||||||
|
let text;
|
||||||
|
if (daysAgo === 0) text = 'Set today';
|
||||||
|
else if (daysAgo === 1) text = 'Set yesterday';
|
||||||
|
else if (daysAgo < 30) text = `Set ${daysAgo} days ago`;
|
||||||
|
else if (daysAgo < 60) text = 'Set ~1 month ago';
|
||||||
|
else text = `Set ~${Math.floor(monthsAgo)} months ago`;
|
||||||
|
|
||||||
|
const remaining = 12 - monthsAgo;
|
||||||
|
let detail;
|
||||||
|
if (remaining > 6) detail = 'Cookie typically lasts ~1 year';
|
||||||
|
else if (remaining > 2) detail = `~${Math.floor(remaining)} months until expiration`;
|
||||||
|
else if (remaining > 0) detail = 'Cookie may expire soon!';
|
||||||
|
else detail = 'Cookie may have expired - update if having issues';
|
||||||
|
|
||||||
|
return { text, class: status, detail, needsInit: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize cookie date if cookie exists but date is not set
|
||||||
|
async function initCookieDate() {
|
||||||
|
if (cookieDateInitialized) {
|
||||||
|
console.log('Cookie date already initialized, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieDateInitialized = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/config/init-cookie-date', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
console.log('Cookie date initialized successfully - restart container to apply');
|
||||||
|
showToast('Cookie date set. Restart container to apply changes.', 'success');
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('Cookie date init response:', data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to init cookie date:', error);
|
||||||
|
cookieDateInitialized = false; // Allow retry on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API calls
|
||||||
|
async function fetchStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/status');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
document.getElementById('version').textContent = 'v' + data.version;
|
||||||
|
document.getElementById('backend-type').textContent = data.backendType;
|
||||||
|
document.getElementById('jellyfin-url').textContent = data.jellyfinUrl || '-';
|
||||||
|
document.getElementById('playlist-count').textContent = data.spotifyImport.playlistCount;
|
||||||
|
document.getElementById('cache-duration').textContent = data.spotify.cacheDurationMinutes + ' min';
|
||||||
|
document.getElementById('isrc-matching').textContent = data.spotify.preferIsrcMatching ? 'Enabled' : 'Disabled';
|
||||||
|
document.getElementById('spotify-user').textContent = data.spotify.user || '-';
|
||||||
|
|
||||||
|
// Update status badge and cookie age
|
||||||
|
const statusBadge = document.getElementById('spotify-status');
|
||||||
|
const authStatus = document.getElementById('spotify-auth-status');
|
||||||
|
const cookieAgeEl = document.getElementById('spotify-cookie-age');
|
||||||
|
|
||||||
|
if (data.spotify.authStatus === 'configured') {
|
||||||
|
statusBadge.className = 'status-badge success';
|
||||||
|
statusBadge.innerHTML = '<span class="status-dot"></span>Spotify Ready';
|
||||||
|
authStatus.textContent = 'Cookie Set';
|
||||||
|
authStatus.className = 'stat-value success';
|
||||||
|
} else if (data.spotify.authStatus === 'missing_cookie') {
|
||||||
|
statusBadge.className = 'status-badge warning';
|
||||||
|
statusBadge.innerHTML = '<span class="status-dot"></span>Cookie Missing';
|
||||||
|
authStatus.textContent = 'No Cookie';
|
||||||
|
authStatus.className = 'stat-value warning';
|
||||||
|
} else {
|
||||||
|
statusBadge.className = 'status-badge';
|
||||||
|
statusBadge.innerHTML = '<span class="status-dot"></span>Not Configured';
|
||||||
|
authStatus.textContent = 'Not Configured';
|
||||||
|
authStatus.className = 'stat-value';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cookie age display
|
||||||
|
if (cookieAgeEl) {
|
||||||
|
const hasCookie = data.spotify.hasCookie;
|
||||||
|
const age = formatCookieAge(data.spotify.cookieSetDate, hasCookie);
|
||||||
|
cookieAgeEl.innerHTML = `<span class="${age.class}">${age.text}</span><br><small style="color:var(--text-secondary)">${age.detail}</small>`;
|
||||||
|
|
||||||
|
// Auto-init cookie date if cookie exists but date is not set
|
||||||
|
if (age.needsInit) {
|
||||||
|
console.log('Cookie exists but date not set, initializing...');
|
||||||
|
initCookieDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch status:', error);
|
||||||
|
showToast('Failed to fetch status', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlaylists() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/playlists');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const tbody = document.getElementById('playlist-table-body');
|
||||||
|
|
||||||
|
if (data.playlists.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = data.playlists.map(p => {
|
||||||
|
// Enhanced statistics display
|
||||||
|
const spotifyTotal = p.trackCount || 0;
|
||||||
|
const localCount = p.localTracks || 0;
|
||||||
|
const externalMatched = p.externalMatched || 0;
|
||||||
|
const externalMissing = p.externalMissing || 0;
|
||||||
|
const totalInJellyfin = p.totalInJellyfin || 0;
|
||||||
|
|
||||||
|
// Build detailed stats string
|
||||||
|
let statsHtml = `<span class="track-count">${spotifyTotal}</span>`;
|
||||||
|
|
||||||
|
// Show breakdown with color coding
|
||||||
|
let breakdownParts = [];
|
||||||
|
if (localCount > 0) {
|
||||||
|
breakdownParts.push(`<span style="color:var(--success)">${localCount} local</span>`);
|
||||||
|
}
|
||||||
|
if (externalMatched > 0) {
|
||||||
|
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} matched</span>`);
|
||||||
|
}
|
||||||
|
if (externalMissing > 0) {
|
||||||
|
breakdownParts.push(`<span style="color:var(--warning)">${externalMissing} missing</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakdown = breakdownParts.length > 0
|
||||||
|
? `<br><small style="color:var(--text-secondary)">${breakdownParts.join(' • ')}</small>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Calculate completion percentage
|
||||||
|
const completionPct = spotifyTotal > 0 ? Math.round((totalInJellyfin / spotifyTotal) * 100) : 0;
|
||||||
|
const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||||
|
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
|
||||||
|
<td>${statsHtml}${breakdown}</td>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<div style="flex:1;background:var(--bg-tertiary);height:6px;border-radius:3px;overflow:hidden;">
|
||||||
|
<div style="width:${completionPct}%;height:100%;background:${completionColor};transition:width 0.3s;"></div>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
||||||
|
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
||||||
|
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch playlists:', error);
|
||||||
|
showToast('Failed to fetch playlists', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/config');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Spotify API settings
|
||||||
|
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
||||||
|
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
||||||
|
document.getElementById('config-cache-duration').textContent = data.spotifyApi.cacheDurationMinutes + ' minutes';
|
||||||
|
document.getElementById('config-isrc-matching').textContent = data.spotifyApi.preferIsrcMatching ? 'Enabled' : 'Disabled';
|
||||||
|
|
||||||
|
// Cookie age in config tab
|
||||||
|
const configCookieAge = document.getElementById('config-cookie-age');
|
||||||
|
if (configCookieAge) {
|
||||||
|
const hasCookie = data.spotifyApi.sessionCookie && data.spotifyApi.sessionCookie !== '(not set)';
|
||||||
|
const age = formatCookieAge(data.spotifyApi.sessionCookieSetDate, hasCookie);
|
||||||
|
configCookieAge.innerHTML = `<span class="${age.class}">${age.text}</span> - ${age.detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deezer settings
|
||||||
|
document.getElementById('config-deezer-arl').textContent = data.deezer.arl || '(not set)';
|
||||||
|
document.getElementById('config-deezer-quality').textContent = data.deezer.quality;
|
||||||
|
|
||||||
|
// SquidWTF settings
|
||||||
|
document.getElementById('config-squid-quality').textContent = data.squidWtf.quality;
|
||||||
|
|
||||||
|
// Qobuz settings
|
||||||
|
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
|
||||||
|
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
|
||||||
|
|
||||||
|
// Jellyfin settings
|
||||||
|
document.getElementById('config-jellyfin-url').textContent = data.jellyfin.url || '-';
|
||||||
|
document.getElementById('config-jellyfin-api-key').textContent = data.jellyfin.apiKey;
|
||||||
|
document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)';
|
||||||
|
document.getElementById('config-jellyfin-library-id').textContent = data.jellyfin.libraryId || '-';
|
||||||
|
|
||||||
|
// Sync settings
|
||||||
|
const syncHour = data.spotifyImport.syncStartHour;
|
||||||
|
const syncMin = data.spotifyImport.syncStartMinute;
|
||||||
|
document.getElementById('config-sync-time').textContent = `${String(syncHour).padStart(2, '0')}:${String(syncMin).padStart(2, '0')}`;
|
||||||
|
document.getElementById('config-sync-window').textContent = data.spotifyImport.syncWindowHours + ' hours';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch config:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJellyfinUsers() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/jellyfin/users');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const select = document.getElementById('jellyfin-user-select');
|
||||||
|
select.innerHTML = '<option value="">All Users</option>' +
|
||||||
|
data.users.map(u => `<option value="${u.id}">${escapeHtml(u.name)}</option>`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch users:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function fetchJellyfinPlaylists() {
|
||||||
|
const tbody = document.getElementById('jellyfin-playlist-table-body');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build URL with optional user filter
|
||||||
|
const userId = document.getElementById('jellyfin-user-select').value;
|
||||||
|
|
||||||
|
let url = '/api/admin/jellyfin/playlists';
|
||||||
|
if (userId) url += '?userId=' + encodeURIComponent(userId);
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json();
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;color:var(--error);padding:40px;">${errorData.error || 'Failed to fetch playlists'}</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.playlists.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists found in Jellyfin</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = data.playlists.map(p => {
|
||||||
|
const statusBadge = p.isConfigured
|
||||||
|
? '<span class="status-badge success"><span class="status-dot"></span>Linked</span>'
|
||||||
|
: '<span class="status-badge"><span class="status-dot"></span>Not Linked</span>';
|
||||||
|
|
||||||
|
const actionButton = p.isConfigured
|
||||||
|
? `<button class="danger" onclick="unlinkPlaylist('${escapeJs(p.name)}')">Unlink</button>`
|
||||||
|
: `<button class="primary" onclick="openLinkPlaylist('${escapeJs(p.id)}', '${escapeJs(p.name)}')">Link to Spotify</button>`;
|
||||||
|
|
||||||
|
const localCount = p.localTracks || 0;
|
||||||
|
const externalCount = p.externalTracks || 0;
|
||||||
|
const externalAvail = p.externalAvailable || 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr data-playlist-id="${escapeHtml(p.id)}">
|
||||||
|
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||||
|
<td class="track-count">${localCount}</td>
|
||||||
|
<td class="track-count">${externalCount > 0 ? `${externalAvail}/${externalCount}` : '-'}</td>
|
||||||
|
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.linkedSpotifyId || '-'}</td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td>${actionButton}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch Jellyfin playlists:', error);
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--error);padding:40px;">Failed to fetch playlists</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLinkPlaylist(jellyfinId, name) {
|
||||||
|
document.getElementById('link-jellyfin-id').value = jellyfinId;
|
||||||
|
document.getElementById('link-jellyfin-name').value = name;
|
||||||
|
document.getElementById('link-spotify-id').value = '';
|
||||||
|
openModal('link-playlist-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkPlaylist() {
|
||||||
|
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
||||||
|
const name = document.getElementById('link-jellyfin-name').value;
|
||||||
|
const spotifyId = document.getElementById('link-spotify-id').value.trim();
|
||||||
|
|
||||||
|
if (!spotifyId) {
|
||||||
|
showToast('Spotify Playlist ID is required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract ID from various Spotify formats:
|
||||||
|
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
||||||
|
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
|
||||||
|
// - 37i9dQZF1DXcBWIGoYBM5M
|
||||||
|
let cleanSpotifyId = spotifyId;
|
||||||
|
|
||||||
|
// Handle spotify: URI format
|
||||||
|
if (spotifyId.startsWith('spotify:playlist:')) {
|
||||||
|
cleanSpotifyId = spotifyId.replace('spotify:playlist:', '');
|
||||||
|
}
|
||||||
|
// Handle URL format
|
||||||
|
else if (spotifyId.includes('spotify.com/playlist/')) {
|
||||||
|
const match = spotifyId.match(/playlist\/([a-zA-Z0-9]+)/);
|
||||||
|
if (match) cleanSpotifyId = match[1];
|
||||||
|
}
|
||||||
|
// Remove any query parameters or trailing slashes
|
||||||
|
cleanSpotifyId = cleanSpotifyId.split('?')[0].split('#')[0].replace(/\/$/, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Playlist linked!', 'success');
|
||||||
|
showRestartBanner();
|
||||||
|
closeModal('link-playlist-modal');
|
||||||
|
|
||||||
|
// Update UI state without refetching all playlists
|
||||||
|
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||||
|
if (playlistsTable) {
|
||||||
|
const rows = playlistsTable.querySelectorAll('tr');
|
||||||
|
rows.forEach(row => {
|
||||||
|
if (row.dataset.playlistId === jellyfinId) {
|
||||||
|
const actionCell = row.querySelector('td:last-child');
|
||||||
|
if (actionCell) {
|
||||||
|
actionCell.innerHTML = `<button class="danger" onclick="unlinkPlaylist('${escapeJs(name)}')">Unlink</button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPlaylists(); // Only refresh the Active Playlists tab
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to link playlist', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to link playlist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkPlaylist(name) {
|
||||||
|
if (!confirm(`Unlink playlist "${name}"? This will stop filling in missing tracks.`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(name)}/unlink`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Playlist unlinked.', 'success');
|
||||||
|
showRestartBanner();
|
||||||
|
|
||||||
|
// Update UI state without refetching all playlists
|
||||||
|
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||||
|
if (playlistsTable) {
|
||||||
|
const rows = playlistsTable.querySelectorAll('tr');
|
||||||
|
rows.forEach(row => {
|
||||||
|
const nameCell = row.querySelector('td:first-child');
|
||||||
|
if (nameCell && nameCell.textContent === name) {
|
||||||
|
const actionCell = row.querySelector('td:last-child');
|
||||||
|
if (actionCell) {
|
||||||
|
const playlistId = row.dataset.playlistId;
|
||||||
|
actionCell.innerHTML = `<button class="primary" onclick="openLinkPlaylist('${escapeJs(playlistId)}', '${escapeJs(name)}')">Link to Spotify</button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPlaylists(); // Only refresh the Active Playlists tab
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to unlink playlist', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to unlink playlist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPlaylists() {
|
||||||
|
try {
|
||||||
|
showToast('Refreshing playlists...', 'success');
|
||||||
|
const res = await fetch('/api/admin/playlists/refresh', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(data.message, 'success');
|
||||||
|
setTimeout(fetchPlaylists, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to refresh playlists', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function matchPlaylistTracks(name) {
|
||||||
|
try {
|
||||||
|
showToast(`Matching tracks for ${name}...`, 'success');
|
||||||
|
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(`✓ ${data.message}`, 'success');
|
||||||
|
// Refresh the playlists table after a delay to show updated counts
|
||||||
|
setTimeout(fetchPlaylists, 2000);
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to match tracks', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to match tracks', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function matchAllPlaylists() {
|
||||||
|
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
showToast('Matching tracks for all playlists...', 'success');
|
||||||
|
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast(`✓ ${data.message}`, 'success');
|
||||||
|
// Refresh the playlists table after a delay to show updated counts
|
||||||
|
setTimeout(fetchPlaylists, 3000);
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to match tracks', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to match tracks', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCache() {
|
||||||
|
if (!confirm('Clear all cached playlist data?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/cache/clear', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(data.message, 'success');
|
||||||
|
fetchPlaylists();
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to clear cache', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartContainer() {
|
||||||
|
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/restart', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// Show the restart overlay
|
||||||
|
document.getElementById('restart-overlay').classList.add('active');
|
||||||
|
document.getElementById('restart-status').textContent = 'Stopping container...';
|
||||||
|
|
||||||
|
// Wait a bit then start checking if the server is back
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById('restart-status').textContent = 'Waiting for server to come back...';
|
||||||
|
checkServerAndReload();
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
showToast(data.message || data.error || 'Failed to restart', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to restart container', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkServerAndReload() {
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 60; // Try for 60 seconds
|
||||||
|
|
||||||
|
const checkHealth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/status', {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
document.getElementById('restart-status').textContent = 'Server is back! Reloading...';
|
||||||
|
dismissRestartBanner();
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Server still restarting
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts++;
|
||||||
|
document.getElementById('restart-status').textContent = `Waiting for server to come back... (${attempts}s)`;
|
||||||
|
|
||||||
|
if (attempts < maxAttempts) {
|
||||||
|
setTimeout(checkHealth, 1000);
|
||||||
|
} else {
|
||||||
|
document.getElementById('restart-overlay').classList.remove('active');
|
||||||
|
showToast('Server may still be restarting. Please refresh manually.', 'warning');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkHealth();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddPlaylist() {
|
||||||
|
document.getElementById('new-playlist-name').value = '';
|
||||||
|
document.getElementById('new-playlist-id').value = '';
|
||||||
|
openModal('add-playlist-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPlaylist() {
|
||||||
|
const name = document.getElementById('new-playlist-name').value.trim();
|
||||||
|
const id = document.getElementById('new-playlist-id').value.trim();
|
||||||
|
|
||||||
|
if (!name || !id) {
|
||||||
|
showToast('Name and ID are required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/playlists', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, spotifyId: id })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Playlist added.', 'success');
|
||||||
|
showRestartBanner();
|
||||||
|
closeModal('add-playlist-modal');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to add playlist', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to add playlist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePlaylist(name) {
|
||||||
|
if (!confirm(`Remove playlist "${name}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name), {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Playlist removed.', 'success');
|
||||||
|
showRestartBanner();
|
||||||
|
fetchPlaylists();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to remove playlist', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to remove playlist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewTracks(name) {
|
||||||
|
document.getElementById('tracks-modal-title').textContent = name + ' - Tracks';
|
||||||
|
document.getElementById('tracks-list').innerHTML = '<div class="loading"><span class="spinner"></span> Loading tracks...</div>';
|
||||||
|
openModal('tracks-modal');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.tracks.length === 0) {
|
||||||
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tracks-list').innerHTML = data.tracks.map(t => {
|
||||||
|
let statusBadge = '';
|
||||||
|
let mapButton = '';
|
||||||
|
|
||||||
|
if (t.isLocal === true) {
|
||||||
|
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
|
||||||
|
} else if (t.isLocal === false) {
|
||||||
|
statusBadge = '<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>External</span>';
|
||||||
|
// Add manual map button for external tracks
|
||||||
|
// Use JSON.stringify to properly escape strings for JavaScript
|
||||||
|
const escapedName = JSON.stringify(name);
|
||||||
|
const escapedTitle = JSON.stringify(t.title || '');
|
||||||
|
// Safely get first artist, defaulting to empty string
|
||||||
|
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||||
|
const escapedArtist = JSON.stringify(firstArtist);
|
||||||
|
const escapedSpotifyId = JSON.stringify(t.spotifyId || '');
|
||||||
|
mapButton = `<button class="small" onclick="openManualMap(${escapedName}, ${t.position}, ${escapedTitle}, ${escapedArtist}, ${escapedSpotifyId})" style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="track-item">
|
||||||
|
<span class="track-position">${t.position + 1}</span>
|
||||||
|
<div class="track-info">
|
||||||
|
<h4>${escapeHtml(t.title)}${statusBadge}${mapButton}</h4>
|
||||||
|
<span class="artists">${escapeHtml((t.artists || []).join(', '))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="track-meta">
|
||||||
|
${t.album ? escapeHtml(t.album) : ''}
|
||||||
|
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic edit setting modal
|
||||||
|
function openEditSetting(envKey, label, inputType, helpText = '', options = []) {
|
||||||
|
currentEditKey = envKey;
|
||||||
|
currentEditType = inputType;
|
||||||
|
currentEditOptions = options;
|
||||||
|
|
||||||
|
document.getElementById('edit-setting-title').textContent = 'Edit ' + label;
|
||||||
|
document.getElementById('edit-setting-label').textContent = label;
|
||||||
|
|
||||||
|
const helpEl = document.getElementById('edit-setting-help');
|
||||||
|
if (helpText) {
|
||||||
|
helpEl.textContent = helpText;
|
||||||
|
helpEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
helpEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById('edit-setting-input-container');
|
||||||
|
|
||||||
|
if (inputType === 'toggle') {
|
||||||
|
container.innerHTML = `
|
||||||
|
<select id="edit-setting-value">
|
||||||
|
<option value="true">Enabled</option>
|
||||||
|
<option value="false">Disabled</option>
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
} else if (inputType === 'select') {
|
||||||
|
container.innerHTML = `
|
||||||
|
<select id="edit-setting-value">
|
||||||
|
${options.map(opt => `<option value="${opt}">${opt}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
} else if (inputType === 'password') {
|
||||||
|
container.innerHTML = `<input type="password" id="edit-setting-value" placeholder="Enter new value" autocomplete="off">`;
|
||||||
|
} else if (inputType === 'number') {
|
||||||
|
container.innerHTML = `<input type="number" id="edit-setting-value" placeholder="Enter value">`;
|
||||||
|
} else {
|
||||||
|
container.innerHTML = `<input type="text" id="edit-setting-value" placeholder="Enter value">`;
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal('edit-setting-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditSetting() {
|
||||||
|
const value = document.getElementById('edit-setting-value').value.trim();
|
||||||
|
|
||||||
|
if (!value && currentEditType !== 'toggle') {
|
||||||
|
showToast('Value is required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ updates: { [currentEditKey]: value } })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Setting updated.', 'success');
|
||||||
|
showRestartBanner();
|
||||||
|
closeModal('edit-setting-modal');
|
||||||
|
fetchConfig();
|
||||||
|
fetchStatus();
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to update setting', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to update setting', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual track mapping
|
||||||
|
let searchTimeout = null;
|
||||||
|
|
||||||
|
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||||
|
document.getElementById('map-playlist-name').value = playlistName;
|
||||||
|
document.getElementById('map-position').textContent = position + 1;
|
||||||
|
document.getElementById('map-spotify-title').textContent = title;
|
||||||
|
document.getElementById('map-spotify-artist').textContent = artist;
|
||||||
|
document.getElementById('map-spotify-id').value = spotifyId;
|
||||||
|
document.getElementById('map-search-query').value = '';
|
||||||
|
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||||
|
document.getElementById('map-save-btn').disabled = true;
|
||||||
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks...</p>';
|
||||||
|
|
||||||
|
openModal('manual-map-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchJellyfinTracks() {
|
||||||
|
const query = document.getElementById('map-search-query').value.trim();
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks...</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(async () => {
|
||||||
|
document.getElementById('map-search-results').innerHTML = '<div class="loading"><span class="spinner"></span> Searching...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.tracks.length === 0) {
|
||||||
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">No tracks found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('map-search-results').innerHTML = data.tracks.map(t => `
|
||||||
|
<div class="track-item" style="cursor: pointer; border: 2px solid transparent;" onclick="selectJellyfinTrack('${t.id}', this)">
|
||||||
|
<div class="track-info">
|
||||||
|
<h4>${escapeHtml(t.title)}</h4>
|
||||||
|
<span class="artists">${escapeHtml(t.artist)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="track-meta">
|
||||||
|
${t.album ? escapeHtml(t.album) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Search failed</p>';
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectJellyfinTrack(jellyfinId, element) {
|
||||||
|
// Remove selection from all tracks
|
||||||
|
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
|
||||||
|
el.style.border = '2px solid transparent';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight selected track
|
||||||
|
element.style.border = '2px solid var(--primary)';
|
||||||
|
|
||||||
|
// Store selected ID and enable save button
|
||||||
|
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
|
||||||
|
document.getElementById('map-save-btn').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveManualMapping() {
|
||||||
|
const playlistName = document.getElementById('map-playlist-name').value;
|
||||||
|
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||||
|
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
|
||||||
|
|
||||||
|
if (!jellyfinId) {
|
||||||
|
showToast('Please select a track', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ spotifyId, jellyfinId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Track mapped successfully! Refresh the playlist to see changes.', 'success');
|
||||||
|
closeModal('manual-map-modal');
|
||||||
|
// Refresh the tracks view
|
||||||
|
viewTracks(playlistName);
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to save mapping', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to save mapping', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeJs(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetchStatus();
|
||||||
|
fetchPlaylists();
|
||||||
|
fetchJellyfinUsers();
|
||||||
|
fetchJellyfinPlaylists();
|
||||||
|
fetchConfig();
|
||||||
|
|
||||||
|
// Auto-refresh every 30 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
fetchStatus();
|
||||||
|
fetchPlaylists();
|
||||||
|
}, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -34,6 +34,9 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5274:8080"
|
- "5274:8080"
|
||||||
|
# Admin UI on port 5275 - for local/Tailscale access only
|
||||||
|
# DO NOT expose through reverse proxy - contains sensitive config
|
||||||
|
- "5275:5275"
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -86,6 +89,16 @@ services:
|
|||||||
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
|
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
|
||||||
- SpotifyImport__PlaylistLocalTracksPositions=${SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS:-}
|
- SpotifyImport__PlaylistLocalTracksPositions=${SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS:-}
|
||||||
|
|
||||||
|
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
|
||||||
|
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
|
||||||
|
- SpotifyApi__ClientId=${SPOTIFY_API_CLIENT_ID:-}
|
||||||
|
- SpotifyApi__ClientSecret=${SPOTIFY_API_CLIENT_SECRET:-}
|
||||||
|
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
|
||||||
|
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
|
||||||
|
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
||||||
|
- SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100}
|
||||||
|
- SpotifyApi__PreferIsrcMatching=${SPOTIFY_API_PREFER_ISRC_MATCHING:-true}
|
||||||
|
|
||||||
# ===== SHARED =====
|
# ===== SHARED =====
|
||||||
- Library__DownloadPath=/app/downloads
|
- Library__DownloadPath=/app/downloads
|
||||||
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}
|
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}
|
||||||
@@ -99,6 +112,10 @@ services:
|
|||||||
- ${DOWNLOAD_PATH:-./downloads}:/app/downloads
|
- ${DOWNLOAD_PATH:-./downloads}:/app/downloads
|
||||||
- ${KEPT_PATH:-./kept}:/app/kept
|
- ${KEPT_PATH:-./kept}:/app/kept
|
||||||
- ${CACHE_PATH:-./cache}:/app/cache
|
- ${CACHE_PATH:-./cache}:/app/cache
|
||||||
|
# Mount .env file for runtime configuration updates from admin UI
|
||||||
|
- ./.env:/app/.env
|
||||||
|
# Docker socket for self-restart capability (admin UI only)
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
allstarr-network:
|
allstarr-network:
|
||||||
|
|||||||
Reference in New Issue
Block a user