mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
6 Commits
6357b524da
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
f3c791496e
|
|||
|
f68706f300
|
|||
|
9f362b4920
|
|||
|
2b09484c0b
|
|||
|
fa9739bfaa
|
|||
|
0ba51e2b30
|
12
.env.example
12
.env.example
@@ -35,12 +35,24 @@ JELLYFIN_LIBRARY_ID=
|
||||
# Music service to use: SquidWTF, Deezer, or Qobuz (default: SquidWTF)
|
||||
MUSIC_SERVICE=SquidWTF
|
||||
|
||||
<<<<<<< HEAD
|
||||
# Base directory for all downloads (default: ./downloads)
|
||||
# This creates three subdirectories:
|
||||
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
||||
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
||||
# - downloads/kept/ - Favorited external tracks (always permanent)
|
||||
DOWNLOAD_PATH=./downloads
|
||||
||||||| bc4e5d9
|
||||
# Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent)
|
||||
DOWNLOAD_PATH=./downloads
|
||||
=======
|
||||
# Base directory for all downloads (default: ./downloads)
|
||||
# This creates three subdirectories:
|
||||
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
||||
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
||||
# - downloads/kept/ - Favorited external tracks (always permanent)
|
||||
Library__DownloadPath=./downloads
|
||||
>>>>>>> dev
|
||||
|
||||
# ===== SQUIDWTF CONFIGURATION =====
|
||||
# Different quality options for SquidWTF. Only FLAC supported right now
|
||||
|
||||
199
README.md
199
README.md
@@ -34,6 +34,7 @@ docker-compose logs -f
|
||||
|
||||
The proxy will be available at `http://localhost:5274`.
|
||||
|
||||
<<<<<<< HEAD
|
||||
## Web Dashboard
|
||||
|
||||
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
||||
@@ -76,6 +77,51 @@ There's an environment variable to modify this.
|
||||
|
||||
|
||||
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
## Web Dashboard
|
||||
|
||||
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
||||
<img width="1664" height="1101" alt="image" src="https://github.com/user-attachments/assets/9159100b-7e11-449e-8530-517d336d6bd2" />
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- **Playlist Management**: Link Jellyfin playlists to Spotify playlists with just a few clicks
|
||||
- **Provider Matching**: It should fill in the gaps of your Jellyfin library with tracks from your selected provider
|
||||
- **WebUI**: Update settings without manually editing .env files
|
||||
- **Music**: Using multiple sources for music (optimized for SquidWTF right now, though)
|
||||
- **Lyrics**: Using multiple sources for lyrics, first Jellyfin Lyrics, then Spotify Lyrics, then LrcLib as a last resort
|
||||
|
||||
### 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 (should be a banner)
|
||||
|
||||
Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them.
|
||||
|
||||
### 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`.
|
||||
|
||||
There's an environment variable to modify this.
|
||||
|
||||
|
||||
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
|
||||
|
||||
>>>>>>> dev
|
||||
### 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!
|
||||
@@ -139,8 +185,21 @@ This project brings together all the music streaming providers into one unified
|
||||
**Compatible Jellyfin clients:**
|
||||
|
||||
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
|
||||
<<<<<<< HEAD
|
||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
|
||||
- [Finamp](https://github.com/jmshrv/finamp) ()
|
||||
||||||| bc4e5d9
|
||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
|
||||
=======
|
||||
<img width="1691" height="1128" alt="image" src="https://github.com/user-attachments/assets/c602f71c-c4dd-49a9-b533-1558e24a9f45" />
|
||||
|
||||
|
||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android)
|
||||
<img width="523" height="1025" alt="image" src="https://github.com/user-attachments/assets/135e2721-5fd7-482f-bb06-b0736003cfe7" />
|
||||
|
||||
|
||||
- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS)
|
||||
>>>>>>> dev
|
||||
|
||||
_Working on getting more currently_
|
||||
|
||||
@@ -332,6 +391,7 @@ Subsonic__EnableExternalPlaylists=false
|
||||
|
||||
> **Note**: Due to client-side filtering, playlists from streaming providers may not appear in the "Playlists" tab of some clients, but will show up in global search results.
|
||||
|
||||
<<<<<<< HEAD
|
||||
### Spotify Playlist Injection (Jellyfin Only)
|
||||
|
||||
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
|
||||
@@ -456,6 +516,136 @@ The easiest way to manage Spotify playlists is through the Web UI at `http://loc
|
||||
- Rate limiting prevents overwhelming your streaming provider
|
||||
- Only works with Jellyfin backend (not Subsonic/Navidrome)
|
||||
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
### Spotify Playlist Injection (Jellyfin Only)
|
||||
|
||||
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
|
||||
|
||||
<img width="1649" height="3764" alt="image" src="https://github.com/user-attachments/assets/a4d3d79c-7741-427f-8c01-ffc90f3a579b" />
|
||||
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
1. **Install the Jellyfin Spotify Import Plugin**
|
||||
- Navigate to Jellyfin Dashboard → Plugins → Catalog
|
||||
- Search for "Spotify Import" by Viperinius
|
||||
- Install and restart Jellyfin
|
||||
- Plugin repository: [Viperinius/jellyfin-plugin-spotify-import](https://github.com/Viperinius/jellyfin-plugin-spotify-import)
|
||||
|
||||
2. **Configure the Spotify Import Plugin**
|
||||
- Go to Jellyfin Dashboard → Plugins → Spotify Import
|
||||
- Connect your Spotify account
|
||||
- Select which playlists to sync (e.g., Release Radar, Discover Weekly)
|
||||
- Set a sync schedule (the plugin will create playlists in Jellyfin)
|
||||
|
||||
3. **Configure Allstarr**
|
||||
- Enable Spotify Import in Allstarr (see configuration below)
|
||||
- Link your Jellyfin playlists to Spotify playlists via the Web UI
|
||||
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
|
||||
|
||||
#### Configuration
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
|
||||
| `SpotifyImport:MatchingIntervalHours` | How often to run track matching in hours (default: 24, set to 0 for startup only) |
|
||||
| `SpotifyImport:Playlists` | JSON array of playlists (managed via Web UI) |
|
||||
|
||||
**Environment variables example:**
|
||||
```bash
|
||||
# Enable the feature
|
||||
SPOTIFY_IMPORT_ENABLED=true
|
||||
|
||||
# Matching interval (24 hours = once per day)
|
||||
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
|
||||
|
||||
# Playlists (use Web UI to manage instead of editing manually)
|
||||
SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","37i9dQZEVXcV6s7Dm7RXsU","first"],["Release Radar","37i9dQZEVXbng2vDHnfQlC","first"]]
|
||||
```
|
||||
|
||||
#### How It Works
|
||||
|
||||
1. **Spotify Import Plugin Runs**
|
||||
- Plugin fetches your Spotify playlists
|
||||
- Creates/updates playlists in Jellyfin with tracks already in your library
|
||||
- Generates "missing tracks" JSON files for songs not found locally
|
||||
|
||||
2. **Allstarr Matches Tracks** (on startup + every 24 hours by default)
|
||||
- Reads missing tracks files from the Jellyfin plugin
|
||||
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
|
||||
- Uses fuzzy matching to find the best match (title + artist similarity)
|
||||
- Rate-limited to avoid overwhelming the service (150ms delay between searches)
|
||||
- Pre-builds playlist cache for instant loading
|
||||
|
||||
3. **You Open the Playlist in Jellyfin**
|
||||
- Allstarr intercepts the request
|
||||
- Returns a merged list: local tracks + matched streaming tracks
|
||||
- Loads instantly from cache!
|
||||
|
||||
4. **You Play a Track**
|
||||
- Local tracks stream from Jellyfin normally
|
||||
- Matched tracks download from streaming provider on-demand
|
||||
- Downloaded tracks are saved to your library for future use
|
||||
|
||||
#### Manual API Triggers
|
||||
|
||||
You can manually trigger operations via the admin API:
|
||||
|
||||
```bash
|
||||
# Get API key from your .env file
|
||||
API_KEY="your-api-key-here"
|
||||
|
||||
# Fetch missing tracks from Jellyfin plugin
|
||||
curl "http://localhost:5274/spotify/sync?api_key=$API_KEY"
|
||||
|
||||
# Trigger track matching (searches streaming provider)
|
||||
curl "http://localhost:5274/spotify/match?api_key=$API_KEY"
|
||||
|
||||
# Match all playlists (refresh all matches)
|
||||
curl "http://localhost:5274/spotify/match-all?api_key=$API_KEY"
|
||||
|
||||
# Clear cache and rebuild
|
||||
curl "http://localhost:5274/spotify/clear-cache?api_key=$API_KEY"
|
||||
|
||||
# Refresh specific playlist
|
||||
curl "http://localhost:5274/spotify/refresh-playlist?playlistId=PLAYLIST_ID&api_key=$API_KEY"
|
||||
```
|
||||
|
||||
#### Web UI Management
|
||||
|
||||
The easiest way to manage Spotify playlists is through the Web UI at `http://localhost:5275`:
|
||||
|
||||
1. **Link Playlists Tab**: Link Jellyfin playlists to Spotify playlists
|
||||
2. **Active Playlists Tab**: View status, trigger matching, and manage playlists
|
||||
3. **Configuration Tab**: Enable/disable Spotify Import and adjust settings
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
**Playlists are empty:**
|
||||
- Check that the Spotify Import plugin is running and creating playlists
|
||||
- Verify playlists are linked in the Web UI
|
||||
- Check logs: `docker-compose logs -f allstarr | grep -i spotify`
|
||||
|
||||
**Tracks aren't matching:**
|
||||
- Ensure your streaming provider is configured (`MUSIC_SERVICE`, credentials)
|
||||
- Manually trigger matching via Web UI or API
|
||||
- Check that the Jellyfin plugin generated missing tracks files
|
||||
|
||||
**Performance:**
|
||||
- Matching runs in background with rate limiting (150ms between searches)
|
||||
- First match may take a few minutes for large playlists
|
||||
- Subsequent loads are instant (served from cache)
|
||||
|
||||
#### Notes
|
||||
|
||||
- Uses your existing `JELLYFIN_URL` and `JELLYFIN_API_KEY` settings
|
||||
- Matched tracks cached for fast loading
|
||||
- Missing tracks cache persists across restarts (Redis + file cache)
|
||||
- Rate limiting prevents overwhelming your streaming provider
|
||||
- Only works with Jellyfin backend (not Subsonic/Navidrome)
|
||||
|
||||
>>>>>>> dev
|
||||
### Getting Credentials
|
||||
|
||||
#### Deezer ARL Token
|
||||
@@ -913,5 +1103,12 @@ GPL-3.0
|
||||
- [Hi-Fi API](https://github.com/binimum/hifi-api) - These people do some great work, and you should thank them for this even existing!
|
||||
- [Deezer](https://www.deezer.com/) - Music streaming service
|
||||
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
|
||||
<<<<<<< HEAD
|
||||
- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
|
||||
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of
|
||||
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of
|
||||
||||||| bc4e5d9
|
||||
- [Subsonic API](http://www.subsonic.org/pages/api.jsp) - The API specification
|
||||
=======
|
||||
- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
|
||||
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of
|
||||
>>>>>>> dev
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
@@ -3494,4 +3495,3631 @@ public class LinkPlaylistRequest
|
||||
}
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
}
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services;
|
||||
using allstarr.Filters;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Runtime;
|
||||
|
||||
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 SubsonicSettings _subsonicSettings;
|
||||
private readonly DeezerSettings _deezerSettings;
|
||||
private readonly QobuzSettings _qobuzSettings;
|
||||
private readonly SquidWTFSettings _squidWtfSettings;
|
||||
private readonly MusicBrainzSettings _musicBrainzSettings;
|
||||
private readonly SpotifyApiClient _spotifyClient;
|
||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||
private readonly SpotifyTrackMatchingService? _matchingService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly HttpClient _jellyfinHttpClient;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly string _envFilePath;
|
||||
private readonly List<string> _squidWtfApiUrls;
|
||||
private static int _urlIndex = 0;
|
||||
private static readonly object _urlIndexLock = new();
|
||||
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<SubsonicSettings> subsonicSettings,
|
||||
IOptions<DeezerSettings> deezerSettings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||
IOptions<MusicBrainzSettings> musicBrainzSettings,
|
||||
SpotifyApiClient spotifyClient,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
RedisCacheService cache,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IServiceProvider serviceProvider,
|
||||
SpotifyTrackMatchingService? matchingService = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_environment = environment;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_subsonicSettings = subsonicSettings.Value;
|
||||
_deezerSettings = deezerSettings.Value;
|
||||
_qobuzSettings = qobuzSettings.Value;
|
||||
_squidWtfSettings = squidWtfSettings.Value;
|
||||
_musicBrainzSettings = musicBrainzSettings.Value;
|
||||
_spotifyClient = spotifyClient;
|
||||
_playlistFetcher = playlistFetcher;
|
||||
_matchingService = matchingService;
|
||||
_cache = cache;
|
||||
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
||||
_serviceProvider = serviceProvider;
|
||||
|
||||
// Decode SquidWTF base URLs
|
||||
_squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||
|
||||
// .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";
|
||||
}
|
||||
|
||||
private static List<string> DecodeSquidWtfUrls()
|
||||
{
|
||||
var encodedUrls = new[]
|
||||
{
|
||||
"aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton
|
||||
"aHR0cHM6Ly90aWRhbC1hcGkuYmluaW11bS5vcmc=", // binimum
|
||||
"aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus
|
||||
"aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spoti-2
|
||||
"aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spoti-1
|
||||
"aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf
|
||||
"aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund
|
||||
"aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze
|
||||
"aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel
|
||||
"aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==" // maus
|
||||
};
|
||||
|
||||
return encodedUrls
|
||||
.Select(encoded => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to safely check if a dynamic cache result has a value
|
||||
/// Handles the case where JsonElement cannot be compared to null directly
|
||||
/// </summary>
|
||||
private static bool HasValue(object? obj)
|
||||
{
|
||||
if (obj == null) return false;
|
||||
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <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,
|
||||
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
|
||||
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 a random SquidWTF base URL for searching (round-robin)
|
||||
/// </summary>
|
||||
[HttpGet("squidwtf-base-url")]
|
||||
public IActionResult GetSquidWtfBaseUrl()
|
||||
{
|
||||
if (_squidWtfApiUrls.Count == 0)
|
||||
{
|
||||
return NotFound(new { error = "No SquidWTF base URLs configured" });
|
||||
}
|
||||
|
||||
string baseUrl;
|
||||
lock (_urlIndexLock)
|
||||
{
|
||||
baseUrl = _squidWtfApiUrls[_urlIndex];
|
||||
_urlIndex = (_urlIndex + 1) % _squidWtfApiUrls.Count;
|
||||
}
|
||||
|
||||
return Ok(new { baseUrl });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get list of configured playlists with their current data
|
||||
/// </summary>
|
||||
[HttpGet("playlists")]
|
||||
public async Task<IActionResult> GetPlaylists([FromQuery] bool refresh = false)
|
||||
{
|
||||
var playlistCacheFile = "/app/cache/admin_playlists_summary.json";
|
||||
|
||||
// Check file cache first (5 minute TTL) unless refresh is requested
|
||||
if (!refresh && System.IO.File.Exists(playlistCacheFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(playlistCacheFile);
|
||||
var age = DateTime.UtcNow - fileInfo.LastWriteTimeUtc;
|
||||
|
||||
if (age.TotalMinutes < 5)
|
||||
{
|
||||
var cachedJson = await System.IO.File.ReadAllTextAsync(playlistCacheFile);
|
||||
var cachedData = JsonSerializer.Deserialize<Dictionary<string, object>>(cachedJson);
|
||||
_logger.LogDebug("📦 Returning cached playlist summary (age: {Age:F1}m)", age.TotalMinutes);
|
||||
return Ok(cachedData);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("🔄 Cache expired (age: {Age:F1}m), refreshing...", age.TotalMinutes);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read cached playlist summary");
|
||||
}
|
||||
}
|
||||
else if (refresh)
|
||||
{
|
||||
_logger.LogInformation("🔄 Force refresh requested for playlist summary");
|
||||
}
|
||||
|
||||
var playlists = new List<object>();
|
||||
|
||||
// Read playlists directly from .env file to get the latest configuration
|
||||
// (IOptions is cached and doesn't reload after .env changes)
|
||||
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
|
||||
foreach (var config in configuredPlaylists)
|
||||
{
|
||||
var playlistInfo = new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = config.Name,
|
||||
["id"] = config.Id,
|
||||
["jellyfinId"] = config.JellyfinId,
|
||||
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
||||
["syncSchedule"] = config.SyncSchedule ?? "0 8 * * 1",
|
||||
["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))
|
||||
{
|
||||
// Get Spotify tracks to match against
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
|
||||
// Try to use the pre-built playlist cache first (includes manual mappings!)
|
||||
var playlistItemsCacheKey = $"spotify:playlist:items:{config.Name}";
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
{
|
||||
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||
}
|
||||
catch (Exception cacheEx)
|
||||
{
|
||||
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}",
|
||||
config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||
|
||||
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||
{
|
||||
// Use the pre-built cache which respects manual mappings
|
||||
var localCount = 0;
|
||||
var externalCount = 0;
|
||||
|
||||
foreach (var item in cachedPlaylistItems)
|
||||
{
|
||||
// Check if it's external by looking for external provider in ProviderIds
|
||||
// External providers: SquidWTF, Deezer, Qobuz, Tidal
|
||||
var isExternal = false;
|
||||
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
// Handle both Dictionary<string, string> and JsonElement
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (providerIds != null)
|
||||
{
|
||||
// Check for external provider keys (not MusicBrainz, ISRC, Spotify, etc)
|
||||
isExternal = providerIds.Keys.Any(k =>
|
||||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("Deezer", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("Qobuz", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("Tidal", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
externalCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
localCount++;
|
||||
}
|
||||
}
|
||||
|
||||
var externalMissingCount = spotifyTracks.Count - cachedPlaylistItems.Count;
|
||||
if (externalMissingCount < 0) externalMissingCount = 0;
|
||||
|
||||
playlistInfo["localTracks"] = localCount;
|
||||
playlistInfo["externalMatched"] = externalCount;
|
||||
playlistInfo["externalMissing"] = externalMissingCount;
|
||||
playlistInfo["externalTotal"] = externalCount + externalMissingCount;
|
||||
playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count;
|
||||
playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served
|
||||
|
||||
_logger.LogInformation("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||
config.Name, spotifyTracks.Count, localCount, externalCount, externalMissingCount, localCount + externalCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: Build list of local tracks from Jellyfin (match by name only)
|
||||
var localTracks = new List<(string Title, string Artist)>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
// Get matched external tracks cache once
|
||||
var matchedTracksKey = $"spotify:matched:ordered:{config.Name}";
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
var matchedSpotifyIds = new HashSet<string>(
|
||||
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
);
|
||||
|
||||
var localCount = 0;
|
||||
var externalMatchedCount = 0;
|
||||
var externalMissingCount = 0;
|
||||
|
||||
// Match each Spotify track to determine if it's local, external, or missing
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
var isLocal = false;
|
||||
var hasExternalMapping = false;
|
||||
|
||||
// FIRST: Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
// Manual Jellyfin mapping exists - this track is definitely local
|
||||
isLocal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
// External manual mapping exists
|
||||
hasExternalMapping = true;
|
||||
}
|
||||
else if (localTracks.Count > 0)
|
||||
{
|
||||
// SECOND: No manual mapping, try fuzzy matching with local tracks
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLocal)
|
||||
{
|
||||
localCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if external track is matched (either manual mapping or auto-matched)
|
||||
if (hasExternalMapping || matchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
externalMatchedCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
externalMissingCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playlistInfo["localTracks"] = localCount;
|
||||
playlistInfo["externalMatched"] = externalMatchedCount;
|
||||
playlistInfo["externalMissing"] = externalMissingCount;
|
||||
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
|
||||
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
|
||||
playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served
|
||||
|
||||
_logger.LogDebug("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||
config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount, localCount + externalMatchedCount);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// Save to file cache
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
var cacheFile = Path.Combine(cacheDir, "admin_playlists_summary.json");
|
||||
|
||||
var response = new { playlists };
|
||||
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false });
|
||||
await System.IO.File.WriteAllTextAsync(cacheFile, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved playlist summary to cache");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save playlist summary cache");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
var tracksWithStatus = new List<object>();
|
||||
|
||||
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
|
||||
// This cache includes all matched tracks with proper provider IDs
|
||||
var playlistItemsCacheKey = $"spotify:playlist:items:{decodedName}";
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
{
|
||||
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||
}
|
||||
catch (Exception cacheEx)
|
||||
{
|
||||
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
_logger.LogInformation("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}",
|
||||
decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||
|
||||
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||
{
|
||||
// Build a map of Spotify ID -> cached item for quick lookup
|
||||
var spotifyIdToItem = new Dictionary<string, Dictionary<string, object?>>();
|
||||
|
||||
foreach (var item in cachedPlaylistItems)
|
||||
{
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId))
|
||||
{
|
||||
spotifyIdToItem[spotifyId] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match each Spotify track to its cached item
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
bool? isLocal = null;
|
||||
string? externalProvider = null;
|
||||
bool isManualMapping = false;
|
||||
string? manualMappingType = null;
|
||||
string? manualMappingId = null;
|
||||
|
||||
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out var cachedItem))
|
||||
{
|
||||
// Track is in the cache - determine if it's local or external
|
||||
if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (providerIdsObj is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds = dict;
|
||||
}
|
||||
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
providerIds = new Dictionary<string, string>();
|
||||
foreach (var prop in jsonEl.EnumerateObject())
|
||||
{
|
||||
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (providerIds != null)
|
||||
{
|
||||
_logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys));
|
||||
|
||||
// Check for external provider keys (case-insensitive)
|
||||
// External providers: squidwtf, deezer, qobuz, tidal (lowercase)
|
||||
var providerKey = providerIds.Keys.FirstOrDefault(k =>
|
||||
k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (providerKey != null)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF";
|
||||
_logger.LogDebug("✓ Track {Title} identified as SquidWTF", track.Title);
|
||||
}
|
||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase))) != null)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "Deezer";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Deezer", track.Title);
|
||||
}
|
||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase))) != null)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "Qobuz";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Qobuz", track.Title);
|
||||
}
|
||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase))) != null)
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "Tidal";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Tidal", track.Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No external provider key found - it's a local track
|
||||
// Local tracks have MusicBrainz, ISRC, Spotify IDs but no external provider
|
||||
isLocal = true;
|
||||
_logger.LogDebug("✓ Track {Title} identified as LOCAL (has ProviderIds but no external provider)", track.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Track {Title} has ProviderIds object but it's null after parsing", track.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Track {Title} in cache but has NO ProviderIds - treating as missing", track.Title);
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
|
||||
// Check if this is a manual mapping
|
||||
var manualJellyfinKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualJellyfinKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
isManualMapping = true;
|
||||
manualMappingType = "jellyfin";
|
||||
manualMappingId = manualJellyfinId;
|
||||
}
|
||||
else
|
||||
{
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
if (extRoot.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
isManualMapping = true;
|
||||
manualMappingType = "external";
|
||||
manualMappingId = idEl.GetString();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Track not in cache - it's missing
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
|
||||
// Check lyrics status
|
||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
|
||||
|
||||
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,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null,
|
||||
isManualMapping = isManualMapping,
|
||||
manualMappingType = manualMappingType,
|
||||
manualMappingId = manualMappingId,
|
||||
hasLyrics = hasLyrics
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
name = decodedName,
|
||||
trackCount = spotifyTracks.Count,
|
||||
tracks = tracksWithStatus
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: Cache not available, use matched tracks cache
|
||||
_logger.LogWarning("Playlist cache not available for {Playlist}, using fallback", decodedName);
|
||||
|
||||
var fallbackMatchedTracksKey = $"spotify:matched:ordered:{decodedName}";
|
||||
var fallbackMatchedTracks = await _cache.GetAsync<List<MatchedTrack>>(fallbackMatchedTracksKey);
|
||||
var fallbackMatchedSpotifyIds = new HashSet<string>(
|
||||
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
);
|
||||
|
||||
foreach (var track in spotifyTracks)
|
||||
{
|
||||
bool? isLocal = null;
|
||||
string? externalProvider = null;
|
||||
|
||||
// Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
isLocal = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||
var extRoot = extDoc.RootElement;
|
||||
|
||||
string? provider = null;
|
||||
|
||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
||||
{
|
||||
provider = providerEl.GetString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(provider))
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = provider;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||
}
|
||||
}
|
||||
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF";
|
||||
}
|
||||
else
|
||||
{
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
externalProvider = externalProvider,
|
||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
name = decodedName,
|
||||
trackCount = spotifyTracks.Count,
|
||||
tracks = tracksWithStatus
|
||||
});
|
||||
}
|
||||
|
||||
/// <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();
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
InvalidatePlaylistSummaryCache();
|
||||
|
||||
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);
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
InvalidatePlaylistSummaryCache();
|
||||
|
||||
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>
|
||||
/// Clear cache and rebuild for a specific playlist
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/clear-cache")]
|
||||
public async Task<IActionResult> ClearPlaylistCache(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Clear cache & rebuild triggered for playlist: {Name}", decodedName);
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "Track matching service is not available" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Clear all cache keys for this playlist
|
||||
var cacheKeys = new[]
|
||||
{
|
||||
$"spotify:playlist:items:{decodedName}", // Pre-built items cache
|
||||
$"spotify:matched:ordered:{decodedName}", // Ordered matched tracks
|
||||
$"spotify:matched:{decodedName}", // Legacy matched tracks
|
||||
$"spotify:missing:{decodedName}" // Missing tracks
|
||||
};
|
||||
|
||||
foreach (var key in cacheKeys)
|
||||
{
|
||||
await _cache.DeleteAsync(key);
|
||||
_logger.LogDebug("Cleared cache key: {Key}", key);
|
||||
}
|
||||
|
||||
// Delete file caches
|
||||
var safeName = string.Join("_", decodedName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filesToDelete = new[]
|
||||
{
|
||||
Path.Combine(CacheDirectory, $"{safeName}_items.json"),
|
||||
Path.Combine(CacheDirectory, $"{safeName}_matched.json")
|
||||
};
|
||||
|
||||
foreach (var file in filesToDelete)
|
||||
{
|
||||
if (System.IO.File.Exists(file))
|
||||
{
|
||||
System.IO.File.Delete(file);
|
||||
_logger.LogDebug("Deleted cache file: {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ Cleared all caches for playlist: {Name}", decodedName);
|
||||
|
||||
// Trigger rebuild
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
InvalidatePlaylistSummaryCache();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = $"Cache cleared and rebuild triggered for {decodedName}",
|
||||
timestamp = DateTime.UtcNow,
|
||||
clearedKeys = cacheKeys.Length,
|
||||
clearedFiles = filesToDelete.Count(System.IO.File.Exists)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to clear cache for {Name}", decodedName);
|
||||
return StatusCode(500, new { error = "Failed to clear cache", 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 userId = _jellyfinSettings.UserId;
|
||||
|
||||
// Build URL with UserId if available
|
||||
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
url += $"&UserId={userId}";
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
_logger.LogDebug("Searching Jellyfin: {Url}", url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
|
||||
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())
|
||||
{
|
||||
// Verify it's actually an Audio item
|
||||
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
||||
if (type != "Audio")
|
||||
{
|
||||
_logger.LogDebug("Skipping non-audio item: {Type}", type);
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
||||
var artist = "";
|
||||
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
tracks.Add(new { id, title, artist, album });
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { tracks });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to search Jellyfin tracks");
|
||||
return StatusCode(500, new { error = "Search failed" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get track details by Jellyfin ID (for URL-based mapping)
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/track/{id}")]
|
||||
public async Task<IActionResult> GetJellyfinTrack(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return BadRequest(new { error = "Track ID is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
|
||||
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
url += $"?UserId={userId}";
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
_logger.LogDebug("Fetching Jellyfin track {Id} from {Url}", id, url);
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}",
|
||||
id, response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var item = doc.RootElement;
|
||||
|
||||
// Verify it's an Audio item
|
||||
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
||||
if (type != "Audio")
|
||||
{
|
||||
_logger.LogWarning("Item {Id} is not an Audio track, it's a {Type}", id, type);
|
||||
return BadRequest(new { error = $"Item is not an audio track (it's a {type})" });
|
||||
}
|
||||
|
||||
var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
||||
var artist = "";
|
||||
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found Jellyfin track: {Title} by {Artist}", title, artist);
|
||||
|
||||
return Ok(new { id = trackId, title, artist, album });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get Jellyfin track {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to get track details" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save manual track mapping (local Jellyfin or external provider)
|
||||
/// </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))
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyId is required" });
|
||||
}
|
||||
|
||||
// Validate that either Jellyfin mapping or external mapping is provided
|
||||
var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId);
|
||||
var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId);
|
||||
|
||||
if (!hasJellyfinMapping && !hasExternalMapping)
|
||||
{
|
||||
return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" });
|
||||
}
|
||||
|
||||
if (hasJellyfinMapping && hasExternalMapping)
|
||||
{
|
||||
return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string? normalizedProvider = null;
|
||||
|
||||
if (hasJellyfinMapping)
|
||||
{
|
||||
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||
await _cache.SetAsync(mappingKey, request.JellyfinId!);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null);
|
||||
|
||||
_logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
||||
decodedName, request.SpotifyId, request.JellyfinId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||
var externalMapping = new { provider = normalizedProvider, id = request.ExternalId };
|
||||
await _cache.SetAsync(externalMappingKey, externalMapping);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!);
|
||||
|
||||
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
|
||||
decodedName, request.SpotifyId, normalizedProvider, request.ExternalId);
|
||||
}
|
||||
|
||||
// Clear all related caches to force rebuild
|
||||
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||
var orderedCacheKey = $"spotify:matched:ordered:{decodedName}";
|
||||
var playlistItemsKey = $"spotify:playlist:items:{decodedName}";
|
||||
|
||||
await _cache.DeleteAsync(matchedCacheKey);
|
||||
await _cache.DeleteAsync(orderedCacheKey);
|
||||
await _cache.DeleteAsync(playlistItemsKey);
|
||||
|
||||
// Also delete file caches to force rebuild
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
var safeName = string.Join("_", decodedName.Split(Path.GetInvalidFileNameChars()));
|
||||
var matchedFile = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||
var itemsFile = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
if (System.IO.File.Exists(matchedFile))
|
||||
{
|
||||
System.IO.File.Delete(matchedFile);
|
||||
_logger.LogDebug("Deleted matched tracks file cache for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
if (System.IO.File.Exists(itemsFile))
|
||||
{
|
||||
System.IO.File.Delete(itemsFile);
|
||||
_logger.LogDebug("Deleted playlist items file cache for {Playlist}", decodedName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete file caches for {Playlist}", decodedName);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
||||
|
||||
// Fetch external provider track details to return to the UI (only for external mappings)
|
||||
string? trackTitle = null;
|
||||
string? trackArtist = null;
|
||||
string? trackAlbum = null;
|
||||
|
||||
if (hasExternalMapping && normalizedProvider != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
|
||||
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
|
||||
|
||||
if (externalSong != null)
|
||||
{
|
||||
trackTitle = externalSong.Title;
|
||||
trackArtist = externalSong.Artist;
|
||||
trackAlbum = externalSong.Album;
|
||||
_logger.LogInformation("✓ Fetched external track metadata: {Title} by {Artist}", trackTitle, trackArtist);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
|
||||
normalizedProvider, request.ExternalId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger immediate playlist rebuild with the new mapping
|
||||
if (_matchingService != null)
|
||||
{
|
||||
_logger.LogInformation("Triggering immediate playlist rebuild for {Playlist} with new manual mapping", decodedName);
|
||||
|
||||
// Run rebuild in background with timeout to avoid blocking the response
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); // 2 minute timeout
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
_logger.LogInformation("✓ Playlist {Playlist} rebuilt successfully with manual mapping", decodedName);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Playlist rebuild for {Playlist} timed out after 2 minutes", decodedName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to rebuild playlist {Playlist} after manual mapping", decodedName);
|
||||
}
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Matching service not available - playlist will rebuild on next scheduled run");
|
||||
}
|
||||
|
||||
// Return success with track details if available
|
||||
var mappedTrack = new
|
||||
{
|
||||
id = request.ExternalId,
|
||||
title = trackTitle ?? "Unknown",
|
||||
artist = trackArtist ?? "Unknown",
|
||||
album = trackAlbum ?? "Unknown",
|
||||
isLocal = false,
|
||||
externalProvider = request.ExternalProvider!.ToLowerInvariant()
|
||||
};
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Mapping saved and playlist rebuild triggered",
|
||||
track = mappedTrack,
|
||||
rebuildTriggered = _matchingService != null
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save manual mapping");
|
||||
return StatusCode(500, new { error = "Failed to save mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
||||
musicService = _configuration.GetValue<string>("MusicService") ?? "SquidWTF",
|
||||
explicitFilter = _configuration.GetValue<string>("ExplicitFilter") ?? "All",
|
||||
enableExternalPlaylists = _configuration.GetValue<bool>("EnableExternalPlaylists", false),
|
||||
playlistsDirectory = _configuration.GetValue<string>("PlaylistsDirectory") ?? "(not set)",
|
||||
redisEnabled = _configuration.GetValue<bool>("Redis:Enabled", false),
|
||||
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,
|
||||
matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours,
|
||||
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
|
||||
},
|
||||
library = new
|
||||
{
|
||||
downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache")
|
||||
: Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "permanent"),
|
||||
keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"),
|
||||
storageMode = _subsonicSettings.StorageMode.ToString(),
|
||||
cacheDurationHours = _subsonicSettings.CacheDurationHours,
|
||||
downloadMode = _subsonicSettings.DownloadMode.ToString()
|
||||
},
|
||||
deezer = new
|
||||
{
|
||||
arl = MaskValue(_deezerSettings.Arl, showLast: 8),
|
||||
arlFallback = MaskValue(_deezerSettings.ArlFallback, showLast: 8),
|
||||
quality = _deezerSettings.Quality ?? "FLAC"
|
||||
},
|
||||
qobuz = new
|
||||
{
|
||||
userAuthToken = MaskValue(_qobuzSettings.UserAuthToken, showLast: 8),
|
||||
userId = _qobuzSettings.UserId,
|
||||
quality = _qobuzSettings.Quality ?? "FLAC"
|
||||
},
|
||||
squidWtf = new
|
||||
{
|
||||
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
||||
},
|
||||
musicBrainz = new
|
||||
{
|
||||
enabled = _musicBrainzSettings.Enabled,
|
||||
username = _musicBrainzSettings.Username ?? "(not set)",
|
||||
password = MaskValue(_musicBrainzSettings.Password),
|
||||
baseUrl = _musicBrainzSettings.BaseUrl,
|
||||
rateLimitMs = _musicBrainzSettings.RateLimitMs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update configuration by modifying .env file
|
||||
/// </summary>
|
||||
[HttpPost("config")]
|
||||
public async Task<IActionResult> UpdateConfig([FromBody] ConfigUpdateRequest request)
|
||||
{
|
||||
if (request == null || request.Updates == null || request.Updates.Count == 0)
|
||||
{
|
||||
return BadRequest(new { error = "No updates provided" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Config update requested: {Count} changes", request.Updates.Count);
|
||||
|
||||
try
|
||||
{
|
||||
// Check if .env file exists
|
||||
if (!System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
_logger.LogWarning(".env file not found at {Path}, creating new file", _envFilePath);
|
||||
}
|
||||
|
||||
// Read current .env file or create new one
|
||||
var envContent = new Dictionary<string, string>();
|
||||
|
||||
if (System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
var lines = await System.IO.File.ReadAllLinesAsync(_envFilePath);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
|
||||
continue;
|
||||
|
||||
var eqIndex = line.IndexOf('=');
|
||||
if (eqIndex > 0)
|
||||
{
|
||||
var key = line[..eqIndex].Trim();
|
||||
var value = line[(eqIndex + 1)..].Trim();
|
||||
envContent[key] = value;
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("Loaded {Count} existing env vars from {Path}", envContent.Count, _envFilePath);
|
||||
}
|
||||
|
||||
// Apply updates with validation
|
||||
var appliedUpdates = new List<string>();
|
||||
foreach (var (key, value) in request.Updates)
|
||||
{
|
||||
// Validate key format
|
||||
if (!IsValidEnvKey(key))
|
||||
{
|
||||
_logger.LogWarning("Invalid env key rejected: {Key}", key);
|
||||
return BadRequest(new { error = $"Invalid environment variable key: {key}" });
|
||||
}
|
||||
|
||||
envContent[key] = value;
|
||||
appliedUpdates.Add(key);
|
||||
_logger.LogInformation(" Setting {Key} = {Value}", key,
|
||||
key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL")
|
||||
? "***" + (value.Length > 8 ? value[^8..] : "")
|
||||
: value);
|
||||
|
||||
// Auto-set cookie date when Spotify session cookie is updated
|
||||
if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE";
|
||||
var dateValue = DateTime.UtcNow.ToString("o"); // ISO 8601 format
|
||||
envContent[dateKey] = dateValue;
|
||||
appliedUpdates.Add(dateKey);
|
||||
_logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
await System.IO.File.WriteAllTextAsync(_envFilePath, newContent + "\n");
|
||||
|
||||
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Configuration updated. Restart container to apply changes.",
|
||||
updatedKeys = appliedUpdates,
|
||||
requiresRestart = true,
|
||||
envFilePath = _envFilePath
|
||||
});
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Permission denied writing to .env file at {Path}", _envFilePath);
|
||||
return StatusCode(500, new {
|
||||
error = "Permission denied",
|
||||
details = "Cannot write to .env file. Check file permissions and volume mount.",
|
||||
path = _envFilePath
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update configuration at {Path}", _envFilePath);
|
||||
return StatusCode(500, new {
|
||||
error = "Failed to update configuration",
|
||||
details = ex.Message,
|
||||
path = _envFilePath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a new playlist to the configuration
|
||||
/// </summary>
|
||||
[HttpPost("playlists")]
|
||||
public async Task<IActionResult> AddPlaylist([FromBody] AddPlaylistRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Name) || string.IsNullOrEmpty(request.SpotifyId))
|
||||
{
|
||||
return BadRequest(new { error = "Name and SpotifyId are required" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Adding playlist: {Name} ({SpotifyId})", request.Name, request.SpotifyId);
|
||||
|
||||
// Get current playlists
|
||||
var currentPlaylists = _spotifyImportSettings.Playlists.ToList();
|
||||
|
||||
// Check for duplicates
|
||||
if (currentPlaylists.Any(p => p.Id == request.SpotifyId || p.Name == request.Name))
|
||||
{
|
||||
return BadRequest(new { error = "Playlist with this name or ID already exists" });
|
||||
}
|
||||
|
||||
// Add new playlist
|
||||
currentPlaylists.Add(new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = request.Name,
|
||||
Id = request.SpotifyId,
|
||||
LocalTracksPosition = request.LocalTracksPosition == "last"
|
||||
? LocalTracksPosition.Last
|
||||
: LocalTracksPosition.First
|
||||
});
|
||||
|
||||
// Convert to JSON format for env var
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
||||
);
|
||||
|
||||
// Update .env file
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a playlist from the configuration
|
||||
/// </summary>
|
||||
[HttpDelete("playlists/{name}")]
|
||||
public async Task<IActionResult> RemovePlaylist(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Removing playlist: {Name}", decodedName);
|
||||
|
||||
// Read current playlists from .env file (not stale in-memory config)
|
||||
var currentPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
var playlist = currentPlaylists.FirstOrDefault(p => p.Name == decodedName);
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
return NotFound(new { error = "Playlist not found" });
|
||||
}
|
||||
|
||||
currentPlaylists.Remove(playlist);
|
||||
|
||||
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
||||
);
|
||||
|
||||
// Update .env file
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all cached data
|
||||
/// </summary>
|
||||
[HttpPost("cache/clear")]
|
||||
public async Task<IActionResult> ClearCache()
|
||||
{
|
||||
_logger.LogInformation("Cache clear requested from admin UI");
|
||||
|
||||
var clearedFiles = 0;
|
||||
var clearedRedisKeys = 0;
|
||||
|
||||
// Clear file cache
|
||||
if (Directory.Exists(CacheDirectory))
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(CacheDirectory, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.File.Delete(file);
|
||||
clearedFiles++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete cache file {File}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear ALL Redis cache keys for Spotify playlists
|
||||
// This includes matched tracks, ordered tracks, missing tracks, playlist items, etc.
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var keysToDelete = new[]
|
||||
{
|
||||
$"spotify:playlist:{playlist.Name}",
|
||||
$"spotify:missing:{playlist.Name}",
|
||||
$"spotify:matched:{playlist.Name}",
|
||||
$"spotify:matched:ordered:{playlist.Name}",
|
||||
$"spotify:playlist:items:{playlist.Name}" // NEW: Clear file-backed playlist items cache
|
||||
};
|
||||
|
||||
foreach (var key in keysToDelete)
|
||||
{
|
||||
if (await _cache.DeleteAsync(key))
|
||||
{
|
||||
clearedRedisKeys++;
|
||||
_logger.LogInformation("Cleared Redis cache key: {Key}", key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all search cache keys (pattern-based deletion)
|
||||
var searchKeysDeleted = await _cache.DeleteByPatternAsync("search:*");
|
||||
clearedRedisKeys += searchKeysDeleted;
|
||||
|
||||
// Clear all image cache keys (pattern-based deletion)
|
||||
var imageKeysDeleted = await _cache.DeleteByPatternAsync("image:*");
|
||||
clearedRedisKeys += imageKeysDeleted;
|
||||
|
||||
_logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys (including {SearchKeys} search keys, {ImageKeys} image keys)",
|
||||
clearedFiles, clearedRedisKeys, searchKeysDeleted, imageKeysDeleted);
|
||||
|
||||
return Ok(new {
|
||||
message = "Cache cleared successfully",
|
||||
filesDeleted = clearedFiles,
|
||||
redisKeysDeleted = clearedRedisKeys
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restart the allstarr container to apply configuration changes
|
||||
/// </summary>
|
||||
[HttpPost("restart")]
|
||||
public async Task<IActionResult> RestartContainer()
|
||||
{
|
||||
_logger.LogInformation("Container restart requested from admin UI");
|
||||
|
||||
try
|
||||
{
|
||||
// Use Docker socket to restart the container
|
||||
var socketPath = "/var/run/docker.sock";
|
||||
|
||||
if (!System.IO.File.Exists(socketPath))
|
||||
{
|
||||
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
|
||||
return StatusCode(503, new {
|
||||
error = "Docker socket not available",
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
});
|
||||
}
|
||||
|
||||
// Get container ID from hostname (Docker sets hostname to container ID by default)
|
||||
// Or use the well-known container name
|
||||
var containerId = Environment.MachineName;
|
||||
var containerName = "allstarr";
|
||||
|
||||
_logger.LogInformation("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName);
|
||||
|
||||
// Create Unix socket HTTP client
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
ConnectCallback = async (context, cancellationToken) =>
|
||||
{
|
||||
var socket = new System.Net.Sockets.Socket(
|
||||
System.Net.Sockets.AddressFamily.Unix,
|
||||
System.Net.Sockets.SocketType.Stream,
|
||||
System.Net.Sockets.ProtocolType.Unspecified);
|
||||
|
||||
var endpoint = new System.Net.Sockets.UnixDomainSocketEndPoint(socketPath);
|
||||
await socket.ConnectAsync(endpoint, cancellationToken);
|
||||
|
||||
return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
};
|
||||
|
||||
using var dockerClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("http://localhost")
|
||||
};
|
||||
|
||||
// Try to restart by container name first, then by ID
|
||||
var response = await dockerClient.PostAsync($"/containers/{containerName}/restart?t=5", null);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// Try by container ID
|
||||
response = await dockerClient.PostAsync($"/containers/{containerId}/restart?t=5", null);
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("Container restart initiated successfully");
|
||||
return Ok(new { message = "Restarting container...", success = true });
|
||||
}
|
||||
else
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new {
|
||||
error = "Failed to restart container",
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error restarting container");
|
||||
return StatusCode(500, new {
|
||||
error = "Failed to restart container",
|
||||
details = ex.Message,
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize cookie date to current date if cookie exists but date is not set
|
||||
/// </summary>
|
||||
[HttpPost("config/init-cookie-date")]
|
||||
public async Task<IActionResult> InitCookieDate()
|
||||
{
|
||||
// Only init if cookie exists but date is not set
|
||||
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
return BadRequest(new { error = "No cookie set" });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_spotifyApiSettings.SessionCookieSetDate))
|
||||
{
|
||||
return Ok(new { message = "Cookie date already set", date = _spotifyApiSettings.SessionCookieSetDate });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Initializing cookie date to current date (cookie existed without date tracking)");
|
||||
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = DateTime.UtcNow.ToString("o")
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all Jellyfin users
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/users")]
|
||||
public async Task<IActionResult> GetJellyfinUsers()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Users";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to fetch Jellyfin users: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch users from Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var users = new List<object>();
|
||||
|
||||
foreach (var user in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var id = user.GetProperty("Id").GetString();
|
||||
var name = user.GetProperty("Name").GetString();
|
||||
|
||||
users.Add(new { id, name });
|
||||
}
|
||||
|
||||
return Ok(new { users });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Jellyfin users");
|
||||
return StatusCode(500, new { error = "Failed to fetch users", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all Jellyfin libraries (virtual folders)
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/libraries")]
|
||||
public async Task<IActionResult> GetJellyfinLibraries()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var url = $"{_jellyfinSettings.Url}/Library/VirtualFolders";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogError("Failed to fetch Jellyfin libraries: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new { error = "Failed to fetch libraries from Jellyfin" });
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var libraries = new List<object>();
|
||||
|
||||
foreach (var lib in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var name = lib.GetProperty("Name").GetString();
|
||||
var itemId = lib.TryGetProperty("ItemId", out var id) ? id.GetString() : null;
|
||||
var collectionType = lib.TryGetProperty("CollectionType", out var ct) ? ct.GetString() : null;
|
||||
|
||||
libraries.Add(new { id = itemId, name, collectionType });
|
||||
}
|
||||
|
||||
return Ok(new { libraries });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Jellyfin libraries");
|
||||
return StatusCode(500, new { error = "Failed to fetch libraries", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all playlists from the user's Spotify account
|
||||
/// </summary>
|
||||
[HttpGet("spotify/user-playlists")]
|
||||
public async Task<IActionResult> GetSpotifyUserPlaylists()
|
||||
{
|
||||
if (!_spotifyApiSettings.Enabled || string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
return BadRequest(new { error = "Spotify API not configured. Please set sp_dc session cookie." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get list of already-configured Spotify playlist IDs
|
||||
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
var linkedSpotifyIds = new HashSet<string>(
|
||||
configuredPlaylists.Select(p => p.Id),
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
// Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
|
||||
var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
|
||||
|
||||
if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
|
||||
{
|
||||
return Ok(new { playlists = new List<object>() });
|
||||
}
|
||||
|
||||
var playlists = spotifyPlaylists.Select(p => new
|
||||
{
|
||||
id = p.SpotifyId,
|
||||
name = p.Name,
|
||||
trackCount = p.TotalTracks,
|
||||
owner = p.OwnerName ?? "",
|
||||
isPublic = p.Public,
|
||||
isLinked = linkedSpotifyIds.Contains(p.SpotifyId)
|
||||
}).ToList();
|
||||
|
||||
return Ok(new { playlists });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Spotify user playlists");
|
||||
return StatusCode(500, new { error = "Failed to fetch Spotify playlists", 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;
|
||||
|
||||
// Only fetch detailed track stats for configured Spotify playlists
|
||||
// This avoids expensive queries for large non-Spotify playlists
|
||||
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
||||
if (isConfigured)
|
||||
{
|
||||
trackStats = await GetPlaylistTrackStats(id!);
|
||||
}
|
||||
|
||||
// Use actual track stats for configured playlists, otherwise use Jellyfin's count
|
||||
var actualTrackCount = isConfigured
|
||||
? trackStats.LocalTracks + trackStats.ExternalTracks
|
||||
: childCount;
|
||||
|
||||
playlists.Add(new
|
||||
{
|
||||
id,
|
||||
name,
|
||||
trackCount = actualTrackCount,
|
||||
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
|
||||
SyncSchedule = request.SyncSchedule ?? "0 8 * * 1" // Default to Monday 8 AM
|
||||
});
|
||||
|
||||
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] {
|
||||
p.Name,
|
||||
p.Id,
|
||||
p.JellyfinId,
|
||||
p.LocalTracksPosition.ToString().ToLower(),
|
||||
p.SyncSchedule ?? "0 8 * * 1"
|
||||
}).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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update playlist sync schedule
|
||||
/// </summary>
|
||||
[HttpPut("playlists/{name}/schedule")]
|
||||
public async Task<IActionResult> UpdatePlaylistSchedule(string name, [FromBody] UpdateScheduleRequest request)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SyncSchedule))
|
||||
{
|
||||
return BadRequest(new { error = "SyncSchedule is required" });
|
||||
}
|
||||
|
||||
// Basic cron validation
|
||||
var cronParts = request.SyncSchedule.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (cronParts.Length != 5)
|
||||
{
|
||||
return BadRequest(new { error = "Invalid cron format. Expected: minute hour day month dayofweek" });
|
||||
}
|
||||
|
||||
// Read current playlists
|
||||
var currentPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
return NotFound(new { error = $"Playlist '{decodedName}' not found" });
|
||||
}
|
||||
|
||||
// Update the schedule
|
||||
playlist.SyncSchedule = request.SyncSchedule.Trim();
|
||||
|
||||
// Save back to .env
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] {
|
||||
p.Name,
|
||||
p.Id,
|
||||
p.JellyfinId,
|
||||
p.LocalTracksPosition.ToString().ToLower(),
|
||||
p.SyncSchedule ?? "0 8 * * 1"
|
||||
}).ToArray()
|
||||
);
|
||||
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
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","cronSchedule"],...]
|
||||
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,
|
||||
SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * 1"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read playlists from .env file");
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
|
||||
private static string MaskValue(string? value, int showLast = 0)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "(not set)";
|
||||
if (value.Length <= showLast) return "***";
|
||||
return showLast > 0 ? "***" + value[^showLast..] : value[..8] + "...";
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
return string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
|
||||
}
|
||||
|
||||
private static bool IsValidEnvKey(string key)
|
||||
{
|
||||
// Only allow alphanumeric, underscore, and must start with letter/underscore
|
||||
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export .env file for backup/transfer
|
||||
/// </summary>
|
||||
[HttpGet("export-env")]
|
||||
public IActionResult ExportEnv()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
return NotFound(new { error = ".env file not found" });
|
||||
}
|
||||
|
||||
var envContent = System.IO.File.ReadAllText(_envFilePath);
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(envContent);
|
||||
|
||||
return File(bytes, "text/plain", ".env");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to export .env file");
|
||||
return StatusCode(500, new { error = "Failed to export .env file", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import .env file from upload
|
||||
/// </summary>
|
||||
[HttpPost("import-env")]
|
||||
public async Task<IActionResult> ImportEnv([FromForm] IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { error = "No file provided" });
|
||||
}
|
||||
|
||||
if (!file.FileName.EndsWith(".env"))
|
||||
{
|
||||
return BadRequest(new { error = "File must be a .env file" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Read uploaded file
|
||||
using var reader = new StreamReader(file.OpenReadStream());
|
||||
var content = await reader.ReadToEndAsync();
|
||||
|
||||
// Validate it's a valid .env file (basic check)
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return BadRequest(new { error = ".env file is empty" });
|
||||
}
|
||||
|
||||
// Backup existing .env
|
||||
if (System.IO.File.Exists(_envFilePath))
|
||||
{
|
||||
var backupPath = $"{_envFilePath}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
System.IO.File.Copy(_envFilePath, backupPath, true);
|
||||
_logger.LogInformation("Backed up existing .env to {BackupPath}", backupPath);
|
||||
}
|
||||
|
||||
// Write new .env file
|
||||
await System.IO.File.WriteAllTextAsync(_envFilePath, content);
|
||||
|
||||
_logger.LogInformation(".env file imported successfully");
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
message = ".env file imported successfully. Restart the application for changes to take effect."
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to import .env file");
|
||||
return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets detailed memory usage statistics for debugging.
|
||||
/// </summary>
|
||||
[HttpGet("memory-stats")]
|
||||
public IActionResult GetMemoryStats()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get memory stats BEFORE GC
|
||||
var memoryBeforeGC = GC.GetTotalMemory(false);
|
||||
var gen0Before = GC.CollectionCount(0);
|
||||
var gen1Before = GC.CollectionCount(1);
|
||||
var gen2Before = GC.CollectionCount(2);
|
||||
|
||||
// Force garbage collection to get accurate numbers
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
var memoryAfterGC = GC.GetTotalMemory(false);
|
||||
var gen0After = GC.CollectionCount(0);
|
||||
var gen1After = GC.CollectionCount(1);
|
||||
var gen2After = GC.CollectionCount(2);
|
||||
|
||||
// Get process memory info
|
||||
var process = System.Diagnostics.Process.GetCurrentProcess();
|
||||
|
||||
return Ok(new {
|
||||
Timestamp = DateTime.UtcNow,
|
||||
BeforeGC = new {
|
||||
GCMemoryBytes = memoryBeforeGC,
|
||||
GCMemoryMB = Math.Round(memoryBeforeGC / (1024.0 * 1024.0), 2)
|
||||
},
|
||||
AfterGC = new {
|
||||
GCMemoryBytes = memoryAfterGC,
|
||||
GCMemoryMB = Math.Round(memoryAfterGC / (1024.0 * 1024.0), 2)
|
||||
},
|
||||
MemoryFreedMB = Math.Round((memoryBeforeGC - memoryAfterGC) / (1024.0 * 1024.0), 2),
|
||||
ProcessWorkingSetBytes = process.WorkingSet64,
|
||||
ProcessWorkingSetMB = Math.Round(process.WorkingSet64 / (1024.0 * 1024.0), 2),
|
||||
ProcessPrivateMemoryBytes = process.PrivateMemorySize64,
|
||||
ProcessPrivateMemoryMB = Math.Round(process.PrivateMemorySize64 / (1024.0 * 1024.0), 2),
|
||||
ProcessVirtualMemoryBytes = process.VirtualMemorySize64,
|
||||
ProcessVirtualMemoryMB = Math.Round(process.VirtualMemorySize64 / (1024.0 * 1024.0), 2),
|
||||
GCCollections = new {
|
||||
Gen0Before = gen0Before,
|
||||
Gen0After = gen0After,
|
||||
Gen0Triggered = gen0After - gen0Before,
|
||||
Gen1Before = gen1Before,
|
||||
Gen1After = gen1After,
|
||||
Gen1Triggered = gen1After - gen1Before,
|
||||
Gen2Before = gen2Before,
|
||||
Gen2After = gen2After,
|
||||
Gen2Triggered = gen2After - gen2Before
|
||||
},
|
||||
GCMode = GCSettings.IsServerGC ? "Server" : "Workstation",
|
||||
GCLatencyMode = GCSettings.LatencyMode.ToString()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces garbage collection to free up memory (emergency use only).
|
||||
/// </summary>
|
||||
[HttpPost("force-gc")]
|
||||
public IActionResult ForceGarbageCollection()
|
||||
{
|
||||
try
|
||||
{
|
||||
var memoryBefore = GC.GetTotalMemory(false);
|
||||
var processBefore = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
|
||||
|
||||
// Force full garbage collection
|
||||
GC.Collect(2, GCCollectionMode.Forced);
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect(2, GCCollectionMode.Forced);
|
||||
|
||||
var memoryAfter = GC.GetTotalMemory(false);
|
||||
var processAfter = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64;
|
||||
|
||||
return Ok(new {
|
||||
Timestamp = DateTime.UtcNow,
|
||||
MemoryFreedMB = Math.Round((memoryBefore - memoryAfter) / (1024.0 * 1024.0), 2),
|
||||
ProcessMemoryFreedMB = Math.Round((processBefore - processAfter) / (1024.0 * 1024.0), 2),
|
||||
BeforeGCMB = Math.Round(memoryBefore / (1024.0 * 1024.0), 2),
|
||||
AfterGCMB = Math.Round(memoryAfter / (1024.0 * 1024.0), 2),
|
||||
BeforeProcessMB = Math.Round(processBefore / (1024.0 * 1024.0), 2),
|
||||
AfterProcessMB = Math.Round(processAfter / (1024.0 * 1024.0), 2)
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets current active sessions for debugging.
|
||||
/// </summary>
|
||||
[HttpGet("sessions")]
|
||||
public IActionResult GetActiveSessions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sessionManager = HttpContext.RequestServices.GetService<JellyfinSessionManager>();
|
||||
if (sessionManager == null)
|
||||
{
|
||||
return BadRequest(new { error = "Session manager not available" });
|
||||
}
|
||||
|
||||
var sessionInfo = sessionManager.GetSessionsInfo();
|
||||
return Ok(sessionInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to trigger GC after large file operations to prevent memory leaks.
|
||||
/// </summary>
|
||||
private static void TriggerGCAfterLargeOperation(int sizeInBytes)
|
||||
{
|
||||
// Only trigger GC for files larger than 1MB to avoid performance impact
|
||||
if (sizeInBytes > 1024 * 1024)
|
||||
{
|
||||
// Suggest GC collection for large objects (they go to LOH and aren't collected as frequently)
|
||||
GC.Collect(2, GCCollectionMode.Optimized, blocking: false);
|
||||
}
|
||||
}
|
||||
|
||||
#region Spotify Admin Endpoints
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
||||
/// </summary>
|
||||
[HttpGet("spotify/sync")]
|
||||
public async Task<IActionResult> TriggerSpotifySync([FromServices] IEnumerable<IHostedService> hostedServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_spotifyImportSettings.Enabled)
|
||||
{
|
||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Manual Spotify sync triggered via admin endpoint");
|
||||
|
||||
// Find the SpotifyMissingTracksFetcher service
|
||||
var fetcherService = hostedServices
|
||||
.OfType<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (fetcherService == null)
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyMissingTracksFetcher service not found" });
|
||||
}
|
||||
|
||||
// Trigger the sync in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use reflection to call the private ExecuteOnceAsync method
|
||||
var method = fetcherService.GetType().GetMethod("ExecuteOnceAsync",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
await (Task)method.Invoke(fetcherService, new object[] { CancellationToken.None })!;
|
||||
_logger.LogInformation("Manual Spotify sync completed successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyMissingTracksFetcher");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during manual Spotify sync");
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(new {
|
||||
message = "Spotify sync started in background",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering Spotify sync");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger endpoint to force Spotify track matching.
|
||||
/// </summary>
|
||||
[HttpGet("spotify/match")]
|
||||
public async Task<IActionResult> TriggerSpotifyMatch([FromServices] IEnumerable<IHostedService> hostedServices)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_spotifyApiSettings.Enabled)
|
||||
{
|
||||
return BadRequest(new { error = "Spotify API is not enabled" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Manual Spotify track matching triggered via admin endpoint");
|
||||
|
||||
// Find the SpotifyTrackMatchingService
|
||||
var matchingService = hostedServices
|
||||
.OfType<allstarr.Services.Spotify.SpotifyTrackMatchingService>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (matchingService == null)
|
||||
{
|
||||
return BadRequest(new { error = "SpotifyTrackMatchingService not found" });
|
||||
}
|
||||
|
||||
// Trigger matching in background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use reflection to call the private ExecuteOnceAsync method
|
||||
var method = matchingService.GetType().GetMethod("ExecuteOnceAsync",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
|
||||
if (method != null)
|
||||
{
|
||||
await (Task)method.Invoke(matchingService, new object[] { CancellationToken.None })!;
|
||||
_logger.LogInformation("Manual Spotify track matching completed successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Could not find ExecuteOnceAsync method on SpotifyTrackMatchingService");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during manual Spotify track matching");
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(new {
|
||||
message = "Spotify track matching started in background",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error triggering Spotify track matching");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear Spotify playlist cache to force re-matching.
|
||||
/// </summary>
|
||||
[HttpPost("spotify/clear-cache")]
|
||||
public async Task<IActionResult> ClearSpotifyCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
var clearedKeys = new List<string>();
|
||||
|
||||
// Clear Redis cache for all configured playlists
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var keys = new[]
|
||||
{
|
||||
$"spotify:playlist:{playlist.Name}",
|
||||
$"spotify:playlist:items:{playlist.Name}",
|
||||
$"spotify:matched:{playlist.Name}"
|
||||
};
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
await _cache.DeleteAsync(key);
|
||||
clearedKeys.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count);
|
||||
|
||||
return Ok(new {
|
||||
message = "Spotify cache cleared successfully",
|
||||
clearedKeys = clearedKeys,
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error clearing Spotify cache");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Debug Endpoints
|
||||
|
||||
/// <summary>
|
||||
/// Gets endpoint usage statistics from the log file.
|
||||
/// </summary>
|
||||
[HttpGet("debug/endpoint-usage")]
|
||||
public async Task<IActionResult> GetEndpointUsage(
|
||||
[FromQuery] int top = 100,
|
||||
[FromQuery] string? since = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||
|
||||
if (!System.IO.File.Exists(logFile))
|
||||
{
|
||||
return Ok(new {
|
||||
message = "No endpoint usage data available",
|
||||
endpoints = new object[0]
|
||||
});
|
||||
}
|
||||
|
||||
var lines = await System.IO.File.ReadAllLinesAsync(logFile);
|
||||
var usage = new Dictionary<string, int>();
|
||||
DateTime? sinceDate = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate))
|
||||
{
|
||||
sinceDate = parsedDate;
|
||||
}
|
||||
|
||||
foreach (var line in lines.Skip(1)) // Skip header
|
||||
{
|
||||
var parts = line.Split(',');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
var timestamp = parts[0];
|
||||
var method = parts[1];
|
||||
var endpoint = parts[2];
|
||||
|
||||
// Combine method and endpoint for better clarity
|
||||
var fullEndpoint = $"{method} {endpoint}";
|
||||
|
||||
// Filter by date if specified
|
||||
if (sinceDate.HasValue && DateTime.TryParse(timestamp, out var logDate))
|
||||
{
|
||||
if (logDate < sinceDate.Value)
|
||||
continue;
|
||||
}
|
||||
|
||||
usage[fullEndpoint] = usage.GetValueOrDefault(fullEndpoint, 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
var topEndpoints = usage
|
||||
.OrderByDescending(kv => kv.Value)
|
||||
.Take(top)
|
||||
.Select(kv => new { endpoint = kv.Key, count = kv.Value })
|
||||
.ToArray();
|
||||
|
||||
return Ok(new {
|
||||
totalEndpoints = usage.Count,
|
||||
totalRequests = usage.Values.Sum(),
|
||||
since = since,
|
||||
top = top,
|
||||
endpoints = topEndpoints
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting endpoint usage");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the endpoint usage log file.
|
||||
/// </summary>
|
||||
[HttpDelete("debug/endpoint-usage")]
|
||||
public IActionResult ClearEndpointUsage()
|
||||
{
|
||||
try
|
||||
{
|
||||
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
|
||||
|
||||
if (System.IO.File.Exists(logFile))
|
||||
{
|
||||
System.IO.File.Delete(logFile);
|
||||
_logger.LogInformation("Cleared endpoint usage log via admin endpoint");
|
||||
|
||||
return Ok(new {
|
||||
message = "Endpoint usage log cleared successfully",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
return Ok(new {
|
||||
message = "No endpoint usage log file found",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error clearing endpoint usage log");
|
||||
return StatusCode(500, new { error = "Internal server error" });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Saves a manual mapping to file for persistence across restarts.
|
||||
/// Manual mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
private async Task SaveManualMappingToFileAsync(
|
||||
string playlistName,
|
||||
string spotifyId,
|
||||
string? jellyfinId,
|
||||
string? externalProvider,
|
||||
string? externalId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsDir = "/app/cache/mappings";
|
||||
Directory.CreateDirectory(mappingsDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
|
||||
|
||||
// Load existing mappings
|
||||
var mappings = new Dictionary<string, ManualMappingEntry>();
|
||||
if (System.IO.File.Exists(filePath))
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json)
|
||||
?? new Dictionary<string, ManualMappingEntry>();
|
||||
}
|
||||
|
||||
// Add or update mapping
|
||||
mappings[spotifyId] = new ManualMappingEntry
|
||||
{
|
||||
SpotifyId = spotifyId,
|
||||
JellyfinId = jellyfinId,
|
||||
ExternalProvider = externalProvider,
|
||||
ExternalId = externalId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Save back to file
|
||||
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
|
||||
|
||||
_logger.LogDebug("💾 Saved manual mapping to file: {Playlist} - {SpotifyId}", playlistName, spotifyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save manual mapping to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save lyrics mapping to file for persistence across restarts.
|
||||
/// Lyrics mappings NEVER expire - they are permanent user decisions.
|
||||
/// </summary>
|
||||
private async Task SaveLyricsMappingToFileAsync(
|
||||
string artist,
|
||||
string title,
|
||||
string album,
|
||||
int durationSeconds,
|
||||
int lyricsId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsFile = "/app/cache/lyrics_mappings.json";
|
||||
|
||||
// Load existing mappings
|
||||
var mappings = new List<LyricsMappingEntry>();
|
||||
if (System.IO.File.Exists(mappingsFile))
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
|
||||
mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json)
|
||||
?? new List<LyricsMappingEntry>();
|
||||
}
|
||||
|
||||
// Remove any existing mapping for this track
|
||||
mappings.RemoveAll(m =>
|
||||
m.Artist.Equals(artist, StringComparison.OrdinalIgnoreCase) &&
|
||||
m.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Add new mapping
|
||||
mappings.Add(new LyricsMappingEntry
|
||||
{
|
||||
Artist = artist,
|
||||
Title = title,
|
||||
Album = album,
|
||||
DurationSeconds = durationSeconds,
|
||||
LyricsId = lyricsId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
// Save back to file
|
||||
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(mappingsFile, updatedJson);
|
||||
|
||||
_logger.LogDebug("💾 Saved lyrics mapping to file: {Artist} - {Title} → Lyrics ID {LyricsId}",
|
||||
artist, title, lyricsId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save lyrics mapping to file for {Artist} - {Title}", artist, title);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save manual lyrics ID mapping for a track
|
||||
/// </summary>
|
||||
[HttpPost("lyrics/map")]
|
||||
public async Task<IActionResult> SaveLyricsMapping([FromBody] LyricsMappingRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Artist) || string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
return BadRequest(new { error = "Artist and Title are required" });
|
||||
}
|
||||
|
||||
if (request.LyricsId <= 0)
|
||||
{
|
||||
return BadRequest(new { error = "Valid LyricsId is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Store lyrics mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var mappingKey = $"lyrics:manual-map:{request.Artist}:{request.Title}";
|
||||
await _cache.SetStringAsync(mappingKey, request.LyricsId.ToString());
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
await SaveLyricsMappingToFileAsync(request.Artist, request.Title, request.Album ?? "", request.DurationSeconds, request.LyricsId);
|
||||
|
||||
_logger.LogInformation("Manual lyrics mapping saved: {Artist} - {Title} → Lyrics ID {LyricsId}",
|
||||
request.Artist, request.Title, request.LyricsId);
|
||||
|
||||
// Optionally fetch and cache the lyrics immediately
|
||||
try
|
||||
{
|
||||
var lyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.LrclibService>();
|
||||
if (lyricsService != null)
|
||||
{
|
||||
var lyricsInfo = await lyricsService.GetLyricsByIdAsync(request.LyricsId);
|
||||
if (lyricsInfo != null && !string.IsNullOrEmpty(lyricsInfo.PlainLyrics))
|
||||
{
|
||||
// Cache the lyrics using the standard cache key
|
||||
var lyricsCacheKey = $"lyrics:{request.Artist}:{request.Title}:{request.Album ?? ""}:{request.DurationSeconds}";
|
||||
await _cache.SetAsync(lyricsCacheKey, lyricsInfo.PlainLyrics);
|
||||
_logger.LogInformation("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics mapping saved and lyrics cached successfully",
|
||||
lyricsId = request.LyricsId,
|
||||
cached = true,
|
||||
lyrics = new
|
||||
{
|
||||
id = lyricsInfo.Id,
|
||||
trackName = lyricsInfo.TrackName,
|
||||
artistName = lyricsInfo.ArtistName,
|
||||
albumName = lyricsInfo.AlbumName,
|
||||
duration = lyricsInfo.Duration,
|
||||
instrumental = lyricsInfo.Instrumental
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch lyrics after mapping, but mapping was saved");
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics mapping saved successfully",
|
||||
lyricsId = request.LyricsId,
|
||||
cached = false
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save lyrics mapping");
|
||||
return StatusCode(500, new { error = "Failed to save lyrics mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get manual lyrics mappings
|
||||
/// </summary>
|
||||
[HttpGet("lyrics/mappings")]
|
||||
public async Task<IActionResult> GetLyricsMappings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsFile = "/app/cache/lyrics_mappings.json";
|
||||
|
||||
if (!System.IO.File.Exists(mappingsFile))
|
||||
{
|
||||
return Ok(new { mappings = new List<object>() });
|
||||
}
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(mappingsFile);
|
||||
var mappings = JsonSerializer.Deserialize<List<LyricsMappingEntry>>(json) ?? new List<LyricsMappingEntry>();
|
||||
|
||||
return Ok(new { mappings });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get lyrics mappings");
|
||||
return StatusCode(500, new { error = "Failed to get lyrics mappings" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all manual track mappings (both Jellyfin and external) for all playlists
|
||||
/// </summary>
|
||||
[HttpGet("mappings/tracks")]
|
||||
public async Task<IActionResult> GetAllTrackMappings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mappingsDir = "/app/cache/mappings";
|
||||
var allMappings = new List<object>();
|
||||
|
||||
if (!Directory.Exists(mappingsDir))
|
||||
{
|
||||
return Ok(new { mappings = allMappings, totalCount = 0 });
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(mappingsDir, "*_mappings.json");
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await System.IO.File.ReadAllTextAsync(file);
|
||||
var playlistMappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
|
||||
|
||||
if (playlistMappings != null)
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
var playlistName = fileName.Replace("_mappings", "").Replace("_", " ");
|
||||
|
||||
foreach (var mapping in playlistMappings.Values)
|
||||
{
|
||||
allMappings.Add(new
|
||||
{
|
||||
playlist = playlistName,
|
||||
spotifyId = mapping.SpotifyId,
|
||||
type = !string.IsNullOrEmpty(mapping.JellyfinId) ? "jellyfin" : "external",
|
||||
jellyfinId = mapping.JellyfinId,
|
||||
externalProvider = mapping.ExternalProvider,
|
||||
externalId = mapping.ExternalId,
|
||||
createdAt = mapping.CreatedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read mapping file {File}", file);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
mappings = allMappings.OrderBy(m => ((dynamic)m).playlist).ThenBy(m => ((dynamic)m).createdAt),
|
||||
totalCount = allMappings.Count,
|
||||
jellyfinCount = allMappings.Count(m => ((dynamic)m).type == "jellyfin"),
|
||||
externalCount = allMappings.Count(m => ((dynamic)m).type == "external")
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get track mappings");
|
||||
return StatusCode(500, new { error = "Failed to get track mappings" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a manual track mapping
|
||||
/// </summary>
|
||||
[HttpDelete("mappings/tracks")]
|
||||
public async Task<IActionResult> DeleteTrackMapping([FromQuery] string playlist, [FromQuery] string spotifyId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(playlist) || string.IsNullOrEmpty(spotifyId))
|
||||
{
|
||||
return BadRequest(new { error = "playlist and spotifyId parameters are required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mappingsDir = "/app/cache/mappings";
|
||||
var safeName = string.Join("_", playlist.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json");
|
||||
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
return NotFound(new { error = "Mapping file not found for playlist" });
|
||||
}
|
||||
|
||||
// Load existing mappings
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
var mappings = JsonSerializer.Deserialize<Dictionary<string, ManualMappingEntry>>(json);
|
||||
|
||||
if (mappings == null || !mappings.ContainsKey(spotifyId))
|
||||
{
|
||||
return NotFound(new { error = "Mapping not found" });
|
||||
}
|
||||
|
||||
// Remove the mapping
|
||||
mappings.Remove(spotifyId);
|
||||
|
||||
// Save back to file (or delete file if empty)
|
||||
if (mappings.Count == 0)
|
||||
{
|
||||
System.IO.File.Delete(filePath);
|
||||
_logger.LogInformation("🗑️ Deleted empty mapping file for playlist {Playlist}", playlist);
|
||||
}
|
||||
else
|
||||
{
|
||||
var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, updatedJson);
|
||||
_logger.LogInformation("🗑️ Deleted mapping: {Playlist} - {SpotifyId}", playlist, spotifyId);
|
||||
}
|
||||
|
||||
// Also remove from Redis cache
|
||||
var cacheKey = $"manual:mapping:{playlist}:{spotifyId}";
|
||||
await _cache.DeleteAsync(cacheKey);
|
||||
|
||||
return Ok(new { success = true, message = "Mapping deleted successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete track mapping for {Playlist} - {SpotifyId}", playlist, spotifyId);
|
||||
return StatusCode(500, new { error = "Failed to delete track mapping" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID
|
||||
/// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP
|
||||
/// </summary>
|
||||
[HttpGet("lyrics/spotify/test")]
|
||||
public async Task<IActionResult> TestSpotifyLyrics([FromQuery] string trackId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(trackId))
|
||||
{
|
||||
return BadRequest(new { error = "trackId parameter is required" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var spotifyLyricsService = _serviceProvider.GetService<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
||||
|
||||
if (spotifyLyricsService == null)
|
||||
{
|
||||
return StatusCode(500, new { error = "Spotify lyrics service not available" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Testing Spotify lyrics for track ID: {TrackId}", trackId);
|
||||
|
||||
var result = await spotifyLyricsService.GetLyricsByTrackIdAsync(trackId);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
error = "No lyrics found",
|
||||
trackId,
|
||||
message = "Lyrics may not be available for this track, or the Spotify API is not configured correctly"
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
trackId = result.SpotifyTrackId,
|
||||
syncType = result.SyncType,
|
||||
lineCount = result.Lines.Count,
|
||||
language = result.Language,
|
||||
provider = result.Provider,
|
||||
providerDisplayName = result.ProviderDisplayName,
|
||||
lines = result.Lines.Select(l => new
|
||||
{
|
||||
startTimeMs = l.StartTimeMs,
|
||||
endTimeMs = l.EndTimeMs,
|
||||
words = l.Words
|
||||
}).ToList(),
|
||||
// Also show LRC format
|
||||
lrcFormat = string.Join("\n", result.Lines.Select(l =>
|
||||
{
|
||||
var timestamp = TimeSpan.FromMilliseconds(l.StartTimeMs);
|
||||
var mm = (int)timestamp.TotalMinutes;
|
||||
var ss = timestamp.Seconds;
|
||||
var ms = timestamp.Milliseconds / 10;
|
||||
return $"[{mm:D2}:{ss:D2}.{ms:D2}]{l.Words}";
|
||||
}))
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Spotify lyrics for track {TrackId}", trackId);
|
||||
return StatusCode(500, new { error = $"Failed to fetch lyrics: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prefetch lyrics for a specific playlist
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/prefetch-lyrics")]
|
||||
public async Task<IActionResult> PrefetchPlaylistLyrics(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
try
|
||||
{
|
||||
var lyricsPrefetchService = _serviceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
|
||||
if (lyricsPrefetchService == null)
|
||||
{
|
||||
return StatusCode(500, new { error = "Lyrics prefetch service not available" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Starting lyrics prefetch for playlist: {Playlist}", decodedName);
|
||||
|
||||
var (fetched, cached, missing) = await lyricsPrefetchService.PrefetchPlaylistLyricsAsync(
|
||||
decodedName,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Lyrics prefetch complete",
|
||||
playlist = decodedName,
|
||||
fetched,
|
||||
cached,
|
||||
missing,
|
||||
total = fetched + cached + missing
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to prefetch lyrics for playlist {Playlist}", decodedName);
|
||||
return StatusCode(500, new { error = $"Failed to prefetch lyrics: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the cached playlist summary so it will be regenerated on next request
|
||||
/// </summary>
|
||||
private void InvalidatePlaylistSummaryCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheFile = "/app/cache/admin_playlists_summary.json";
|
||||
if (System.IO.File.Exists(cacheFile))
|
||||
{
|
||||
System.IO.File.Delete(cacheFile);
|
||||
_logger.LogDebug("🗑️ Invalidated playlist summary cache");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to invalidate playlist summary cache");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public class ManualMappingRequest
|
||||
{
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string? JellyfinId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
}
|
||||
|
||||
public class LyricsMappingRequest
|
||||
{
|
||||
public string Artist { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string? Album { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
public int LyricsId { get; set; }
|
||||
}
|
||||
|
||||
public class ManualMappingEntry
|
||||
{
|
||||
public string SpotifyId { get; set; } = "";
|
||||
public string? JellyfinId { get; set; }
|
||||
public string? ExternalProvider { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class LyricsMappingEntry
|
||||
{
|
||||
public string Artist { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string? Album { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
public int LyricsId { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
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;
|
||||
public string SyncSchedule { get; set; } = "0 8 * * 1"; // Default: 8 AM every Monday
|
||||
}
|
||||
|
||||
public class UpdateScheduleRequest
|
||||
{
|
||||
public string SyncSchedule { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/downloads
|
||||
/// Lists all downloaded files in the KEPT folder only (favorited tracks)
|
||||
/// </summary>
|
||||
[HttpGet("downloads")]
|
||||
public IActionResult GetDownloads()
|
||||
{
|
||||
try
|
||||
{
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
|
||||
_logger.LogInformation("📂 Checking kept folder: {Path}", keptPath);
|
||||
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
|
||||
|
||||
if (!Directory.Exists(keptPath))
|
||||
{
|
||||
_logger.LogWarning("Kept folder does not exist: {Path}", keptPath);
|
||||
return Ok(new { files = new List<object>(), totalSize = 0, count = 0 });
|
||||
}
|
||||
|
||||
var files = new List<object>();
|
||||
long totalSize = 0;
|
||||
|
||||
// Recursively get all audio files from kept folder
|
||||
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
||||
|
||||
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("📂 Found {Count} audio files in kept folder", allFiles.Count);
|
||||
|
||||
foreach (var filePath in allFiles)
|
||||
{
|
||||
_logger.LogDebug("📂 Processing file: {Path}", filePath);
|
||||
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
var relativePath = Path.GetRelativePath(keptPath, filePath);
|
||||
|
||||
// Parse artist/album/track from path structure
|
||||
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var artist = parts.Length > 0 ? parts[0] : "";
|
||||
var album = parts.Length > 1 ? parts[1] : "";
|
||||
var fileName = parts.Length > 2 ? parts[^1] : Path.GetFileName(filePath);
|
||||
|
||||
files.Add(new
|
||||
{
|
||||
path = relativePath,
|
||||
fullPath = filePath,
|
||||
artist,
|
||||
album,
|
||||
fileName,
|
||||
size = fileInfo.Length,
|
||||
sizeFormatted = FormatFileSize(fileInfo.Length),
|
||||
lastModified = fileInfo.LastWriteTimeUtc,
|
||||
extension = fileInfo.Extension
|
||||
});
|
||||
|
||||
totalSize += fileInfo.Length;
|
||||
}
|
||||
|
||||
_logger.LogInformation("📂 Returning {Count} kept files, total size: {Size}", files.Count, FormatFileSize(totalSize));
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName),
|
||||
totalSize,
|
||||
totalSizeFormatted = FormatFileSize(totalSize),
|
||||
count = files.Count
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to list kept downloads");
|
||||
return StatusCode(500, new { error = "Failed to list kept downloads" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE /api/admin/downloads
|
||||
/// Deletes a specific kept file and cleans up empty folders
|
||||
/// </summary>
|
||||
[HttpDelete("downloads")]
|
||||
public IActionResult DeleteDownload([FromQuery] string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return BadRequest(new { error = "Path is required" });
|
||||
}
|
||||
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var fullPath = Path.Combine(keptPath, path);
|
||||
|
||||
_logger.LogInformation("🗑️ Delete request for: {Path}", fullPath);
|
||||
|
||||
// Security: Ensure the path is within the kept directory
|
||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||
var normalizedKeptPath = Path.GetFullPath(keptPath);
|
||||
|
||||
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
|
||||
{
|
||||
_logger.LogWarning("🗑️ Invalid path (outside kept folder): {Path}", normalizedFullPath);
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
{
|
||||
_logger.LogWarning("🗑️ File not found: {Path}", fullPath);
|
||||
return NotFound(new { error = "File not found" });
|
||||
}
|
||||
|
||||
System.IO.File.Delete(fullPath);
|
||||
_logger.LogInformation("🗑️ Deleted file: {Path}", fullPath);
|
||||
|
||||
// Clean up empty directories (Album folder, then Artist folder if empty)
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
while (directory != null && directory != keptPath && directory.StartsWith(keptPath))
|
||||
{
|
||||
if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
_logger.LogInformation("🗑️ Deleted empty directory: {Dir}", directory);
|
||||
directory = Path.GetDirectoryName(directory);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("🗑️ Directory not empty or doesn't exist, stopping cleanup: {Dir}", directory);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "File deleted successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete file: {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to delete file" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/downloads/file
|
||||
/// Downloads a specific file from the kept folder
|
||||
/// </summary>
|
||||
[HttpGet("downloads/file")]
|
||||
public IActionResult DownloadFile([FromQuery] string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return BadRequest(new { error = "Path is required" });
|
||||
}
|
||||
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
var fullPath = Path.Combine(keptPath, path);
|
||||
|
||||
// Security: Ensure the path is within the kept directory
|
||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||
var normalizedKeptPath = Path.GetFullPath(keptPath);
|
||||
|
||||
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
|
||||
{
|
||||
return BadRequest(new { error = "Invalid path" });
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
{
|
||||
return NotFound(new { error = "File not found" });
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(fullPath);
|
||||
var fileStream = System.IO.File.OpenRead(fullPath);
|
||||
|
||||
return File(fileStream, "application/octet-stream", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to download file: {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to download file" });
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB", "TB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len = len / 1024;
|
||||
}
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
}
|
||||
>>>>>>> dev
|
||||
|
||||
@@ -14,11 +14,26 @@ public class Song
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Artist { get; set; } = string.Empty;
|
||||
public string? ArtistId { get; set; }
|
||||
<<<<<<< HEAD
|
||||
|
||||
/// <summary>
|
||||
/// All artists for this track (main + featured). For display in Jellyfin clients.
|
||||
/// </summary>
|
||||
public List<string> Artists { get; set; } = new();
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
|
||||
/// <summary>
|
||||
/// All artists for this track (main + featured). For display in Jellyfin clients.
|
||||
/// </summary>
|
||||
public List<string> Artists { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// All artist IDs corresponding to the Artists list. Index-matched with Artists.
|
||||
/// </summary>
|
||||
public List<string> ArtistIds { get; set; } = new();
|
||||
|
||||
>>>>>>> dev
|
||||
public string Album { get; set; } = string.Empty;
|
||||
public string? AlbumId { get; set; }
|
||||
public int? Duration { get; set; } // In seconds
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
namespace allstarr.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
@@ -121,3 +122,137 @@ public class SpotifyImportSettings
|
||||
public bool IsSpotifyPlaylist(string jellyfinPlaylistId) =>
|
||||
Playlists.Any(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
namespace allstarr.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Where to position local tracks relative to external matched tracks in Spotify playlists.
|
||||
/// </summary>
|
||||
public enum LocalTracksPosition
|
||||
{
|
||||
/// <summary>
|
||||
/// Local tracks appear first, external tracks appended at the end (default)
|
||||
/// </summary>
|
||||
First,
|
||||
|
||||
/// <summary>
|
||||
/// External tracks appear first, local tracks appended at the end
|
||||
/// </summary>
|
||||
Last
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for a single Spotify Import playlist.
|
||||
/// </summary>
|
||||
public class SpotifyPlaylistConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Playlist name as it appears in Jellyfin/Spotify Import plugin
|
||||
/// Example: "Discover Weekly", "Release Radar"
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Spotify playlist ID (get from Spotify playlist URL)
|
||||
/// Example: "37i9dQZF1DXcBWIGoYBM5M" (from open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M)
|
||||
/// Required for personalized playlists like Discover Weekly, Release Radar, etc.
|
||||
/// </summary>
|
||||
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>
|
||||
/// Where to position local tracks: "first" or "last"
|
||||
/// </summary>
|
||||
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
|
||||
|
||||
/// <summary>
|
||||
/// Cron schedule for syncing this playlist with Spotify
|
||||
/// Format: minute hour day month dayofweek
|
||||
/// Example: "0 8 * * 1" = 8 AM every Monday
|
||||
/// Default: "0 8 * * 1" (weekly on Monday at 8 AM)
|
||||
/// </summary>
|
||||
public string SyncSchedule { get; set; } = "0 8 * * 1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for Spotify playlist injection feature.
|
||||
/// Requires Jellyfin Spotify Import Plugin: https://github.com/Viperinius/jellyfin-plugin-spotify-import
|
||||
/// Uses JellyfinSettings.Url and JellyfinSettings.ApiKey for API access.
|
||||
/// </summary>
|
||||
public class SpotifyImportSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable Spotify playlist injection feature
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How often to run track matching in hours.
|
||||
/// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly.
|
||||
/// Most playlists don't change frequently, so running every 24 hours is reasonable.
|
||||
/// Set to 0 to only run once on startup (manual trigger via admin UI still works).
|
||||
/// Default: 24 hours
|
||||
/// </summary>
|
||||
public int MatchingIntervalHours { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Combined playlist configuration as JSON array.
|
||||
/// Format: [["Name","Id","first|last"],...]
|
||||
/// Example: [["Discover Weekly","abc123","first"],["Release Radar","def456","last"]]
|
||||
/// </summary>
|
||||
public List<SpotifyPlaylistConfig> Playlists { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Legacy: Comma-separated list of Jellyfin playlist IDs to inject
|
||||
/// Deprecated: Use Playlists instead
|
||||
/// </summary>
|
||||
[Obsolete("Use Playlists instead")]
|
||||
public List<string> PlaylistIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Legacy: Comma-separated list of playlist names
|
||||
/// Deprecated: Use Playlists instead
|
||||
/// </summary>
|
||||
[Obsolete("Use Playlists instead")]
|
||||
public List<string> PlaylistNames { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Legacy: Comma-separated list of local track positions ("first" or "last")
|
||||
/// Deprecated: Use Playlists instead
|
||||
/// Example: "first,last,first,first" (one per playlist)
|
||||
/// </summary>
|
||||
[Obsolete("Use Playlists instead")]
|
||||
public List<string> PlaylistLocalTracksPositions { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the playlist configuration by Jellyfin playlist ID.
|
||||
/// </summary>
|
||||
public SpotifyPlaylistConfig? GetPlaylistById(string playlistId) =>
|
||||
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>
|
||||
/// Gets the playlist configuration by name.
|
||||
/// </summary>
|
||||
public SpotifyPlaylistConfig? GetPlaylistByName(string name) =>
|
||||
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a Jellyfin playlist ID is configured for Spotify import.
|
||||
/// </summary>
|
||||
public bool IsSpotifyPlaylist(string jellyfinPlaylistId) =>
|
||||
Playlists.Any(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
>>>>>>> dev
|
||||
|
||||
@@ -13,9 +13,28 @@ using allstarr.Middleware;
|
||||
using allstarr.Filters;
|
||||
using Microsoft.Extensions.Http;
|
||||
using System.Text;
|
||||
using System.Net;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure forwarded headers for reverse proxy support (nginx, etc.)
|
||||
// This allows ASP.NET Core to read X-Forwarded-For, X-Real-IP, etc.
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
|
||||
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
|
||||
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedHost;
|
||||
|
||||
// Clear known networks and proxies to accept headers from any proxy
|
||||
// This is safe when running behind a trusted reverse proxy (nginx)
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
|
||||
// Trust X-Forwarded-* headers from any source
|
||||
// Only do this if your reverse proxy is properly configured and trusted
|
||||
options.ForwardLimit = null;
|
||||
});
|
||||
|
||||
// Decode SquidWTF API base URLs once at startup
|
||||
var squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||
static List<string> DecodeSquidWtfUrls()
|
||||
@@ -626,7 +645,23 @@ builder.Services.AddCors(options =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Migrate old .env file format on startup
|
||||
try
|
||||
{
|
||||
var migrationService = new EnvMigrationService(app.Services.GetRequiredService<ILogger<EnvMigrationService>>());
|
||||
migrationService.MigrateEnvFile();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogWarning(ex, "Failed to run .env migration");
|
||||
}
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
|
||||
// IMPORTANT: UseForwardedHeaders must be called BEFORE other middleware
|
||||
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
app.UseExceptionHandler(_ => { }); // Global exception handler
|
||||
|
||||
// Enable response compression EARLY in the pipeline
|
||||
|
||||
@@ -264,6 +264,7 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
|
||||
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
|
||||
await DownloadLock.WaitAsync(cancellationToken);
|
||||
var lockHeld = true;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -288,6 +289,7 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
|
||||
// Release lock while waiting
|
||||
DownloadLock.Release();
|
||||
lockHeld = false;
|
||||
|
||||
// Wait for download to complete, checking every 100ms (faster than 500ms)
|
||||
// Also respect cancellation token so client timeouts are handled immediately
|
||||
@@ -444,7 +446,10 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
}
|
||||
finally
|
||||
{
|
||||
DownloadLock.Release();
|
||||
if (lockHeld)
|
||||
{
|
||||
DownloadLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ public class CacheCleanupService : BackgroundService
|
||||
|
||||
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var cachePath = PathHelper.GetCachePath();
|
||||
// Get the actual cache path used by download services
|
||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "downloads";
|
||||
var cachePath = Path.Combine(downloadPath, "cache");
|
||||
|
||||
if (!Directory.Exists(cachePath))
|
||||
{
|
||||
@@ -78,7 +80,7 @@ public class CacheCleanupService : BackgroundService
|
||||
var deletedCount = 0;
|
||||
var totalSize = 0L;
|
||||
|
||||
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime}", cutoffTime);
|
||||
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime} from {Path}", cutoffTime, cachePath);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
@@ -133,3 +134,144 @@ public class EndpointMetrics
|
||||
public double SuccessRate { get; set; }
|
||||
public DateTime LastBenchmark { get; set; }
|
||||
}
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks API endpoints on startup and maintains performance metrics.
|
||||
/// Used to prioritize faster endpoints in racing scenarios.
|
||||
/// </summary>
|
||||
public class EndpointBenchmarkService
|
||||
{
|
||||
private readonly ILogger<EndpointBenchmarkService> _logger;
|
||||
private readonly Dictionary<string, EndpointMetrics> _metrics = new();
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public EndpointBenchmarkService(ILogger<EndpointBenchmarkService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Benchmarks a list of endpoints by making test requests.
|
||||
/// Returns endpoints sorted by average response time (fastest first).
|
||||
///
|
||||
/// IMPORTANT: The testFunc should implement its own timeout to prevent slow endpoints
|
||||
/// from blocking startup. Recommended: 5-10 second timeout per ping.
|
||||
/// </summary>
|
||||
public async Task<List<string>> BenchmarkEndpointsAsync(
|
||||
List<string> endpoints,
|
||||
Func<string, CancellationToken, Task<bool>> testFunc,
|
||||
int pingCount = 3,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("🏁 Benchmarking {Count} endpoints with {Pings} pings each...", endpoints.Count, pingCount);
|
||||
|
||||
var tasks = endpoints.Select(async endpoint =>
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var successCount = 0;
|
||||
var totalMs = 0L;
|
||||
|
||||
for (int i = 0; i < pingCount; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pingStart = Stopwatch.GetTimestamp();
|
||||
var success = await testFunc(endpoint, cancellationToken);
|
||||
var pingMs = Stopwatch.GetElapsedTime(pingStart).TotalMilliseconds;
|
||||
|
||||
if (success)
|
||||
{
|
||||
successCount++;
|
||||
totalMs += (long)pingMs;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Benchmark ping failed for {Endpoint}", endpoint);
|
||||
}
|
||||
|
||||
// Small delay between pings
|
||||
if (i < pingCount - 1)
|
||||
{
|
||||
await Task.Delay(100, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
var avgMs = successCount > 0 ? totalMs / successCount : long.MaxValue;
|
||||
var metrics = new EndpointMetrics
|
||||
{
|
||||
Endpoint = endpoint,
|
||||
AverageResponseMs = avgMs,
|
||||
SuccessRate = (double)successCount / pingCount,
|
||||
LastBenchmark = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _lock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
_metrics[endpoint] = metrics;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
|
||||
_logger.LogInformation(" {Endpoint}: {AvgMs}ms avg, {SuccessRate:P0} success rate",
|
||||
endpoint, avgMs, metrics.SuccessRate);
|
||||
|
||||
return metrics;
|
||||
}).ToList();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Sort by: success rate first (must be > 0), then by average response time
|
||||
var sorted = results
|
||||
.Where(m => m.SuccessRate > 0)
|
||||
.OrderByDescending(m => m.SuccessRate)
|
||||
.ThenBy(m => m.AverageResponseMs)
|
||||
.Select(m => m.Endpoint)
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation("✅ Benchmark complete. Fastest: {Fastest} ({Ms}ms)",
|
||||
sorted.FirstOrDefault() ?? "none",
|
||||
results.Where(m => m.SuccessRate > 0).MinBy(m => m.AverageResponseMs)?.AverageResponseMs ?? 0);
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the metrics for a specific endpoint.
|
||||
/// </summary>
|
||||
public EndpointMetrics? GetMetrics(string endpoint)
|
||||
{
|
||||
_metrics.TryGetValue(endpoint, out var metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all endpoint metrics sorted by performance.
|
||||
/// </summary>
|
||||
public List<EndpointMetrics> GetAllMetrics()
|
||||
{
|
||||
return _metrics.Values
|
||||
.OrderByDescending(m => m.SuccessRate)
|
||||
.ThenBy(m => m.AverageResponseMs)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public class EndpointMetrics
|
||||
{
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
public long AverageResponseMs { get; set; }
|
||||
public double SuccessRate { get; set; }
|
||||
public DateTime LastBenchmark { get; set; }
|
||||
}
|
||||
>>>>>>> dev
|
||||
|
||||
59
allstarr/Services/Common/EnvMigrationService.cs
Normal file
59
allstarr/Services/Common/EnvMigrationService.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Service that runs on startup to migrate old .env file format to new format
|
||||
/// </summary>
|
||||
public class EnvMigrationService
|
||||
{
|
||||
private readonly ILogger<EnvMigrationService> _logger;
|
||||
private readonly string _envFilePath;
|
||||
|
||||
public EnvMigrationService(ILogger<EnvMigrationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_envFilePath = Path.Combine(Directory.GetCurrentDirectory(), ".env");
|
||||
}
|
||||
|
||||
public void MigrateEnvFile()
|
||||
{
|
||||
if (!File.Exists(_envFilePath))
|
||||
{
|
||||
_logger.LogDebug("No .env file found, skipping migration");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var lines = File.ReadAllLines(_envFilePath);
|
||||
var modified = false;
|
||||
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
|
||||
continue;
|
||||
|
||||
// Migrate DOWNLOAD_PATH to Library__DownloadPath
|
||||
if (line.StartsWith("DOWNLOAD_PATH="))
|
||||
{
|
||||
var value = line.Substring("DOWNLOAD_PATH=".Length);
|
||||
lines[i] = $"Library__DownloadPath={value}";
|
||||
modified = true;
|
||||
_logger.LogInformation("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file");
|
||||
}
|
||||
}
|
||||
|
||||
if (modified)
|
||||
{
|
||||
File.WriteAllLines(_envFilePath, lines);
|
||||
_logger.LogInformation("✅ .env file migration completed successfully");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to migrate .env file - please manually update DOWNLOAD_PATH to Library__DownloadPath");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,17 +384,23 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
}
|
||||
}
|
||||
|
||||
// Contributors
|
||||
// Contributors (all artists including features)
|
||||
var contributors = new List<string>();
|
||||
var contributorIds = new List<string>();
|
||||
if (track.TryGetProperty("contributors", out var contribs))
|
||||
{
|
||||
foreach (var contrib in contribs.EnumerateArray())
|
||||
{
|
||||
if (contrib.TryGetProperty("name", out var contribName))
|
||||
if (contrib.TryGetProperty("name", out var contribName) &&
|
||||
contrib.TryGetProperty("id", out var contribId))
|
||||
{
|
||||
var name = contribName.GetString();
|
||||
var id = contribId.GetInt64();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
contributors.Add(name);
|
||||
contributorIds.Add($"ext-deezer-artist-{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,6 +443,8 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
ArtistId = track.TryGetProperty("artist", out var artistForId)
|
||||
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
|
||||
: null,
|
||||
Artists = contributors.Count > 0 ? contributors : new List<string>(),
|
||||
ArtistIds = contributorIds.Count > 0 ? contributorIds : new List<string>(),
|
||||
Album = track.TryGetProperty("album", out var album)
|
||||
? album.GetProperty("title").GetString() ?? ""
|
||||
: "",
|
||||
|
||||
@@ -298,6 +298,7 @@ public class JellyfinResponseBuilder
|
||||
["Key"] = $"Audio-{song.Id}",
|
||||
["ItemId"] = song.Id
|
||||
},
|
||||
<<<<<<< HEAD
|
||||
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
|
||||
["ArtistItems"] = artistNames.Count > 0
|
||||
? artistNames.Select((name, index) => new Dictionary<string, object?>
|
||||
@@ -338,6 +339,47 @@ public class JellyfinResponseBuilder
|
||||
["MediaType"] = "Audio",
|
||||
["NormalizationGain"] = 0.0,
|
||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
|
||||
["ArtistItems"] = artistNames.Count > 0 && song.ArtistIds.Count == artistNames.Count
|
||||
? artistNames.Select((name, index) => new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = name,
|
||||
["Id"] = song.ArtistIds[index]
|
||||
}).ToArray()
|
||||
: new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = song.ArtistId ?? song.Id,
|
||||
["Name"] = artistName ?? ""
|
||||
}
|
||||
},
|
||||
["Album"] = albumName,
|
||||
["AlbumId"] = song.AlbumId ?? song.Id,
|
||||
["AlbumPrimaryImageTag"] = song.AlbumId ?? song.Id,
|
||||
["AlbumArtist"] = song.AlbumArtist ?? artistName,
|
||||
["AlbumArtists"] = new[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = song.AlbumArtist ?? artistName ?? "",
|
||||
["Id"] = song.ArtistId ?? song.Id
|
||||
}
|
||||
},
|
||||
["ImageTags"] = new Dictionary<string, string>
|
||||
{
|
||||
["Primary"] = song.Id
|
||||
},
|
||||
["BackdropImageTags"] = new string[0],
|
||||
["ParentLogoImageTag"] = song.AlbumId ?? song.Id,
|
||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||
["LocationType"] = "FileSystem",
|
||||
["MediaType"] = "Audio",
|
||||
["NormalizationGain"] = 0.0,
|
||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||
>>>>>>> dev
|
||||
["CanDownload"] = true,
|
||||
["SupportsSync"] = true
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
@@ -589,3 +590,604 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
}
|
||||
}
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Services.Jellyfin;
|
||||
|
||||
/// <summary>
|
||||
/// Manages Jellyfin sessions for connected clients.
|
||||
/// Creates sessions on first playback and keeps them alive with periodic pings.
|
||||
/// Also maintains server-side WebSocket connections to Jellyfin on behalf of clients.
|
||||
/// </summary>
|
||||
public class JellyfinSessionManager : IDisposable
|
||||
{
|
||||
private readonly JellyfinProxyService _proxyService;
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly ILogger<JellyfinSessionManager> _logger;
|
||||
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
|
||||
private readonly Timer _keepAliveTimer;
|
||||
|
||||
public JellyfinSessionManager(
|
||||
JellyfinProxyService proxyService,
|
||||
IOptions<JellyfinSettings> settings,
|
||||
ILogger<JellyfinSessionManager> logger)
|
||||
{
|
||||
_proxyService = proxyService;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
|
||||
// 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));
|
||||
|
||||
_logger.LogDebug("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures a session exists for the given device. Creates one if needed.
|
||||
/// Returns false if token is expired (401), indicating client needs to re-authenticate.
|
||||
/// </summary>
|
||||
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
|
||||
{
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogWarning("Cannot create session - no device ID");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we already have this session tracked
|
||||
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
||||
{
|
||||
existingSession.LastActivity = DateTime.UtcNow;
|
||||
_logger.LogTrace("Session already exists for device {DeviceId}", deviceId);
|
||||
|
||||
// Refresh capabilities to keep session alive
|
||||
// If this returns false (401), the token expired and client needs to re-auth
|
||||
var success = await PostCapabilitiesAsync(headers);
|
||||
if (!success)
|
||||
{
|
||||
// Token expired - remove the stale session
|
||||
_logger.LogInformation("Token expired for device {DeviceId} - removing session", deviceId);
|
||||
await RemoveSessionAsync(deviceId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
|
||||
|
||||
try
|
||||
{
|
||||
// Post session capabilities to Jellyfin - this creates the session
|
||||
var success = await PostCapabilitiesAsync(headers);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
// Token expired or invalid - client needs to re-authenticate
|
||||
_logger.LogInformation("Failed to create session for {DeviceId} - token may be expired", deviceId);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
||||
|
||||
// Track this session
|
||||
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
||||
?? headers["X-Real-IP"].FirstOrDefault()
|
||||
?? "Unknown";
|
||||
|
||||
_sessions[deviceId] = new SessionInfo
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
Client = client,
|
||||
Device = device,
|
||||
Version = version,
|
||||
LastActivity = DateTime.UtcNow,
|
||||
Headers = CloneHeaders(headers),
|
||||
ClientIp = clientIp
|
||||
};
|
||||
|
||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
||||
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating session for {DeviceId}", deviceId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts session capabilities to Jellyfin.
|
||||
/// Returns true if successful, false if token expired (401).
|
||||
/// </summary>
|
||||
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
|
||||
{
|
||||
var capabilities = new
|
||||
{
|
||||
PlayableMediaTypes = new[] { "Audio" },
|
||||
SupportedCommands = new[]
|
||||
{
|
||||
"Play",
|
||||
"Playstate",
|
||||
"PlayNext"
|
||||
},
|
||||
SupportsMediaControl = true,
|
||||
SupportsPersistentIdentifier = true,
|
||||
SupportsSync = false
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(capabilities);
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
{
|
||||
_logger.LogTrace("Posted capabilities successfully ({StatusCode})", statusCode);
|
||||
return true;
|
||||
}
|
||||
else if (statusCode == 401)
|
||||
{
|
||||
// Token expired - this is expected, client needs to re-authenticate
|
||||
_logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate");
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Capabilities post returned {StatusCode}", statusCode);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates session activity timestamp.
|
||||
/// </summary>
|
||||
public void UpdateActivity(string deviceId)
|
||||
{
|
||||
if (_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
session.LastActivity = DateTime.UtcNow;
|
||||
_logger.LogDebug("🔄 SESSION: Updated activity for {DeviceId}", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the currently playing item for a session (for scrobbling on cleanup).
|
||||
/// </summary>
|
||||
public void UpdatePlayingItem(string deviceId, string? itemId, long? positionTicks)
|
||||
{
|
||||
if (_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
session.LastPlayingItemId = itemId;
|
||||
session.LastPlayingPositionTicks = positionTicks;
|
||||
session.LastActivity = DateTime.UtcNow;
|
||||
_logger.LogDebug("🎵 SESSION: Updated playing item for {DeviceId}: {ItemId} at {Position}",
|
||||
deviceId, itemId, positionTicks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a session as potentially ended (e.g., after playback stops).
|
||||
/// The session will be cleaned up if no new activity occurs within the timeout.
|
||||
/// </summary>
|
||||
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
|
||||
{
|
||||
if (_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
_logger.LogDebug("⏰ SESSION: Marking session {DeviceId} as potentially ended, will cleanup in {Seconds}s if no activity",
|
||||
deviceId, timeout.TotalSeconds);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var markedTime = DateTime.UtcNow;
|
||||
await Task.Delay(timeout);
|
||||
|
||||
// Check if there's been activity since we marked it
|
||||
if (_sessions.TryGetValue(deviceId, out var currentSession) &&
|
||||
currentSession.LastActivity <= markedTime)
|
||||
{
|
||||
_logger.LogDebug("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
|
||||
await RemoveSessionAsync(deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("✓ SESSION: Session {DeviceId} had activity, keeping alive", deviceId);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets information about current active sessions for debugging.
|
||||
/// </summary>
|
||||
public object GetSessionsInfo()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var sessions = _sessions.Values.Select(s => new
|
||||
{
|
||||
DeviceId = s.DeviceId,
|
||||
Client = s.Client,
|
||||
Device = s.Device,
|
||||
Version = s.Version,
|
||||
ClientIp = s.ClientIp,
|
||||
LastActivity = s.LastActivity,
|
||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||
HasWebSocket = s.WebSocket != null,
|
||||
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
|
||||
}).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
TotalSessions = sessions.Count,
|
||||
ActiveSessions = sessions.Count(s => s.InactiveMinutes < 2),
|
||||
StaleSessions = sessions.Count(s => s.InactiveMinutes >= 2),
|
||||
Sessions = sessions.OrderBy(s => s.InactiveMinutes)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a session when the client disconnects.
|
||||
/// </summary>
|
||||
public async Task RemoveSessionAsync(string deviceId)
|
||||
{
|
||||
if (_sessions.TryRemove(deviceId, out var session))
|
||||
{
|
||||
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
|
||||
|
||||
// Close WebSocket if it exists
|
||||
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None);
|
||||
_logger.LogDebug("🔌 WEBSOCKET: Closed WebSocket for device {DeviceId}", deviceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.WebSocket?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
|
||||
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
|
||||
{
|
||||
var stopPayload = new
|
||||
{
|
||||
ItemId = session.LastPlayingItemId,
|
||||
PositionTicks = session.LastPlayingPositionTicks ?? 0
|
||||
};
|
||||
var stopJson = JsonSerializer.Serialize(stopPayload);
|
||||
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
||||
_logger.LogDebug("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
||||
}
|
||||
|
||||
// Notify Jellyfin that the session is ending
|
||||
await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maintains a WebSocket connection to Jellyfin on behalf of a client session.
|
||||
/// This allows the session to appear in Jellyfin's dashboard.
|
||||
/// </summary>
|
||||
private async Task MaintainWebSocketForSessionAsync(string deviceId, IHeaderDictionary headers)
|
||||
{
|
||||
if (!_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
_logger.LogDebug("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
|
||||
return;
|
||||
}
|
||||
|
||||
ClientWebSocket? webSocket = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Build Jellyfin WebSocket URL
|
||||
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
|
||||
var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://";
|
||||
var jellyfinHost = jellyfinUrl.Replace("https://", "").Replace("http://", "");
|
||||
var jellyfinWsUrl = $"{wsScheme}{jellyfinHost}/socket";
|
||||
|
||||
// IMPORTANT: Do NOT add api_key to URL - we want to authenticate as the CLIENT, not the server
|
||||
// The client's token is passed via X-Emby-Authorization header
|
||||
// Using api_key would create a session for the server/admin, not the actual user's client
|
||||
|
||||
webSocket = new ClientWebSocket();
|
||||
session.WebSocket = webSocket;
|
||||
|
||||
// Use stored session headers instead of parameter (parameter might be disposed)
|
||||
var sessionHeaders = session.Headers;
|
||||
|
||||
// Log available headers for debugging
|
||||
_logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
|
||||
deviceId, string.Join(", ", sessionHeaders.Keys));
|
||||
|
||||
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
|
||||
bool authFound = false;
|
||||
if (sessionHeaders.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
||||
{
|
||||
webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString());
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
|
||||
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
|
||||
authFound = true;
|
||||
}
|
||||
else if (sessionHeaders.TryGetValue("Authorization", out var auth))
|
||||
{
|
||||
var authValue = auth.ToString();
|
||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
webSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization for {DeviceId}: {Auth}",
|
||||
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
|
||||
authFound = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
webSocket.Options.SetRequestHeader("Authorization", authValue);
|
||||
_logger.LogDebug("🔑 WEBSOCKET: Using Authorization for {DeviceId}: {Auth}",
|
||||
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
|
||||
authFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!authFound)
|
||||
{
|
||||
// No client auth found - fall back to server API key as last resort
|
||||
if (!string.IsNullOrEmpty(_settings.ApiKey))
|
||||
{
|
||||
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
|
||||
_logger.LogDebug("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId} - WebSocket will fail", deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin for device {DeviceId}: {Url}", deviceId, jellyfinWsUrl);
|
||||
|
||||
// Set user agent
|
||||
webSocket.Options.SetRequestHeader("User-Agent", $"Allstarr-Proxy/{session.Client}");
|
||||
|
||||
// Connect to Jellyfin
|
||||
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
|
||||
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
|
||||
|
||||
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
|
||||
// This tells Jellyfin to create/show the session in the dashboard
|
||||
// Without this message, the WebSocket is connected but no session appears
|
||||
var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}";
|
||||
var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
|
||||
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
_logger.LogDebug("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
|
||||
|
||||
// Also send SessionsStart to subscribe to session updates
|
||||
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
|
||||
messageBytes = Encoding.UTF8.GetBytes(sessionsStartMessage);
|
||||
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
_logger.LogDebug("📤 WEBSOCKET: Sent SessionsStart for {DeviceId}", deviceId);
|
||||
|
||||
// Keep the WebSocket alive by reading messages and sending periodic keep-alive
|
||||
var buffer = new byte[1024 * 4];
|
||||
var lastKeepAlive = DateTime.UtcNow;
|
||||
using var cts = new CancellationTokenSource();
|
||||
|
||||
while (webSocket.State == WebSocketState.Open && _sessions.ContainsKey(deviceId))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use a timeout so we can send keep-alive messages periodically
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
|
||||
try
|
||||
{
|
||||
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), timeoutCts.Token);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_logger.LogDebug("🔌 WEBSOCKET: Jellyfin closed WebSocket for device {DeviceId}", deviceId);
|
||||
break;
|
||||
}
|
||||
|
||||
// Log received messages for debugging (only non-routine messages)
|
||||
if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
|
||||
// Respond to KeepAlive requests from Jellyfin
|
||||
if (message.Contains("\"MessageType\":\"KeepAlive\""))
|
||||
{
|
||||
_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 trace level
|
||||
_logger.LogTrace("📥 WEBSOCKET: {DeviceId}: {Message}",
|
||||
deviceId, message.Length > 100 ? message[..100] + "..." : message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!cts.IsCancellationRequested)
|
||||
{
|
||||
// Timeout - this is expected, send keep-alive if needed
|
||||
}
|
||||
|
||||
// Send periodic keep-alive every 30 seconds
|
||||
if (DateTime.UtcNow - lastKeepAlive > TimeSpan.FromSeconds(30))
|
||||
{
|
||||
var keepAliveMsg = "{\"MessageType\":\"KeepAlive\"}";
|
||||
var keepAliveBytes = Encoding.UTF8.GetBytes(keepAliveMsg);
|
||||
await webSocket.SendAsync(new ArraySegment<byte>(keepAliveBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
_logger.LogDebug("💓 WEBSOCKET: Sent KeepAlive for {DeviceId}", deviceId);
|
||||
lastKeepAlive = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (WebSocketException wsEx)
|
||||
{
|
||||
_logger.LogDebug(wsEx, "WEBSOCKET: Connection closed for device {DeviceId}", deviceId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "❌ WEBSOCKET: Failed to maintain WebSocket for device {DeviceId}", deviceId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (webSocket != null)
|
||||
{
|
||||
if (webSocket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
webSocket.Dispose();
|
||||
_logger.LogDebug("🧹 WEBSOCKET: Cleaned up WebSocket for device {DeviceId}", deviceId);
|
||||
}
|
||||
|
||||
// Clear WebSocket reference from session
|
||||
if (_sessions.TryGetValue(deviceId, out var sess))
|
||||
{
|
||||
sess.WebSocket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Periodically pings Jellyfin to keep sessions alive.
|
||||
/// Note: This is a backup mechanism. The WebSocket connection is the primary keep-alive.
|
||||
/// Removes sessions with expired tokens (401 responses).
|
||||
/// </summary>
|
||||
private async void KeepSessionsAlive(object? state)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var activeSessions = _sessions.Values.Where(s => now - s.LastActivity < TimeSpan.FromMinutes(5)).ToList();
|
||||
|
||||
if (activeSessions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogTrace("Keeping {Count} sessions alive", activeSessions.Count);
|
||||
|
||||
var expiredSessions = new List<string>();
|
||||
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Post capabilities again to keep session alive
|
||||
// If this returns false (401), the token has expired
|
||||
var success = await PostCapabilitiesAsync(session.Headers);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogInformation("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
|
||||
expiredSessions.Add(session.DeviceId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove sessions with expired tokens
|
||||
foreach (var deviceId in expiredSessions)
|
||||
{
|
||||
_logger.LogInformation("Removing session with expired token: {DeviceId}", deviceId);
|
||||
await RemoveSessionAsync(deviceId);
|
||||
}
|
||||
|
||||
// Clean up stale sessions after 3 minutes of inactivity
|
||||
// This balances cleaning up finished sessions with allowing brief pauses/network issues
|
||||
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList();
|
||||
foreach (var stale in staleSessions)
|
||||
{
|
||||
_logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)",
|
||||
stale.Key, (now - stale.Value.LastActivity).TotalMinutes);
|
||||
await RemoveSessionAsync(stale.Key);
|
||||
}
|
||||
}
|
||||
|
||||
private static IHeaderDictionary CloneHeaders(IHeaderDictionary headers)
|
||||
{
|
||||
var cloned = new HeaderDictionary();
|
||||
foreach (var header in headers)
|
||||
{
|
||||
cloned[header.Key] = header.Value;
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
private class SessionInfo
|
||||
{
|
||||
public required string DeviceId { get; init; }
|
||||
public required string Client { get; init; }
|
||||
public required string Device { get; init; }
|
||||
public required string Version { get; init; }
|
||||
public DateTime LastActivity { get; set; }
|
||||
public required IHeaderDictionary Headers { get; init; }
|
||||
public ClientWebSocket? WebSocket { get; set; }
|
||||
public string? LastPlayingItemId { get; set; }
|
||||
public long? LastPlayingPositionTicks { get; set; }
|
||||
public string? ClientIp { get; set; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_keepAliveTimer?.Dispose();
|
||||
|
||||
// Close all WebSocket connections
|
||||
foreach (var session in _sessions.Values)
|
||||
{
|
||||
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Service stopping", CancellationToken.None).Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
session.WebSocket?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
>>>>>>> dev
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services;
|
||||
|
||||
namespace allstarr.Services.Local;
|
||||
|
||||
/// <summary>
|
||||
/// Local library service implementation
|
||||
/// Uses a simple JSON file to store mappings (can be replaced with a database)
|
||||
/// </summary>
|
||||
public class LocalLibraryService : ILocalLibraryService
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services;
|
||||
|
||||
namespace allstarr.Services.Local;
|
||||
|
||||
/// <summary>
|
||||
/// Local library service implementation
|
||||
/// Uses a simple JSON file to store mappings (can be replaced with a database)
|
||||
/// </summary>
|
||||
public class LocalLibraryService : ILocalLibraryService
|
||||
{
|
||||
private readonly string _mappingFilePath;
|
||||
private readonly string _downloadDirectory;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
@@ -898,3 +899,1072 @@ public class SpotifyApiClient : IDisposable
|
||||
public List<byte> Secret { get; set; } = new();
|
||||
}
|
||||
}
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
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);
|
||||
|
||||
// Handle 429 rate limiting with exponential backoff
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds);
|
||||
await Task.Delay(retryAfter, cancellationToken);
|
||||
|
||||
// Retry the request
|
||||
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)
|
||||
{
|
||||
return await GetUserPlaylistsAsync(searchName, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all playlists from the user's library, optionally filtered by name.
|
||||
/// Uses GraphQL API which is less rate-limited than REST API.
|
||||
/// </summary>
|
||||
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
|
||||
public async Task<List<SpotifyPlaylist>> GetUserPlaylistsAsync(
|
||||
string? searchName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return new List<SpotifyPlaylist>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Use GraphQL endpoint instead of REST API to avoid rate limiting
|
||||
// GraphQL is less aggressive with rate limits
|
||||
var playlists = new List<SpotifyPlaylist>();
|
||||
var offset = 0;
|
||||
const int limit = 50;
|
||||
|
||||
while (true)
|
||||
{
|
||||
// GraphQL query to fetch user playlists - using libraryV3 operation
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
{ "operationName", "libraryV3" },
|
||||
{ "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
|
||||
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Handle 429 rate limiting with exponential backoff
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds);
|
||||
await Task.Delay(retryAfter, cancellationToken);
|
||||
|
||||
// Retry the request
|
||||
response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
|
||||
break;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("data", out var data) ||
|
||||
!data.TryGetProperty("me", out var me) ||
|
||||
!me.TryGetProperty("libraryV3", out var library) ||
|
||||
!library.TryGetProperty("items", out var items))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Get total count
|
||||
if (library.TryGetProperty("totalCount", out var totalCount))
|
||||
{
|
||||
var total = totalCount.GetInt32();
|
||||
if (total == 0) break;
|
||||
}
|
||||
|
||||
var itemCount = 0;
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
itemCount++;
|
||||
|
||||
if (!item.TryGetProperty("item", out var playlistItem) ||
|
||||
!playlistItem.TryGetProperty("data", out var playlist))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check __typename to filter out folders and only include playlists
|
||||
if (playlistItem.TryGetProperty("__typename", out var typename))
|
||||
{
|
||||
var typeStr = typename.GetString();
|
||||
// Skip folders - only process Playlist types
|
||||
if (typeStr != null && typeStr.Contains("Folder", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get playlist URI/ID
|
||||
string? uri = null;
|
||||
if (playlistItem.TryGetProperty("uri", out var uriProp))
|
||||
{
|
||||
uri = uriProp.GetString();
|
||||
}
|
||||
else if (playlistItem.TryGetProperty("_uri", out var uriProp2))
|
||||
{
|
||||
uri = uriProp2.GetString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(uri)) continue;
|
||||
|
||||
// Skip if not a playlist URI (e.g., folders have different URI format)
|
||||
if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
|
||||
// Check if name matches (case-insensitive) - if searchName is provided
|
||||
if (!string.IsNullOrEmpty(searchName) &&
|
||||
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get track count if available - try multiple possible paths
|
||||
var trackCount = 0;
|
||||
if (playlist.TryGetProperty("content", out var content))
|
||||
{
|
||||
if (content.TryGetProperty("totalCount", out var totalTrackCount))
|
||||
{
|
||||
trackCount = totalTrackCount.GetInt32();
|
||||
}
|
||||
}
|
||||
// Fallback: try attributes.itemCount
|
||||
else if (playlist.TryGetProperty("attributes", out var attributes) &&
|
||||
attributes.TryGetProperty("itemCount", out var itemCountProp))
|
||||
{
|
||||
trackCount = itemCountProp.GetInt32();
|
||||
}
|
||||
// Fallback: try totalCount directly
|
||||
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
|
||||
{
|
||||
trackCount = directTotalCount.GetInt32();
|
||||
}
|
||||
|
||||
// Log if we couldn't find track count for debugging
|
||||
if (trackCount == 0)
|
||||
{
|
||||
_logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}",
|
||||
itemName, spotifyId, playlist.GetRawText());
|
||||
}
|
||||
|
||||
// Get owner name
|
||||
string? ownerName = null;
|
||||
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
||||
ownerV2.TryGetProperty("data", out var ownerData) &&
|
||||
ownerData.TryGetProperty("username", out var ownerNameProp))
|
||||
{
|
||||
ownerName = ownerNameProp.GetString();
|
||||
}
|
||||
|
||||
// Get image URL
|
||||
string? imageUrl = null;
|
||||
if (playlist.TryGetProperty("images", out var images) &&
|
||||
images.TryGetProperty("items", out var imageItems) &&
|
||||
imageItems.GetArrayLength() > 0)
|
||||
{
|
||||
var firstImage = imageItems[0];
|
||||
if (firstImage.TryGetProperty("sources", out var sources) &&
|
||||
sources.GetArrayLength() > 0)
|
||||
{
|
||||
var firstSource = sources[0];
|
||||
if (firstSource.TryGetProperty("url", out var urlProp))
|
||||
{
|
||||
imageUrl = urlProp.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playlists.Add(new SpotifyPlaylist
|
||||
{
|
||||
SpotifyId = spotifyId,
|
||||
Name = itemName,
|
||||
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
TotalTracks = trackCount,
|
||||
OwnerName = ownerName,
|
||||
ImageUrl = imageUrl,
|
||||
SnapshotId = null
|
||||
});
|
||||
}
|
||||
|
||||
if (itemCount < limit) break;
|
||||
offset += limit;
|
||||
|
||||
// Add delay between pages to avoid rate limiting
|
||||
// Library fetching can be aggressive, so use a longer delay
|
||||
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
|
||||
_logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs);
|
||||
await Task.Delay(delayMs, cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} playlists{Filter} via GraphQL",
|
||||
playlists.Count,
|
||||
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
||||
return playlists;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL",
|
||||
string.IsNullOrEmpty(searchName) ? "" : $" matching '{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();
|
||||
}
|
||||
}
|
||||
>>>>>>> dev
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
@@ -334,3 +335,475 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
}
|
||||
}
|
||||
}
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Cronos;
|
||||
|
||||
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.)
|
||||
///
|
||||
/// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval.
|
||||
/// Cache persists until next cron run to prevent excess Spotify API calls.
|
||||
/// </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.
|
||||
/// Cache persists until next cron run to prevent excess API calls.
|
||||
/// </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;
|
||||
|
||||
// Calculate if cache should still be valid based on cron schedule
|
||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
var shouldRefresh = false;
|
||||
|
||||
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(playlistConfig.SyncSchedule);
|
||||
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue && DateTime.UtcNow >= nextRun.Value)
|
||||
{
|
||||
shouldRefresh = true;
|
||||
_logger.LogInformation("Cache expired for '{Name}' - next cron run was at {NextRun} UTC",
|
||||
playlistName, nextRun.Value);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not parse cron schedule for '{Name}', falling back to cache duration", playlistName);
|
||||
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No cron schedule, use cache duration from settings
|
||||
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
|
||||
}
|
||||
|
||||
if (!shouldRefresh)
|
||||
{
|
||||
_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 config = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
if (config != null && !string.IsNullOrEmpty(config.Id))
|
||||
{
|
||||
// Use the configured Spotify playlist ID directly
|
||||
spotifyId = config.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>();
|
||||
}
|
||||
|
||||
// Calculate cache expiration based on cron schedule
|
||||
var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
|
||||
|
||||
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(playlistCfg.SyncSchedule);
|
||||
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
|
||||
// Add 5 minutes buffer
|
||||
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
|
||||
|
||||
_logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)",
|
||||
playlistName, nextRun.Value, timeUntilNextRun.TotalHours);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache with cron-based expiration
|
||||
await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
|
||||
await SaveToFileCacheAsync(playlistName, playlist);
|
||||
|
||||
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
|
||||
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
|
||||
|
||||
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("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
||||
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
||||
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
||||
}
|
||||
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Initial fetch of all playlists on startup
|
||||
await FetchAllPlaylistsAsync(stoppingToken);
|
||||
|
||||
// Cron-based refresh loop - only fetch when cron schedule triggers
|
||||
// This prevents excess Spotify API calls
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check each playlist to see if it needs refreshing based on cron schedule
|
||||
var now = DateTime.UtcNow;
|
||||
var needsRefresh = new List<string>();
|
||||
|
||||
foreach (var config in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * 1" : config.SyncSchedule;
|
||||
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(schedule);
|
||||
|
||||
// Check if we have cached data
|
||||
var cacheKey = $"{CacheKeyPrefix}{config.Name}";
|
||||
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||
|
||||
if (cached != null)
|
||||
{
|
||||
// Calculate when the next run should be after the last fetch
|
||||
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue && now >= nextRun.Value)
|
||||
{
|
||||
needsRefresh.Add(config.Name);
|
||||
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
|
||||
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No cache, fetch it
|
||||
needsRefresh.Add(config.Name);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}", config.Name, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch playlists that need refreshing
|
||||
if (needsRefresh.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
|
||||
|
||||
foreach (var playlistName in needsRefresh)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
await GetPlaylistTracksAsync(playlistName);
|
||||
|
||||
// Rate limiting between playlists
|
||||
if (playlistName != needsRefresh.Last())
|
||||
{
|
||||
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED FETCHING PLAYLISTS ===");
|
||||
}
|
||||
|
||||
// Sleep for 1 hour before checking again
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in playlist fetcher loop");
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
>>>>>>> dev
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
@@ -1273,3 +1274,1386 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Cronos;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that pre-matches Spotify tracks with external providers.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers.
|
||||
/// Manual refresh is always allowed. Cache persists until next cron run.
|
||||
/// </summary>
|
||||
public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<SpotifyTrackMatchingService> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
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)
|
||||
|
||||
// Track last run time per playlist to prevent duplicate runs
|
||||
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
|
||||
private readonly TimeSpan _minimumRunInterval = TimeSpan.FromMinutes(5); // Cooldown between runs
|
||||
|
||||
public SpotifyTrackMatchingService(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SpotifyTrackMatchingService> logger)
|
||||
{
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to safely check if a dynamic cache result has a value
|
||||
/// Handles the case where JsonElement cannot be compared to null directly
|
||||
/// </summary>
|
||||
private static bool HasValue(object? obj)
|
||||
{
|
||||
if (obj == null) return false;
|
||||
if (obj is JsonElement jsonEl) return jsonEl.ValueKind != JsonValueKind.Null && jsonEl.ValueKind != JsonValueKind.Undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||
? "ISRC-preferred" : "fuzzy";
|
||||
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
||||
_logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule");
|
||||
|
||||
// Log all playlist schedules
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
||||
}
|
||||
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Wait a bit for the fetcher to run first
|
||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||
|
||||
// Run once on startup to match any existing missing tracks
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Running initial track matching on startup (one-time)");
|
||||
await MatchAllPlaylistsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during startup track matching");
|
||||
}
|
||||
|
||||
// Now start the cron-based scheduling loop
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Calculate next run time for each playlist
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
|
||||
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(schedule);
|
||||
var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
nextRuns.Add((playlist.Name, nextRun.Value, cron));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Could not calculate next run for playlist {Name} with schedule {Schedule}",
|
||||
playlist.Name, schedule);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}",
|
||||
playlist.Name, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextRuns.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No valid cron schedules found, sleeping for 1 hour");
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the next playlist that needs to run
|
||||
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
|
||||
var waitTime = nextPlaylist.NextRun - now;
|
||||
|
||||
if (waitTime.TotalSeconds > 0)
|
||||
{
|
||||
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
|
||||
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
|
||||
|
||||
// Wait until next run (or max 1 hour to re-check schedules)
|
||||
var maxWait = TimeSpan.FromHours(1);
|
||||
var actualWait = waitTime > maxWait ? maxWait : waitTime;
|
||||
await Task.Delay(actualWait, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Time to run this playlist
|
||||
_logger.LogInformation("=== CRON TRIGGER: Running scheduled match for {Playlist} ===", nextPlaylist.PlaylistName);
|
||||
|
||||
// Check cooldown to prevent duplicate runs
|
||||
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
|
||||
{
|
||||
var timeSinceLastRun = now - lastRun;
|
||||
if (timeSinceLastRun < _minimumRunInterval)
|
||||
{
|
||||
_logger.LogInformation("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||
nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Run matching for this playlist
|
||||
await MatchSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
|
||||
_lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
|
||||
|
||||
_logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
|
||||
nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in cron scheduling loop");
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches tracks for a single playlist (called by cron scheduler or manual trigger).
|
||||
/// </summary>
|
||||
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to legacy mode
|
||||
await MatchPlaylistTracksLegacyAsync(
|
||||
playlist.Name, metadataService, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger matching manually for all playlists (called from controller).
|
||||
/// This bypasses cron schedules and runs immediately.
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingAsync()
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
|
||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger matching for a specific playlist (called from controller).
|
||||
/// This bypasses cron schedules and runs immediately.
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (bypassing cron schedule)", playlistName);
|
||||
|
||||
// Check cooldown to prevent abuse
|
||||
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
||||
{
|
||||
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||
if (timeSinceLastRun < _minimumRunInterval)
|
||||
{
|
||||
_logger.LogWarning("Skipping manual refresh for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
||||
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before refreshing again");
|
||||
}
|
||||
}
|
||||
|
||||
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
|
||||
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||
|
||||
var playlists = _spotifySettings.Playlists;
|
||||
if (playlists.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No playlists configured for matching");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Uses GREEDY ASSIGNMENT to maximize total matches.
|
||||
/// </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";
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
queryParams["UserId"] = userId;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
|
||||
}
|
||||
|
||||
var (existingTracksResponse, _) = await proxyService.GetJsonAsyncInternal(
|
||||
playlistItemsUrl,
|
||||
queryParams);
|
||||
|
||||
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}, AGGRESSIVE MODE)",
|
||||
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);
|
||||
|
||||
// CRITICAL: Skip matching if cache exists and is valid
|
||||
// Only re-match if cache is missing OR if we detect manual mappings that need to be applied
|
||||
if (existingMatched != null && existingMatched.Count > 0)
|
||||
{
|
||||
// Check if we have NEW manual mappings that aren't in the cache
|
||||
var hasNewManualMappings = false;
|
||||
foreach (var track in tracksToMatch)
|
||||
{
|
||||
// Check if this track has a manual mapping but isn't in the cached results
|
||||
var manualMappingKey = $"spotify:manual-map:{playlistName}:{track.SpotifyId}";
|
||||
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
|
||||
var isInCache = existingMatched.Any(m => m.SpotifyId == track.SpotifyId);
|
||||
|
||||
// If track has manual mapping but isn't in cache, we need to rebuild
|
||||
if (hasManualMapping && !isInCache)
|
||||
{
|
||||
hasNewManualMappings = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasNewManualMappings)
|
||||
{
|
||||
_logger.LogInformation("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
|
||||
playlistName, existingMatched.Count, tracksToMatch.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("New manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
|
||||
}
|
||||
|
||||
var matchedTracks = new List<MatchedTrack>();
|
||||
var isrcMatches = 0;
|
||||
var fuzzyMatches = 0;
|
||||
var noMatch = 0;
|
||||
|
||||
// GREEDY ASSIGNMENT: Collect all possible matches first, then assign optimally
|
||||
var allCandidates = new List<(SpotifyPlaylistTrack SpotifyTrack, Song MatchedSong, double Score, string MatchType)>();
|
||||
|
||||
// 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
|
||||
{
|
||||
var candidates = new List<(Song Song, double Score, string MatchType)>();
|
||||
|
||||
// Try ISRC match first if available and enabled
|
||||
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||
{
|
||||
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||
if (isrcSong != null)
|
||||
{
|
||||
candidates.Add((isrcSong, 100.0, "isrc"));
|
||||
}
|
||||
}
|
||||
|
||||
// Always try fuzzy matching to get more candidates
|
||||
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
|
||||
spotifyTrack.Title,
|
||||
spotifyTrack.Artists,
|
||||
metadataService);
|
||||
|
||||
foreach (var (song, score) in fuzzySongs)
|
||||
{
|
||||
candidates.Add((song, score, "fuzzy"));
|
||||
}
|
||||
|
||||
return (spotifyTrack, candidates);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
return (spotifyTrack, new List<(Song, double, string)>());
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
// Wait for all tracks in this batch to complete
|
||||
var batchResults = await Task.WhenAll(batchTasks);
|
||||
|
||||
// Collect all candidates
|
||||
foreach (var (spotifyTrack, candidates) in batchResults)
|
||||
{
|
||||
foreach (var (song, score, matchType) in candidates)
|
||||
{
|
||||
allCandidates.Add((spotifyTrack, song, score, matchType));
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting between batches
|
||||
if (i + BatchSize < orderedTracks.Count)
|
||||
{
|
||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
// GREEDY ASSIGNMENT: Assign each Spotify track to its best unique match
|
||||
var usedSongIds = new HashSet<string>();
|
||||
var assignments = new Dictionary<string, (Song Song, double Score, string MatchType)>();
|
||||
|
||||
// Sort candidates by score (highest first)
|
||||
var sortedCandidates = allCandidates
|
||||
.OrderByDescending(c => c.Score)
|
||||
.ToList();
|
||||
|
||||
foreach (var (spotifyTrack, song, score, matchType) in sortedCandidates)
|
||||
{
|
||||
// Skip if this Spotify track already has a match
|
||||
if (assignments.ContainsKey(spotifyTrack.SpotifyId))
|
||||
continue;
|
||||
|
||||
// Skip if this song is already used
|
||||
if (usedSongIds.Contains(song.Id))
|
||||
continue;
|
||||
|
||||
// Assign this match
|
||||
assignments[spotifyTrack.SpotifyId] = (song, score, matchType);
|
||||
usedSongIds.Add(song.Id);
|
||||
}
|
||||
|
||||
// Build final matched tracks list
|
||||
foreach (var spotifyTrack in orderedTracks)
|
||||
{
|
||||
if (assignments.TryGetValue(spotifyTrack.SpotifyId, out var match))
|
||||
{
|
||||
var matched = new MatchedTrack
|
||||
{
|
||||
Position = spotifyTrack.Position,
|
||||
SpotifyId = spotifyTrack.SpotifyId,
|
||||
SpotifyTitle = spotifyTrack.Title,
|
||||
SpotifyArtist = spotifyTrack.PrimaryArtist,
|
||||
Isrc = spotifyTrack.Isrc,
|
||||
MatchType = match.MatchType,
|
||||
MatchedSong = match.Song
|
||||
};
|
||||
|
||||
matchedTracks.Add(matched);
|
||||
|
||||
if (match.MatchType == "isrc") isrcMatches++;
|
||||
else if (match.MatchType == "fuzzy") fuzzyMatches++;
|
||||
|
||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match (score: {Score:F1}): {MatchedTitle}",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
||||
match.MatchType, match.Score, match.Song.Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
noMatch++;
|
||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedTracks.Count > 0)
|
||||
{
|
||||
// Calculate cache expiration: until next cron run (not just cache duration from settings)
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
|
||||
|
||||
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(playlist.SyncSchedule);
|
||||
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
|
||||
// Add 5 minutes buffer to ensure cache doesn't expire before next run
|
||||
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
|
||||
|
||||
_logger.LogInformation("Cache will persist until next cron run: {NextRun} UTC (in {Hours:F1} hours)",
|
||||
nextRun.Value, timeUntilNextRun.TotalHours);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not calculate next cron run for {Playlist}, using default cache duration", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache matched tracks with position data until next cron run
|
||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
|
||||
|
||||
// Save matched tracks to file for persistence across restarts
|
||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||
|
||||
// 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, cacheExpiration);
|
||||
|
||||
_logger.LogInformation(
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - cache expires in {Hours:F1}h",
|
||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours);
|
||||
|
||||
// Pre-build playlist items cache for instant serving
|
||||
// This is what makes the UI show all matched tracks at once
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("No tracks matched for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns multiple candidate matches with scores for greedy assignment.
|
||||
/// FOLLOWS OPTIMAL ORDER:
|
||||
/// 1. Strip decorators (done in FuzzyMatcher)
|
||||
/// 2. Substring matching (done in FuzzyMatcher)
|
||||
/// 3. Levenshtein distance (done in FuzzyMatcher)
|
||||
/// This method just collects candidates; greedy assignment happens later.
|
||||
/// </summary>
|
||||
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
||||
string title,
|
||||
List<string> artists,
|
||||
IMusicMetadataService metadataService)
|
||||
{
|
||||
try
|
||||
{
|
||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||
|
||||
// STEP 1: Strip decorators FIRST (before searching)
|
||||
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||
var query = $"{titleStripped} {primaryArtist}";
|
||||
|
||||
var results = await metadataService.SearchSongsAsync(query, limit: 10);
|
||||
|
||||
if (results.Count == 0) return new List<(Song, double)>();
|
||||
|
||||
// STEP 2-3: Score all results (substring + Levenshtein already in CalculateSimilarityAggressive)
|
||||
var scoredResults = results
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
// Use aggressive matching which follows optimal order internally
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.Where(x =>
|
||||
x.TotalScore >= 40 ||
|
||||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||
x.TitleScore >= 85)
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.Select(x => (x.Song, x.TotalScore))
|
||||
.ToList();
|
||||
|
||||
return scoredResults;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<(Song, double)>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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 AGGRESSIVE fuzzy matching.
|
||||
/// FOLLOWS OPTIMAL ORDER:
|
||||
/// 1. Strip decorators FIRST (before searching)
|
||||
/// 2. Substring matching (in FuzzyMatcher)
|
||||
/// 3. Levenshtein distance (in FuzzyMatcher)
|
||||
/// PRIORITY: Match as many tracks as possible, even with lower confidence.
|
||||
/// </summary>
|
||||
private async Task<Song?> TryMatchByFuzzyAsync(
|
||||
string title,
|
||||
List<string> artists,
|
||||
IMusicMetadataService metadataService)
|
||||
{
|
||||
try
|
||||
{
|
||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||
|
||||
// STEP 1: Strip decorators FIRST (before searching)
|
||||
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||
var query = $"{titleStripped} {primaryArtist}";
|
||||
|
||||
var results = await metadataService.SearchSongsAsync(query, limit: 10);
|
||||
|
||||
if (results.Count == 0) return null;
|
||||
|
||||
// STEP 2-3: Score all results (substring + Levenshtein in CalculateSimilarityAggressive)
|
||||
var scoredResults = results
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
// Use aggressive matching which follows optimal order internally
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.ToList();
|
||||
|
||||
var bestMatch = scoredResults.FirstOrDefault();
|
||||
|
||||
if (bestMatch == null) return null;
|
||||
|
||||
// AGGRESSIVE: Accept matches with score >= 40 (was 50)
|
||||
if (bestMatch.TotalScore >= 40)
|
||||
{
|
||||
_logger.LogDebug("✓ Matched (score: {Score:F1}, title: {TitleScore}, artist: {ArtistScore}): {SpotifyTitle} → {MatchedTitle}",
|
||||
bestMatch.TotalScore, bestMatch.TitleScore, bestMatch.ArtistScore, title, bestMatch.Song.Title);
|
||||
return bestMatch.Song;
|
||||
}
|
||||
|
||||
// SUPER AGGRESSIVE: If artist matches well (70+), accept even lower title scores
|
||||
// This handles cases like "a" → "a-blah" where artist is the same
|
||||
if (bestMatch.ArtistScore >= 70 && bestMatch.TitleScore >= 30)
|
||||
{
|
||||
_logger.LogDebug("✓ Matched via artist priority (artist: {ArtistScore}, title: {TitleScore}): {SpotifyTitle} → {MatchedTitle}",
|
||||
bestMatch.ArtistScore, bestMatch.TitleScore, title, bestMatch.Song.Title);
|
||||
return bestMatch.Song;
|
||||
}
|
||||
|
||||
// ULTRA AGGRESSIVE: If title has high substring match (85+), accept it
|
||||
// This handles "luther" → "luther (feat. sza)"
|
||||
if (bestMatch.TitleScore >= 85)
|
||||
{
|
||||
_logger.LogDebug("✓ Matched via substring (title: {TitleScore}): {SpotifyTitle} → {MatchedTitle}",
|
||||
bestMatch.TitleScore, title, bestMatch.Song.Title);
|
||||
return bestMatch.Song;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
|
||||
/// </summary>
|
||||
private async Task MatchPlaylistTracksLegacyAsync(
|
||||
string playlistName,
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var missingTracksKey = $"spotify:missing:{playlistName}";
|
||||
var matchedTracksKey = $"spotify:matched:{playlistName}";
|
||||
|
||||
// Check if we already have matched tracks cached
|
||||
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
|
||||
if (existingMatched != null && existingMatched.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
||||
playlistName, existingMatched.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get missing tracks
|
||||
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No missing tracks found for {Playlist}, skipping matching", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {Count} tracks for {Playlist} (with rate limiting)",
|
||||
missingTracks.Count, playlistName);
|
||||
|
||||
var matchedSongs = new List<Song>();
|
||||
var matchCount = 0;
|
||||
|
||||
foreach (var track in missingTracks)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
var query = $"{track.Title} {track.PrimaryArtist}";
|
||||
var results = await metadataService.SearchSongsAsync(query, limit: 5);
|
||||
|
||||
if (results.Count > 0)
|
||||
{
|
||||
// Fuzzy match to find best result
|
||||
// Check that ALL artists match (not just some)
|
||||
var bestMatch = results
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||
// Calculate artist score by checking ALL artists match
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.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)
|
||||
{
|
||||
matchedSongs.Add(bestMatch.Song);
|
||||
matchCount++;
|
||||
|
||||
if (matchCount % 10 == 0)
|
||||
{
|
||||
_logger.LogInformation("Matched {Count}/{Total} tracks for {Playlist}",
|
||||
matchCount, missingTracks.Count, playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting: delay between searches
|
||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
track.Title, track.PrimaryArtist);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedSongs.Count > 0)
|
||||
{
|
||||
// Cache matched tracks for 1 hour
|
||||
await _cache.SetAsync(matchedTracksKey, matchedSongs, TimeSpan.FromHours(1));
|
||||
_logger.LogInformation("✓ Cached {Matched}/{Total} matched tracks for {Playlist}",
|
||||
matchedSongs.Count, missingTracks.Count, playlistName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("No tracks matched for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates artist match score ensuring ALL artists are present.
|
||||
/// Penalizes if artist counts don't match or if any artist is missing.
|
||||
/// </summary>
|
||||
private static double CalculateArtistMatchScore(List<string> spotifyArtists, string songMainArtist, List<string> songContributors)
|
||||
{
|
||||
if (spotifyArtists.Count == 0 || string.IsNullOrEmpty(songMainArtist))
|
||||
return 0;
|
||||
|
||||
// Build list of all song artists (main + contributors)
|
||||
var allSongArtists = new List<string> { songMainArtist };
|
||||
allSongArtists.AddRange(songContributors);
|
||||
|
||||
// If artist counts differ significantly, penalize
|
||||
var countDiff = Math.Abs(spotifyArtists.Count - allSongArtists.Count);
|
||||
if (countDiff > 1) // Allow 1 artist difference (sometimes features are listed differently)
|
||||
return 0;
|
||||
|
||||
// Check that each Spotify artist has a good match in song artists
|
||||
var spotifyScores = new List<double>();
|
||||
foreach (var spotifyArtist in spotifyArtists)
|
||||
{
|
||||
var bestMatch = allSongArtists.Max(songArtist =>
|
||||
FuzzyMatcher.CalculateSimilarity(spotifyArtist, songArtist));
|
||||
spotifyScores.Add(bestMatch);
|
||||
}
|
||||
|
||||
// Check that each song artist has a good match in Spotify artists
|
||||
var songScores = new List<double>();
|
||||
foreach (var songArtist in allSongArtists)
|
||||
{
|
||||
var bestMatch = spotifyArtists.Max(spotifyArtist =>
|
||||
FuzzyMatcher.CalculateSimilarity(songArtist, spotifyArtist));
|
||||
songScores.Add(bestMatch);
|
||||
}
|
||||
|
||||
// Average all scores - this ensures ALL artists must match well
|
||||
var allScores = spotifyScores.Concat(songScores);
|
||||
var avgScore = allScores.Average();
|
||||
|
||||
// Penalize if any individual artist match is poor (< 70)
|
||||
var minScore = allScores.Min();
|
||||
if (minScore < 70)
|
||||
avgScore *= 0.7; // 30% penalty for poor individual match
|
||||
|
||||
return avgScore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-builds the playlist items cache for instant serving.
|
||||
/// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order.
|
||||
/// </summary>
|
||||
private async Task PreBuildPlaylistItemsCacheAsync(
|
||||
string playlistName,
|
||||
string? jellyfinPlaylistId,
|
||||
List<SpotifyPlaylistTrack> spotifyTracks,
|
||||
List<MatchedTrack> matchedTracks,
|
||||
TimeSpan cacheExpiration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
|
||||
|
||||
if (string.IsNullOrEmpty(jellyfinPlaylistId))
|
||||
{
|
||||
_logger.LogWarning("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing tracks from Jellyfin playlist
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
var responseBuilder = scope.ServiceProvider.GetService<JellyfinResponseBuilder>();
|
||||
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
|
||||
|
||||
if (proxyService == null || responseBuilder == null || jellyfinSettings == null)
|
||||
{
|
||||
_logger.LogWarning("Required services not available for pre-building cache");
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = jellyfinSettings.UserId;
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogWarning("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create authentication headers for background service call
|
||||
var headers = new HeaderDictionary();
|
||||
if (!string.IsNullOrEmpty(jellyfinSettings.ApiKey))
|
||||
{
|
||||
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
||||
}
|
||||
|
||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
||||
|
||||
if (statusCode != 200 || existingTracksResponse == null)
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Index Jellyfin items by title+artist for matching
|
||||
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
|
||||
|
||||
if (existingTracksResponse.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() ?? "";
|
||||
}
|
||||
|
||||
var key = $"{title}|{artist}".ToLowerInvariant();
|
||||
if (!jellyfinItemsByName.ContainsKey(key))
|
||||
{
|
||||
jellyfinItemsByName[key] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the final track list in correct Spotify order
|
||||
var finalItems = new List<Dictionary<string, object?>>();
|
||||
var usedJellyfinItems = new HashSet<string>();
|
||||
var localUsedCount = 0;
|
||||
var externalUsedCount = 0;
|
||||
var manualExternalCount = 0;
|
||||
|
||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
JsonElement? matchedJellyfinItem = null;
|
||||
string? matchedKey = null;
|
||||
|
||||
// FIRST: Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
{
|
||||
// Find the Jellyfin item by ID
|
||||
foreach (var kvp in jellyfinItemsByName)
|
||||
{
|
||||
var item = kvp.Value;
|
||||
if (item.TryGetProperty("Id", out var idEl) && idEl.GetString() == manualJellyfinId)
|
||||
{
|
||||
matchedJellyfinItem = item;
|
||||
matchedKey = kvp.Key;
|
||||
_logger.LogInformation("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}",
|
||||
spotifyTrack.Title, manualJellyfinId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedJellyfinItem.HasValue)
|
||||
{
|
||||
// Use the raw Jellyfin item (preserves ALL metadata)
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||
if (itemDict != null)
|
||||
{
|
||||
// Add Spotify ID to ProviderIds so lyrics can work for local tracks too
|
||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
{
|
||||
if (!itemDict.ContainsKey("ProviderIds"))
|
||||
{
|
||||
itemDict["ProviderIds"] = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
|
||||
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||
{
|
||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||
_logger.LogDebug("Added Spotify ID {SpotifyId} to local track for lyrics support", spotifyTrack.SpotifyId);
|
||||
}
|
||||
}
|
||||
|
||||
finalItems.Add(itemDict);
|
||||
if (matchedKey != null)
|
||||
{
|
||||
usedJellyfinItems.Add(matchedKey);
|
||||
}
|
||||
localUsedCount++;
|
||||
}
|
||||
continue; // Skip to next track
|
||||
}
|
||||
}
|
||||
|
||||
// SECOND: Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}";
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(externalMappingJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
string? provider = null;
|
||||
string? externalId = null;
|
||||
|
||||
if (root.TryGetProperty("provider", out var providerEl))
|
||||
{
|
||||
provider = providerEl.GetString();
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
externalId = idEl.GetString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
||||
{
|
||||
// Fetch full metadata from the provider instead of using minimal Spotify data
|
||||
Song? externalSong = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var metadataScope = _serviceProvider.CreateScope();
|
||||
var metadataServiceForFetch = metadataScope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||
externalSong = await metadataServiceForFetch.GetSongAsync(provider, externalId);
|
||||
|
||||
if (externalSong != null)
|
||||
{
|
||||
_logger.LogInformation("✓ Fetched full metadata for manual external mapping: {Title} by {Artist}",
|
||||
externalSong.Title, externalSong.Artist);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
|
||||
provider, externalId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error fetching metadata for {Provider} ID {ExternalId}, using fallback",
|
||||
provider, externalId);
|
||||
}
|
||||
|
||||
// Fallback to minimal metadata if fetch failed
|
||||
if (externalSong == null)
|
||||
{
|
||||
externalSong = new Song
|
||||
{
|
||||
Id = $"ext-{provider}-song-{externalId}",
|
||||
Title = spotifyTrack.Title,
|
||||
Artist = spotifyTrack.PrimaryArtist,
|
||||
Album = spotifyTrack.Album,
|
||||
Duration = spotifyTrack.DurationMs / 1000,
|
||||
Isrc = spotifyTrack.Isrc,
|
||||
IsLocal = false,
|
||||
ExternalProvider = provider,
|
||||
ExternalId = externalId
|
||||
};
|
||||
}
|
||||
|
||||
var matchedTrack = new MatchedTrack
|
||||
{
|
||||
Position = spotifyTrack.Position,
|
||||
SpotifyId = spotifyTrack.SpotifyId,
|
||||
MatchedSong = externalSong
|
||||
};
|
||||
|
||||
matchedTracks.Add(matchedTrack);
|
||||
|
||||
// Convert external song to Jellyfin item format and add to finalItems
|
||||
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
|
||||
|
||||
// Add Spotify ID to ProviderIds so lyrics can work
|
||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
{
|
||||
if (!externalItem.ContainsKey("ProviderIds"))
|
||||
{
|
||||
externalItem["ProviderIds"] = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
|
||||
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||
{
|
||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||
}
|
||||
}
|
||||
|
||||
finalItems.Add(externalItem);
|
||||
externalUsedCount++;
|
||||
manualExternalCount++;
|
||||
|
||||
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
|
||||
spotifyTrack.Title, provider, externalId);
|
||||
continue; // Skip to next track
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
|
||||
}
|
||||
}
|
||||
|
||||
// If no manual external mapping, try AGGRESSIVE fuzzy matching with local Jellyfin tracks
|
||||
double bestScore = 0;
|
||||
|
||||
foreach (var kvp in jellyfinItemsByName)
|
||||
{
|
||||
if (usedJellyfinItems.Contains(kvp.Key)) continue;
|
||||
|
||||
var item = kvp.Value;
|
||||
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() ?? "";
|
||||
}
|
||||
|
||||
// Use AGGRESSIVE matching with decorator stripping
|
||||
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(spotifyTrack.Title, title);
|
||||
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
|
||||
|
||||
// Weight: 70% title, 30% artist (prioritize title matching)
|
||||
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
|
||||
|
||||
// AGGRESSIVE: Accept score >= 40 (was 70)
|
||||
// Also accept if artist matches well (70+) and title is decent (30+)
|
||||
var isGoodMatch = totalScore >= 40 || (artistScore >= 70 && titleScore >= 30);
|
||||
|
||||
if (totalScore > bestScore && isGoodMatch)
|
||||
{
|
||||
bestScore = totalScore;
|
||||
matchedJellyfinItem = item;
|
||||
matchedKey = kvp.Key;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedJellyfinItem.HasValue)
|
||||
{
|
||||
// Use the raw Jellyfin item (preserves ALL metadata)
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||
if (itemDict != null)
|
||||
{
|
||||
// Add Spotify ID to ProviderIds so lyrics can work for fuzzy-matched local tracks too
|
||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
{
|
||||
if (!itemDict.ContainsKey("ProviderIds"))
|
||||
{
|
||||
itemDict["ProviderIds"] = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
|
||||
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||
{
|
||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||
_logger.LogDebug("Added Spotify ID {SpotifyId} to fuzzy-matched local track for lyrics support", spotifyTrack.SpotifyId);
|
||||
}
|
||||
}
|
||||
|
||||
finalItems.Add(itemDict);
|
||||
if (matchedKey != null)
|
||||
{
|
||||
usedJellyfinItems.Add(matchedKey);
|
||||
}
|
||||
localUsedCount++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No local match - try to find external track
|
||||
var matched = matchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||
if (matched != null && matched.MatchedSong != null)
|
||||
{
|
||||
// Convert external song to Jellyfin item format
|
||||
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
|
||||
|
||||
// Add Spotify ID to ProviderIds so lyrics can work
|
||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
{
|
||||
if (!externalItem.ContainsKey("ProviderIds"))
|
||||
{
|
||||
externalItem["ProviderIds"] = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
|
||||
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||
{
|
||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||
}
|
||||
}
|
||||
|
||||
finalItems.Add(externalItem);
|
||||
externalUsedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (finalItems.Count > 0)
|
||||
{
|
||||
// Save to Redis cache with same expiration as matched tracks (until next cron run)
|
||||
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
||||
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
||||
|
||||
// Save to file cache for persistence
|
||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||
|
||||
var manualMappingInfo = "";
|
||||
if (manualExternalCount > 0)
|
||||
{
|
||||
manualMappingInfo = $" [Manual external: {manualExternalCount}]";
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
|
||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("No items to cache for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to pre-build playlist items cache for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves playlist items to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SavePlaylistItemsToFileAsync(string playlistName, List<Dictionary<string, object?>> items)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved {Count} playlist items to file cache: {Path}", items.Count, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves matched tracks to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SaveMatchedTracksToFileAsync(string playlistName, List<MatchedTrack> matchedTracks)
|
||||
{
|
||||
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(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
>>>>>>> dev
|
||||
|
||||
@@ -593,9 +593,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
? volNum.GetInt32()
|
||||
: null;
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
|
||||
var allArtists = new List<string>();
|
||||
||||||| bc4e5d9
|
||||
// Get artist name - handle both single artist and artists array
|
||||
=======
|
||||
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
|
||||
var allArtists = new List<string>();
|
||||
var allArtistIds = new List<string>();
|
||||
>>>>>>> dev
|
||||
string artistName = "";
|
||||
<<<<<<< HEAD
|
||||
string? artistId = null;
|
||||
|
||||
// Prefer the "artists" array as it includes all collaborators
|
||||
@@ -619,10 +628,61 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
}
|
||||
// Fallback to singular "artist" field
|
||||
else if (track.TryGetProperty("artist", out var artist))
|
||||
||||||| bc4e5d9
|
||||
if (track.TryGetProperty("artist", out var artist))
|
||||
=======
|
||||
string? artistId = null;
|
||||
|
||||
// Prefer the "artists" array as it includes all collaborators
|
||||
if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
|
||||
{
|
||||
foreach (var artistEl in artists.EnumerateArray())
|
||||
{
|
||||
var name = artistEl.GetProperty("name").GetString();
|
||||
var id = artistEl.GetProperty("id").GetInt64();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
allArtists.Add(name);
|
||||
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
||||
}
|
||||
}
|
||||
|
||||
// First artist is the main artist
|
||||
if (allArtists.Count > 0)
|
||||
{
|
||||
artistName = allArtists[0];
|
||||
artistId = allArtistIds[0];
|
||||
}
|
||||
}
|
||||
// Fallback to singular "artist" field
|
||||
else if (track.TryGetProperty("artist", out var artist))
|
||||
>>>>>>> dev
|
||||
{
|
||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||
<<<<<<< HEAD
|
||||
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
||||
allArtists.Add(artistName);
|
||||
||||||| bc4e5d9
|
||||
}
|
||||
else if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
|
||||
{
|
||||
artistName = artists[0].GetProperty("name").GetString() ?? "";
|
||||
}
|
||||
|
||||
// Get artist ID
|
||||
string? artistId = null;
|
||||
if (track.TryGetProperty("artist", out var artistForId))
|
||||
{
|
||||
artistId = $"ext-squidwtf-artist-{artistForId.GetProperty("id").GetInt64()}";
|
||||
}
|
||||
else if (track.TryGetProperty("artists", out var artistsForId) && artistsForId.GetArrayLength() > 0)
|
||||
{
|
||||
artistId = $"ext-squidwtf-artist-{artistsForId[0].GetProperty("id").GetInt64()}";
|
||||
=======
|
||||
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
||||
allArtists.Add(artistName);
|
||||
allArtistIds.Add(artistId);
|
||||
>>>>>>> dev
|
||||
}
|
||||
|
||||
// Get album info
|
||||
@@ -648,7 +708,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
Title = track.GetProperty("title").GetString() ?? "",
|
||||
Artist = artistName,
|
||||
ArtistId = artistId,
|
||||
<<<<<<< HEAD
|
||||
Artists = allArtists,
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
Artists = allArtists,
|
||||
ArtistIds = allArtistIds,
|
||||
>>>>>>> dev
|
||||
Album = albumTitle,
|
||||
AlbumId = albumId,
|
||||
Duration = track.TryGetProperty("duration", out var duration)
|
||||
@@ -709,6 +775,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
}
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Get all artists - prefer "artists" array for collaborations
|
||||
var allArtists = new List<string>();
|
||||
string artistName = "";
|
||||
@@ -737,6 +804,44 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
artistIdNum = artist.GetProperty("id").GetInt64();
|
||||
allArtists.Add(artistName);
|
||||
}
|
||||
||||||| bc4e5d9
|
||||
// Get artist info
|
||||
string artistName = track.GetProperty("artist").GetProperty("name").GetString() ?? "";
|
||||
long artistIdNum = track.GetProperty("artist").GetProperty("id").GetInt64();
|
||||
=======
|
||||
// Get all artists - prefer "artists" array for collaborations
|
||||
var allArtists = new List<string>();
|
||||
var allArtistIds = new List<string>();
|
||||
string artistName = "";
|
||||
long artistIdNum = 0;
|
||||
|
||||
if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
|
||||
{
|
||||
foreach (var artistEl in artists.EnumerateArray())
|
||||
{
|
||||
var name = artistEl.GetProperty("name").GetString();
|
||||
var id = artistEl.GetProperty("id").GetInt64();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
allArtists.Add(name);
|
||||
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
||||
}
|
||||
}
|
||||
|
||||
if (allArtists.Count > 0)
|
||||
{
|
||||
artistName = allArtists[0];
|
||||
artistIdNum = artists[0].GetProperty("id").GetInt64();
|
||||
}
|
||||
}
|
||||
else if (track.TryGetProperty("artist", out var artist))
|
||||
{
|
||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||
artistIdNum = artist.GetProperty("id").GetInt64();
|
||||
allArtists.Add(artistName);
|
||||
allArtistIds.Add($"ext-squidwtf-artist-{artistIdNum}");
|
||||
}
|
||||
>>>>>>> dev
|
||||
|
||||
// Album artist - same as main artist for Tidal tracks
|
||||
string? albumArtist = artistName;
|
||||
@@ -770,7 +875,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
Title = track.GetProperty("title").GetString() ?? "",
|
||||
Artist = artistName,
|
||||
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
|
||||
<<<<<<< HEAD
|
||||
Artists = allArtists,
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
Artists = allArtists,
|
||||
ArtistIds = allArtistIds,
|
||||
>>>>>>> dev
|
||||
Album = albumTitle,
|
||||
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
|
||||
AlbumArtist = albumArtist,
|
||||
|
||||
@@ -50,6 +50,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
|
||||
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
|
||||
|
||||
<<<<<<< HEAD
|
||||
// Benchmark all endpoints to determine fastest
|
||||
var apiUrls = _fallbackHelper.EndpointCount > 0
|
||||
? Enumerable.Range(0, _fallbackHelper.EndpointCount).Select(_ => "").ToList() // Placeholder, we'll get actual URLs from fallback helper
|
||||
@@ -91,6 +92,54 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
}
|
||||
}
|
||||
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
// Benchmark all endpoints to determine fastest
|
||||
var apiUrls = _fallbackHelper.EndpointCount > 0
|
||||
? Enumerable.Range(0, _fallbackHelper.EndpointCount).Select(_ => "").ToList() // Placeholder, we'll get actual URLs from fallback helper
|
||||
: new List<string>();
|
||||
|
||||
// Get the actual API URLs by reflection (not ideal, but works for now)
|
||||
var fallbackHelperType = _fallbackHelper.GetType();
|
||||
var apiUrlsField = fallbackHelperType.GetField("_apiUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
if (apiUrlsField != null)
|
||||
{
|
||||
apiUrls = (List<string>)apiUrlsField.GetValue(_fallbackHelper)!;
|
||||
}
|
||||
|
||||
if (apiUrls.Count > 1)
|
||||
{
|
||||
WriteStatus("Benchmarking Endpoints", $"{apiUrls.Count} endpoints", ConsoleColor.Cyan);
|
||||
|
||||
var orderedEndpoints = await _benchmarkService.BenchmarkEndpointsAsync(
|
||||
apiUrls,
|
||||
async (endpoint, ct) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// 5 second timeout per ping - mark slow endpoints as failed
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
|
||||
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
},
|
||||
pingCount: 2,
|
||||
cancellationToken);
|
||||
|
||||
if (orderedEndpoints.Count > 0)
|
||||
{
|
||||
_fallbackHelper.SetEndpointOrder(orderedEndpoints);
|
||||
WriteDetail($"Fastest endpoint: {orderedEndpoints.First()}");
|
||||
}
|
||||
}
|
||||
|
||||
>>>>>>> dev
|
||||
// Test connectivity with fallback
|
||||
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Cronos" Version="0.11.1" />
|
||||
<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" />
|
||||
|
||||
@@ -32,7 +32,8 @@
|
||||
"EnableExternalPlaylists": true
|
||||
},
|
||||
"Library": {
|
||||
"DownloadPath": "./downloads"
|
||||
"DownloadPath": "./downloads",
|
||||
"KeptPath": "/app/kept"
|
||||
},
|
||||
"Qobuz": {
|
||||
"UserAuthToken": "your-qobuz-token",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -3052,3 +3053,3342 @@
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
<!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); }
|
||||
.toast.warning { border-color: var(--warning); }
|
||||
.toast.info { border-color: var(--accent); }
|
||||
|
||||
@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: 75%;
|
||||
width: 75%;
|
||||
max-height: 65vh;
|
||||
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">Injected Playlists</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</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">
|
||||
<!-- Warning Banner (hidden by default) -->
|
||||
<div id="matching-warning-banner" style="display:none;background:#f59e0b;color:#000;padding:16px;border-radius:8px;margin-bottom:16px;font-weight:600;text-align:center;box-shadow:0 4px 6px rgba(0,0,0,0.1);">
|
||||
⚠️ TRACK MATCHING IN PROGRESS - Please wait for matching to complete before making changes to playlists or mappings!
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>
|
||||
Injected Spotify Playlists
|
||||
<div class="actions">
|
||||
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
|
||||
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
|
||||
<button onclick="refreshAndMatchAll()" title="Clear caches, fetch fresh data from Spotify, and match all tracks. This is a full rebuild and may take several minutes." style="background:var(--accent);border-color:var(--accent);">Refresh & Match All</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music service.
|
||||
</p>
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Spotify ID</th>
|
||||
<th>Sync Schedule</th>
|
||||
<th>Tracks</th>
|
||||
<th>Completion</th>
|
||||
<th>Cache Age</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlist-table-body">
|
||||
<tr>
|
||||
<td colspan="7" class="loading">
|
||||
<span class="spinner"></span> Loading playlists...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Manual Track Mappings Section -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
Manual Track Mappings
|
||||
<div class="actions">
|
||||
<button onclick="fetchTrackMappings()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||
</p>
|
||||
<div id="mappings-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">External:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--success);" id="mappings-external">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Playlist</th>
|
||||
<th>Spotify ID</th>
|
||||
<th>Type</th>
|
||||
<th>Target</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mappings-table-body">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">
|
||||
<span class="spinner"></span> Loading mappings...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Missing Tracks Section -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
Missing Tracks (All Playlists)
|
||||
<div class="actions">
|
||||
<button onclick="fetchMissingTracks()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
Tracks that couldn't be matched locally or externally. Map them manually to add them to your playlists.
|
||||
</p>
|
||||
<div id="missing-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Missing:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);" id="missing-total">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Playlist</th>
|
||||
<th>Track</th>
|
||||
<th>Artist</th>
|
||||
<th>Album</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="missing-tracks-table-body">
|
||||
<tr>
|
||||
<td colspan="5" class="loading">
|
||||
<span class="spinner"></span> Loading missing tracks...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Kept Downloads Section -->
|
||||
<div class="card">
|
||||
<h2>
|
||||
Kept Downloads
|
||||
<div class="actions">
|
||||
<button onclick="fetchDownloads()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
Downloaded files stored permanently. Download or delete individual tracks.
|
||||
</p>
|
||||
<div id="downloads-summary" style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Files:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="color: var(--text-secondary);">Total Size:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0 B</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Artist</th>
|
||||
<th>Album</th>
|
||||
<th>File</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="downloads-table-body">
|
||||
<tr>
|
||||
<td colspan="5" class="loading">
|
||||
<span class="spinner"></span> Loading downloads...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Tab -->
|
||||
<div class="tab-content" id="tab-config">
|
||||
<div class="card">
|
||||
<h2>Core Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Backend Type <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-backend-type">-</span>
|
||||
<button onclick="openEditSetting('BACKEND_TYPE', 'Backend Type', 'select', 'Choose your media server backend', ['Jellyfin', 'Subsonic'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Music Service <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-music-service">-</span>
|
||||
<button onclick="openEditSetting('MUSIC_SERVICE', 'Music Service', 'select', 'Choose your music download provider', ['SquidWTF', 'Deezer', 'Qobuz'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Storage Mode</span>
|
||||
<span class="value" id="config-storage-mode">-</span>
|
||||
<button onclick="openEditSetting('STORAGE_MODE', 'Storage Mode', 'select', 'Permanent keeps files forever, Cache auto-deletes after duration', ['Permanent', 'Cache'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item" id="cache-duration-row" style="display: none;">
|
||||
<span class="label">Cache Duration (hours)</span>
|
||||
<span class="value" id="config-cache-duration-hours">-</span>
|
||||
<button onclick="openEditSetting('CACHE_DURATION_HOURS', 'Cache Duration (hours)', 'number', 'How long to keep cached files before deletion')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Download Mode</span>
|
||||
<span class="value" id="config-download-mode">-</span>
|
||||
<button onclick="openEditSetting('DOWNLOAD_MODE', 'Download Mode', 'select', 'Download individual tracks or full albums', ['Track', 'Album'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Explicit Filter</span>
|
||||
<span class="value" id="config-explicit-filter">-</span>
|
||||
<button onclick="openEditSetting('EXPLICIT_FILTER', 'Explicit Filter', 'select', 'Filter explicit content', ['All', 'Explicit', 'Clean'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Enable External Playlists</span>
|
||||
<span class="value" id="config-enable-external-playlists">-</span>
|
||||
<button onclick="openEditSetting('ENABLE_EXTERNAL_PLAYLISTS', 'Enable External Playlists', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Playlists Directory</span>
|
||||
<span class="value" id="config-playlists-directory">-</span>
|
||||
<button onclick="openEditSetting('PLAYLISTS_DIRECTORY', 'Playlists Directory', 'text', 'Directory path for external playlists')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Redis Enabled</span>
|
||||
<span class="value" id="config-redis-enabled">-</span>
|
||||
<button onclick="openEditSetting('REDIS_ENABLED', 'Redis Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Spotify API Settings</h2>
|
||||
<div style="background: rgba(248, 81, 73, 0.15); border: 1px solid var(--error); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
|
||||
⚠️ For active playlists and link functionality to work, sp_dc session cookie must be set!
|
||||
</div>
|
||||
<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 style="color: var(--error);">*</span></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>MusicBrainz Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Enabled</span>
|
||||
<span class="value" id="config-musicbrainz-enabled">-</span>
|
||||
<button onclick="openEditSetting('MUSICBRAINZ_ENABLED', 'MusicBrainz Enabled', 'select', '', ['true', 'false'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Username</span>
|
||||
<span class="value" id="config-musicbrainz-username">-</span>
|
||||
<button onclick="openEditSetting('MUSICBRAINZ_USERNAME', 'MusicBrainz Username', 'text', 'Your MusicBrainz username')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Password</span>
|
||||
<span class="value" id="config-musicbrainz-password">-</span>
|
||||
<button onclick="openEditSetting('MUSICBRAINZ_PASSWORD', 'MusicBrainz Password', 'password', 'Your MusicBrainz password')">Update</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 style="color: var(--error);">*</span></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 style="color: var(--error);">*</span></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 style="color: var(--error);">*</span></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>Library Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Download Path (Cache)</span>
|
||||
<span class="value" id="config-download-path">-</span>
|
||||
<button onclick="openEditSetting('LIBRARY_DOWNLOAD_PATH', 'Download Path', 'text')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Kept Path (Favorited)</span>
|
||||
<span class="value" id="config-kept-path">-</span>
|
||||
<button onclick="openEditSetting('LIBRARY_KEPT_PATH', 'Kept Path', 'text')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Spotify Import Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Spotify Import Enabled</span>
|
||||
<span class="value" id="config-spotify-import-enabled">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_ENABLED', 'Spotify Import Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Matching Interval (hours)</span>
|
||||
<span class="value" id="config-matching-interval">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS', 'Matching Interval (hours)', 'number', 'How often to check for playlist updates')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Configuration Backup</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Export your .env configuration for backup or import a previously saved configuration.
|
||||
</p>
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button onclick="exportEnv()">📥 Export .env</button>
|
||||
<button onclick="document.getElementById('import-env-input').click()">📤 Import .env</button>
|
||||
<input type="file" id="import-env-input" accept=".env" style="display:none" onchange="importEnv(event)">
|
||||
</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>
|
||||
|
||||
<!-- API Analytics Tab -->
|
||||
<div class="tab-content" id="tab-endpoints">
|
||||
<div class="card">
|
||||
<h2>
|
||||
API Endpoint Usage
|
||||
<div class="actions">
|
||||
<button onclick="fetchEndpointUsage()">Refresh</button>
|
||||
<button class="danger" onclick="clearEndpointUsage()">Clear Data</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Track which Jellyfin API endpoints are being called most frequently. Useful for debugging and understanding client behavior.
|
||||
</p>
|
||||
|
||||
<div id="endpoints-summary" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 20px;">
|
||||
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Total Requests</div>
|
||||
<div style="font-size: 1.8rem; font-weight: 600; color: var(--accent);" id="endpoints-total-requests">0</div>
|
||||
</div>
|
||||
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Unique Endpoints</div>
|
||||
<div style="font-size: 1.8rem; font-weight: 600; color: var(--success);" id="endpoints-unique-count">0</div>
|
||||
</div>
|
||||
<div style="background: var(--bg-tertiary); padding: 16px; border-radius: 8px;">
|
||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 4px;">Most Called</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: var(--text-primary); word-break: break-all;" id="endpoints-most-called">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; color: var(--text-secondary); font-size: 0.9rem;">Show Top</label>
|
||||
<select id="endpoints-top-select" onchange="fetchEndpointUsage()" style="padding: 8px; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
||||
<option value="25">Top 25</option>
|
||||
<option value="50" selected>Top 50</option>
|
||||
<option value="100">Top 100</option>
|
||||
<option value="500">Top 500</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="max-height: 600px; overflow-y: auto;">
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 60px;">#</th>
|
||||
<th>Endpoint</th>
|
||||
<th style="width: 120px; text-align: right;">Requests</th>
|
||||
<th style="width: 120px; text-align: right;">% of Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="endpoints-table-body">
|
||||
<tr>
|
||||
<td colspan="4" class="loading">
|
||||
<span class="spinner"></span> Loading endpoint usage data...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>About Endpoint Tracking</h2>
|
||||
<p style="color: var(--text-secondary); line-height: 1.6;">
|
||||
Allstarr logs every Jellyfin API endpoint call to help you understand how clients interact with your server.
|
||||
This data is stored in <code style="background: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px;">/app/cache/endpoint-usage/endpoints.csv</code>
|
||||
and persists across restarts.
|
||||
<br><br>
|
||||
<strong>Common Endpoints:</strong>
|
||||
<ul style="margin-top: 8px; margin-left: 20px;">
|
||||
<li><code>/Users/{userId}/Items</code> - Browse library items</li>
|
||||
<li><code>/Items/{itemId}</code> - Get item details</li>
|
||||
<li><code>/Audio/{itemId}/stream</code> - Stream audio</li>
|
||||
<li><code>/Sessions/Playing</code> - Report playback status</li>
|
||||
<li><code>/Search/Hints</code> - Search functionality</li>
|
||||
</ul>
|
||||
</p>
|
||||
</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: 90%; width: 90%;">
|
||||
<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 External Provider</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||
</p>
|
||||
|
||||
<!-- Track Info -->
|
||||
<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>
|
||||
|
||||
<!-- External Mapping Section -->
|
||||
<div id="external-mapping-section">
|
||||
<div class="form-group">
|
||||
<label>External Provider</label>
|
||||
<select id="map-external-provider" style="width: 100%;">
|
||||
<option value="SquidWTF">SquidWTF</option>
|
||||
<option value="Deezer">Deezer</option>
|
||||
<option value="Qobuz">Qobuz</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>External Provider ID</label>
|
||||
<input type="text" id="map-external-id" placeholder="Enter the provider-specific track ID..." oninput="validateExternalMapping()">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
For SquidWTF: Use the track ID from the search results or URL<br>
|
||||
For Deezer: Use the track ID from Deezer URLs<br>
|
||||
For Qobuz: Use the track ID from Qobuz URLs
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="map-playlist-name">
|
||||
<input type="hidden" id="map-spotify-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;">
|
||||
Select a playlist from your Spotify library or enter a playlist ID/URL manually. 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>
|
||||
|
||||
<!-- Toggle between select and manual input -->
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<button type="button" id="select-mode-btn" class="primary" onclick="switchLinkMode('select')" style="flex: 1;">Select from My Playlists</button>
|
||||
<button type="button" id="manual-mode-btn" onclick="switchLinkMode('manual')" style="flex: 1;">Enter Manually</button>
|
||||
</div>
|
||||
|
||||
<!-- Select from user playlists -->
|
||||
<div class="form-group" id="link-select-group">
|
||||
<label>Your Spotify Playlists</label>
|
||||
<select id="link-spotify-select" style="width: 100%;">
|
||||
<option value="">Loading playlists...</option>
|
||||
</select>
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Select a playlist from your Spotify library
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Manual input -->
|
||||
<div class="form-group" id="link-manual-group" style="display: none;">
|
||||
<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>
|
||||
|
||||
<!-- Sync Schedule -->
|
||||
<div class="form-group">
|
||||
<label>Sync Schedule (Cron)</label>
|
||||
<input type="text" id="link-sync-schedule" placeholder="0 8 * * 1" value="0 8 * * 1" style="font-family: monospace;">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Cron format: <code>minute hour day month dayofweek</code><br>
|
||||
Default: <code>0 8 * * 1</code> = 8 AM every Monday<br>
|
||||
Examples: <code>0 6 * * *</code> = daily at 6 AM, <code>0 20 * * 5</code> = Fridays at 8 PM<br>
|
||||
<a href="https://crontab.guru/" target="_blank" style="color: var(--primary);">Use crontab.guru to build your schedule</a>
|
||||
</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>
|
||||
|
||||
<!-- Lyrics ID Mapping Modal -->
|
||||
<div class="modal" id="lyrics-map-modal">
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<h3>Map Lyrics ID</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Manually map a track to a specific lyrics ID from lrclib.net. You can find lyrics IDs by searching on <a href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a>.
|
||||
</p>
|
||||
|
||||
<!-- Track Info -->
|
||||
<div class="form-group">
|
||||
<label>Track</label>
|
||||
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||
<strong id="lyrics-map-title"></strong><br>
|
||||
<span style="color: var(--text-secondary);" id="lyrics-map-artist"></span><br>
|
||||
<small style="color: var(--text-secondary);" id="lyrics-map-album"></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lyrics ID Input -->
|
||||
<div class="form-group">
|
||||
<label>Lyrics ID from lrclib.net</label>
|
||||
<input type="number" id="lyrics-map-id" placeholder="Enter lyrics ID (e.g., 5929990)" min="1">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Search for the track on <a href="https://lrclib.net" target="_blank" style="color: var(--accent);">lrclib.net</a> and copy the ID from the URL or API response
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="lyrics-map-artist-value">
|
||||
<input type="hidden" id="lyrics-map-title-value">
|
||||
<input type="hidden" id="lyrics-map-album-value">
|
||||
<input type="hidden" id="lyrics-map-duration">
|
||||
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('lyrics-map-modal')">Cancel</button>
|
||||
<button class="primary" onclick="saveLyricsMapping()" id="lyrics-map-save-btn">Save Mapping</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);
|
||||
}
|
||||
|
||||
// Start auto-refresh for playlists tab (every 5 seconds)
|
||||
startPlaylistAutoRefresh();
|
||||
});
|
||||
|
||||
// Auto-refresh functionality for playlists
|
||||
let playlistAutoRefreshInterval = null;
|
||||
|
||||
function startPlaylistAutoRefresh() {
|
||||
// Clear any existing interval
|
||||
if (playlistAutoRefreshInterval) {
|
||||
clearInterval(playlistAutoRefreshInterval);
|
||||
}
|
||||
|
||||
// Refresh every 5 seconds when on playlists tab
|
||||
playlistAutoRefreshInterval = setInterval(() => {
|
||||
const playlistsTab = document.getElementById('tab-playlists');
|
||||
if (playlistsTab && playlistsTab.classList.contains('active')) {
|
||||
// Silently refresh without showing loading state
|
||||
fetchPlaylists(true);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function stopPlaylistAutoRefresh() {
|
||||
if (playlistAutoRefreshInterval) {
|
||||
clearInterval(playlistAutoRefreshInterval);
|
||||
playlistAutoRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Toast notification
|
||||
function showToast(message, type = 'success', duration = 3000) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast ' + type;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), duration);
|
||||
}
|
||||
|
||||
// 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(silent = false) {
|
||||
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) {
|
||||
if (!silent) {
|
||||
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;
|
||||
const totalPlayable = p.totalPlayable || (localCount + externalMatched); // Total tracks that will be served
|
||||
|
||||
// Debug: Log the raw data
|
||||
console.log(`Playlist ${p.name}:`, {
|
||||
spotifyTotal,
|
||||
localCount,
|
||||
externalMatched,
|
||||
externalMissing,
|
||||
totalInJellyfin,
|
||||
totalPlayable,
|
||||
rawData: p
|
||||
});
|
||||
|
||||
// Build detailed stats string - show total playable tracks prominently
|
||||
let statsHtml = `<span class="track-count">${totalPlayable}/${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 based on playable tracks
|
||||
const completionPct = spotifyTotal > 0 ? Math.round((totalPlayable / spotifyTotal) * 100) : 0;
|
||||
const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0;
|
||||
const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0;
|
||||
const missingPct = spotifyTotal > 0 ? Math.round((externalMissing / spotifyTotal) * 100) : 0;
|
||||
const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)';
|
||||
|
||||
// Debug logging
|
||||
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
|
||||
|
||||
const syncSchedule = p.syncSchedule || '0 8 * * 1';
|
||||
|
||||
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 style="font-family:monospace;font-size:0.85rem;">
|
||||
${escapeHtml(syncSchedule)}
|
||||
<button onclick="editPlaylistSchedule('${escapeJs(p.name)}', '${escapeJs(syncSchedule)}')" style="margin-left:4px;font-size:0.75rem;padding:2px 6px;">Edit</button>
|
||||
</td>
|
||||
<td>${statsHtml}${breakdown}</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;">
|
||||
<div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div>
|
||||
<div style="width:${externalPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMatched} external matched tracks"></div>
|
||||
<div style="width:${missingPct}%;height:100%;background:#6b7280;transition:width 0.3s;" title="${externalMissing} missing tracks"></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="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
||||
<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 fetchTrackMappings() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/mappings/tracks');
|
||||
const data = await res.json();
|
||||
|
||||
// Update summary (only external now)
|
||||
document.getElementById('mappings-total').textContent = data.externalCount || 0;
|
||||
document.getElementById('mappings-external').textContent = data.externalCount || 0;
|
||||
|
||||
const tbody = document.getElementById('mappings-table-body');
|
||||
|
||||
if (data.mappings.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No manual mappings found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to only show external mappings
|
||||
const externalMappings = data.mappings.filter(m => m.type === 'external');
|
||||
|
||||
if (externalMappings.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No external mappings found. Local Jellyfin mappings should be managed via Spotify Import plugin.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = externalMappings.map((m, index) => {
|
||||
const typeColor = 'var(--success)';
|
||||
const typeBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">external</span>`;
|
||||
|
||||
const targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
|
||||
|
||||
const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : '-';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(m.playlist)}</strong></td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${m.spotifyId}</td>
|
||||
<td>${typeBadge}</td>
|
||||
<td>${targetDisplay}</td>
|
||||
<td style="color:var(--text-secondary);font-size:0.85rem;">${createdDate}</td>
|
||||
<td>
|
||||
<button class="danger delete-mapping-btn" style="padding:4px 12px;font-size:0.8rem;" data-playlist="${escapeHtml(m.playlist)}" data-spotify-id="${m.spotifyId}">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add event listeners to all delete buttons
|
||||
document.querySelectorAll('.delete-mapping-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const playlist = this.getAttribute('data-playlist');
|
||||
const spotifyId = this.getAttribute('data-spotify-id');
|
||||
deleteTrackMapping(playlist, spotifyId);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch track mappings:', error);
|
||||
showToast('Failed to fetch track mappings', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTrackMapping(playlist, spotifyId) {
|
||||
if (!confirm(`Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\n• The track may be re-matched with potentially better results\n\nThis action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Mapping removed successfully', 'success');
|
||||
await fetchTrackMappings();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
showToast(error.error || 'Failed to remove mapping', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete mapping:', error);
|
||||
showToast('Failed to remove mapping', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMissingTracks() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists');
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('missing-tracks-table-body');
|
||||
const missingTracks = [];
|
||||
|
||||
// Collect all missing tracks from all playlists
|
||||
for (const playlist of data.playlists) {
|
||||
if (playlist.externalMissing > 0) {
|
||||
// Fetch tracks for this playlist
|
||||
try {
|
||||
const tracksRes = await fetch(`/api/admin/playlists/${encodeURIComponent(playlist.name)}/tracks`);
|
||||
const tracksData = await tracksRes.json();
|
||||
|
||||
// Filter to only missing tracks (isLocal === null)
|
||||
const missing = tracksData.tracks.filter(t => t.isLocal === null);
|
||||
missing.forEach(t => {
|
||||
missingTracks.push({
|
||||
playlist: playlist.name,
|
||||
...t
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch tracks for ${playlist.name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update summary
|
||||
document.getElementById('missing-total').textContent = missingTracks.length;
|
||||
|
||||
if (missingTracks.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">🎉 No missing tracks! All tracks are matched.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = missingTracks.map(t => {
|
||||
const artist = (t.artists && t.artists.length > 0) ? t.artists.join(', ') : '';
|
||||
const searchQuery = `${t.title} ${artist}`;
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(t.playlist)}</strong></td>
|
||||
<td>${escapeHtml(t.title)}</td>
|
||||
<td>${escapeHtml(artist)}</td>
|
||||
<td style="color:var(--text-secondary);">${t.album ? escapeHtml(t.album) : '-'}</td>
|
||||
<td>
|
||||
<button onclick="searchProvider('${escapeJs(searchQuery)}', 'squidwtf')"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">🔍 Search</button>
|
||||
<button onclick="openMapToLocal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--success);border-color:var(--success);">Map to Local</button>
|
||||
<button onclick="openMapToExternal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
|
||||
style="font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch missing tracks:', error);
|
||||
showToast('Failed to fetch missing tracks', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDownloads() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/downloads');
|
||||
const data = await res.json();
|
||||
|
||||
const tbody = document.getElementById('downloads-table-body');
|
||||
|
||||
// Update summary
|
||||
document.getElementById('downloads-count').textContent = data.count;
|
||||
document.getElementById('downloads-size').textContent = data.totalSizeFormatted;
|
||||
|
||||
if (data.count === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.files.map(f => {
|
||||
return `
|
||||
<tr data-path="${escapeHtml(f.path)}">
|
||||
<td><strong>${escapeHtml(f.artist)}</strong></td>
|
||||
<td>${escapeHtml(f.album)}</td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||
<td>
|
||||
<button onclick="downloadFile('${escapeJs(f.path)}')"
|
||||
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
|
||||
<button onclick="deleteDownload('${escapeJs(f.path)}')"
|
||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch downloads:', error);
|
||||
showToast('Failed to fetch downloads', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(path) {
|
||||
try {
|
||||
window.open(`/api/admin/downloads/file?path=${encodeURIComponent(path)}`, '_blank');
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error);
|
||||
showToast('Failed to download file', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDownload(path) {
|
||||
if (!confirm(`Delete this file?\n\n${path}\n\nThis action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/downloads?path=${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showToast('File deleted successfully', 'success');
|
||||
|
||||
// Remove the row immediately for live update
|
||||
const escapedPath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const row = document.querySelector(`tr[data-path="${escapedPath}"]`);
|
||||
if (row) {
|
||||
row.remove();
|
||||
}
|
||||
|
||||
// Refresh to update counts
|
||||
await fetchDownloads();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
showToast(error.error || 'Failed to delete file', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error);
|
||||
showToast('Failed to delete file', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/config');
|
||||
const data = await res.json();
|
||||
|
||||
// Core settings
|
||||
document.getElementById('config-backend-type').textContent = data.backendType || 'Jellyfin';
|
||||
document.getElementById('config-music-service').textContent = data.musicService || 'SquidWTF';
|
||||
document.getElementById('config-storage-mode').textContent = data.library?.storageMode || 'Cache';
|
||||
document.getElementById('config-cache-duration-hours').textContent = data.library?.cacheDurationHours || '24';
|
||||
document.getElementById('config-download-mode').textContent = data.library?.downloadMode || 'Track';
|
||||
document.getElementById('config-explicit-filter').textContent = data.explicitFilter || 'All';
|
||||
document.getElementById('config-enable-external-playlists').textContent = data.enableExternalPlaylists ? 'Yes' : 'No';
|
||||
document.getElementById('config-playlists-directory').textContent = data.playlistsDirectory || '(not set)';
|
||||
document.getElementById('config-redis-enabled').textContent = data.redisEnabled ? 'Yes' : 'No';
|
||||
|
||||
// Show/hide cache duration based on storage mode
|
||||
const cacheDurationRow = document.getElementById('cache-duration-row');
|
||||
if (cacheDurationRow) {
|
||||
cacheDurationRow.style.display = data.library?.storageMode === 'Cache' ? 'grid' : 'none';
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// MusicBrainz settings
|
||||
document.getElementById('config-musicbrainz-enabled').textContent = data.musicBrainz.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-musicbrainz-username').textContent = data.musicBrainz.username || '(not set)';
|
||||
document.getElementById('config-musicbrainz-password').textContent = data.musicBrainz.password || '(not set)';
|
||||
|
||||
// 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 || '-';
|
||||
|
||||
// Library settings
|
||||
document.getElementById('config-download-path').textContent = data.library?.downloadPath || './downloads';
|
||||
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
|
||||
|
||||
// Sync settings
|
||||
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' 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>';
|
||||
}
|
||||
}
|
||||
|
||||
let currentLinkMode = 'select'; // 'select' or 'manual'
|
||||
let spotifyUserPlaylists = []; // Cache of user playlists
|
||||
|
||||
function switchLinkMode(mode) {
|
||||
currentLinkMode = mode;
|
||||
|
||||
const selectGroup = document.getElementById('link-select-group');
|
||||
const manualGroup = document.getElementById('link-manual-group');
|
||||
const selectBtn = document.getElementById('select-mode-btn');
|
||||
const manualBtn = document.getElementById('manual-mode-btn');
|
||||
|
||||
if (mode === 'select') {
|
||||
selectGroup.style.display = 'block';
|
||||
manualGroup.style.display = 'none';
|
||||
selectBtn.classList.add('primary');
|
||||
manualBtn.classList.remove('primary');
|
||||
} else {
|
||||
selectGroup.style.display = 'none';
|
||||
manualGroup.style.display = 'block';
|
||||
selectBtn.classList.remove('primary');
|
||||
manualBtn.classList.add('primary');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSpotifyUserPlaylists() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/spotify/user-playlists');
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
console.error('Failed to fetch Spotify playlists:', res.status, error);
|
||||
|
||||
// Show user-friendly error message
|
||||
if (res.status === 429) {
|
||||
showToast('Spotify rate limit reached. Please wait a moment and try again.', 'warning', 5000);
|
||||
} else if (res.status === 401) {
|
||||
showToast('Spotify authentication failed. Check your sp_dc cookie.', 'error', 5000);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.playlists || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Spotify playlists:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function openLinkPlaylist(jellyfinId, name) {
|
||||
document.getElementById('link-jellyfin-id').value = jellyfinId;
|
||||
document.getElementById('link-jellyfin-name').value = name;
|
||||
document.getElementById('link-spotify-id').value = '';
|
||||
|
||||
// Reset to select mode
|
||||
switchLinkMode('select');
|
||||
|
||||
// Fetch user playlists if not already cached
|
||||
if (spotifyUserPlaylists.length === 0) {
|
||||
const select = document.getElementById('link-spotify-select');
|
||||
select.innerHTML = '<option value="">Loading playlists...</option>';
|
||||
|
||||
spotifyUserPlaylists = await fetchSpotifyUserPlaylists();
|
||||
|
||||
// Filter out already-linked playlists
|
||||
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
|
||||
|
||||
if (availablePlaylists.length === 0) {
|
||||
if (spotifyUserPlaylists.length > 0) {
|
||||
select.innerHTML = '<option value="">All your playlists are already linked</option>';
|
||||
} else {
|
||||
select.innerHTML = '<option value="">No playlists found or Spotify not configured</option>';
|
||||
}
|
||||
// Switch to manual mode if no available playlists
|
||||
switchLinkMode('manual');
|
||||
} else {
|
||||
// Populate dropdown with only unlinked playlists
|
||||
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
|
||||
availablePlaylists.map(p =>
|
||||
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
|
||||
).join('');
|
||||
}
|
||||
} else {
|
||||
// Re-filter in case playlists were linked since last fetch
|
||||
const select = document.getElementById('link-spotify-select');
|
||||
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
|
||||
|
||||
if (availablePlaylists.length === 0) {
|
||||
select.innerHTML = '<option value="">All your playlists are already linked</option>';
|
||||
switchLinkMode('manual');
|
||||
} else {
|
||||
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
|
||||
availablePlaylists.map(p =>
|
||||
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
|
||||
openModal('link-playlist-modal');
|
||||
}
|
||||
|
||||
async function linkPlaylist() {
|
||||
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
||||
const name = document.getElementById('link-jellyfin-name').value;
|
||||
const syncSchedule = document.getElementById('link-sync-schedule').value.trim();
|
||||
|
||||
// Validate sync schedule (basic cron format check)
|
||||
if (!syncSchedule) {
|
||||
showToast('Sync schedule is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const cronParts = syncSchedule.split(/\s+/);
|
||||
if (cronParts.length !== 5) {
|
||||
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get Spotify ID based on current mode
|
||||
let spotifyId = '';
|
||||
if (currentLinkMode === 'select') {
|
||||
spotifyId = document.getElementById('link-spotify-select').value;
|
||||
if (!spotifyId) {
|
||||
showToast('Please select a Spotify playlist', 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
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,
|
||||
syncSchedule: syncSchedule
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Playlist linked!', 'success');
|
||||
showRestartBanner();
|
||||
closeModal('link-playlist-modal');
|
||||
|
||||
// Clear the Spotify playlists cache so it refreshes next time
|
||||
spotifyUserPlaylists = [];
|
||||
|
||||
// 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();
|
||||
|
||||
// Clear the Spotify playlists cache so it refreshes next time
|
||||
spotifyUserPlaylists = [];
|
||||
|
||||
// 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 clearPlaylistCache(name) {
|
||||
if (!confirm(`Clear cache and rebuild for "${name}"?\n\nThis will:\n• Clear Redis cache\n• Delete file caches\n• Rebuild with latest Spotify IDs\n\nThis may take a minute.`)) return;
|
||||
|
||||
try {
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
showToast(`Clearing cache for ${name}...`, 'info');
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`✓ ${data.message} (Cleared ${data.clearedKeys} cache keys, ${data.clearedFiles} files)`, 'success', 5000);
|
||||
// Refresh the playlists table after a delay to show updated counts
|
||||
setTimeout(() => {
|
||||
fetchPlaylists();
|
||||
// Hide warning banner after refresh
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 3000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to clear cache', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to clear cache', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function matchPlaylistTracks(name) {
|
||||
try {
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
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();
|
||||
// Hide warning banner after refresh
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function matchAllPlaylists() {
|
||||
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
||||
|
||||
try {
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
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();
|
||||
// Hide warning banner after refresh
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAndMatchAll() {
|
||||
if (!confirm('Clear caches, refresh from Spotify, and match all tracks?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Match all tracks against local library and external providers\n\nThis may take several minutes.')) return;
|
||||
|
||||
try {
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
showToast('Starting full refresh and match...', 'info', 3000);
|
||||
|
||||
// Step 1: Clear all caches
|
||||
showToast('Step 1/3: Clearing caches...', 'info', 2000);
|
||||
await fetch('/api/admin/cache/clear', { method: 'POST' });
|
||||
|
||||
// Wait for cache to be fully cleared
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Step 2: Refresh playlists from Spotify
|
||||
showToast('Step 2/3: Fetching from Spotify...', 'info', 2000);
|
||||
await fetch('/api/admin/playlists/refresh', { method: 'POST' });
|
||||
|
||||
// Wait for Spotify fetch to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
// Step 3: Match all tracks
|
||||
showToast('Step 3/3: Matching all tracks (this may take several minutes)...', 'info', 3000);
|
||||
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`✓ Full refresh and match complete!`, 'success', 5000);
|
||||
// Refresh the playlists table after a delay
|
||||
setTimeout(() => {
|
||||
fetchPlaylists();
|
||||
// Hide warning banner after refresh
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 3000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to complete refresh and match', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function searchProvider(query, provider) {
|
||||
// Use SquidWTF HiFi API with round-robin base URLs for all searches
|
||||
// Get a random base URL from the backend
|
||||
try {
|
||||
const response = await fetch('/api/admin/squidwtf-base-url');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.baseUrl) {
|
||||
// Use the HiFi API search endpoint: /search/?s=query
|
||||
const searchUrl = `${data.baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||
window.open(searchUrl, '_blank');
|
||||
} else {
|
||||
showToast('Failed to get search URL', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to get search URL', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function capitalizeProvider(provider) {
|
||||
// Capitalize provider names for display
|
||||
const providerMap = {
|
||||
'squidwtf': 'SquidWTF',
|
||||
'deezer': 'Deezer',
|
||||
'qobuz': 'Qobuz'
|
||||
};
|
||||
return providerMap[provider?.toLowerCase()] || provider;
|
||||
}
|
||||
|
||||
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 exportEnv() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/export-env');
|
||||
if (!res.ok) {
|
||||
throw new Error('Export failed');
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `.env.backup.${new Date().toISOString().split('T')[0]}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
showToast('.env file exported successfully', 'success');
|
||||
} catch (error) {
|
||||
showToast('Failed to export .env file', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function importEnv(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!confirm('Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.')) {
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch('/api/admin/import-env', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(data.message, 'success');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to import .env file', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to import .env file', 'error');
|
||||
}
|
||||
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
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 editPlaylistSchedule(playlistName, currentSchedule) {
|
||||
const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM\n\nUse https://crontab.guru/ to build your schedule`, currentSchedule);
|
||||
|
||||
if (!newSchedule || newSchedule === currentSchedule) return;
|
||||
|
||||
// Validate cron format
|
||||
const cronParts = newSchedule.trim().split(/\s+/);
|
||||
if (cronParts.length !== 5) {
|
||||
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/schedule`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ syncSchedule: newSchedule.trim() })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showToast('Sync schedule updated!', 'success');
|
||||
showRestartBanner();
|
||||
fetchPlaylists();
|
||||
} else {
|
||||
const error = await res.json();
|
||||
showToast(error.error || 'Failed to update schedule', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update schedule:', error);
|
||||
showToast('Failed to update schedule', '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');
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch tracks:', res.status, res.statusText);
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + res.status + ' ' + res.statusText + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
console.log('Tracks data received:', data);
|
||||
|
||||
if (!data || !data.tracks) {
|
||||
console.error('Invalid data structure:', data);
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Invalid data received from server</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
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 = '';
|
||||
let lyricsBadge = '';
|
||||
|
||||
// Add lyrics status badge
|
||||
if (t.hasLyrics) {
|
||||
lyricsBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:#3b82f6;color:white;"><span class="status-dot" style="background:white;"></span>Lyrics</span>';
|
||||
}
|
||||
|
||||
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>';
|
||||
// Add manual mapping indicator for local tracks
|
||||
if (t.isManualMapping && t.manualMappingType === 'jellyfin') {
|
||||
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
|
||||
}
|
||||
} else if (t.isLocal === false) {
|
||||
const provider = capitalizeProvider(t.externalProvider) || 'External';
|
||||
statusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
|
||||
// Add manual mapping indicator for external tracks
|
||||
if (t.isManualMapping && t.manualMappingType === 'external') {
|
||||
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
|
||||
}
|
||||
// Add both mapping buttons for external tracks using data attributes
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
mapButton = `<button class="small map-track-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
|
||||
<button class="small map-external-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
||||
} else {
|
||||
// isLocal is null/undefined - track is missing (not found locally or externally)
|
||||
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:var(--bg-tertiary);color:var(--text-secondary);"><span class="status-dot" style="background:var(--text-secondary);"></span>Missing</span>';
|
||||
// Add both mapping buttons for missing tracks
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
mapButton = `<button class="small map-track-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
|
||||
<button class="small map-external-btn"
|
||||
data-playlist-name="${escapeHtml(name)}"
|
||||
data-position="${t.position}"
|
||||
data-title="${escapeHtml(t.title || '')}"
|
||||
data-artist="${escapeHtml(firstArtist)}"
|
||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
||||
}
|
||||
|
||||
// Build search link with track name and artist
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
const searchLinkText = `${t.title} - ${firstArtist}`;
|
||||
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
|
||||
|
||||
// Add lyrics mapping button
|
||||
const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || '')}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
|
||||
|
||||
return `
|
||||
<div class="track-item" data-position="${t.position}">
|
||||
<span class="track-position">${t.position + 1}</span>
|
||||
<div class="track-info">
|
||||
<h4>${escapeHtml(t.title)}${statusBadge}${lyricsBadge}${mapButton}${lyricsMapButton}</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>' : ''}
|
||||
${t.isLocal === false && t.searchQuery && t.externalProvider ? '<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'' + escapeJs(t.externalProvider) + '\'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ' + escapeHtml(searchLinkText) + '</a></small>' : ''}
|
||||
${t.isLocal === null && t.searchQuery ? '<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'squidwtf\'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ' + escapeHtml(searchLinkText) + '</a></small>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add event listeners to map buttons
|
||||
document.querySelectorAll('.map-track-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const playlistName = this.getAttribute('data-playlist-name');
|
||||
const position = parseInt(this.getAttribute('data-position'));
|
||||
const title = this.getAttribute('data-title');
|
||||
const artist = this.getAttribute('data-artist');
|
||||
const spotifyId = this.getAttribute('data-spotify-id');
|
||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listeners to external map buttons
|
||||
document.querySelectorAll('.map-external-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const playlistName = this.getAttribute('data-playlist-name');
|
||||
const position = parseInt(this.getAttribute('data-position'));
|
||||
const title = this.getAttribute('data-title');
|
||||
const artist = this.getAttribute('data-artist');
|
||||
const spotifyId = this.getAttribute('data-spotify-id');
|
||||
openExternalMap(playlistName, position, title, artist, spotifyId);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in viewTracks:', error);
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + error.message + '</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;
|
||||
|
||||
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 or paste a Jellyfin URL...</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear URL input when searching
|
||||
document.getElementById('map-jellyfin-url').value = '';
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
async function extractJellyfinId() {
|
||||
const url = document.getElementById('map-jellyfin-url').value.trim();
|
||||
|
||||
if (!url) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">Type to search for local tracks or paste a Jellyfin URL...</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear search input when using URL
|
||||
document.getElementById('map-search-query').value = '';
|
||||
|
||||
// Extract ID from URL patterns:
|
||||
// https://jellyfin.example.com/web/#/details?id=XXXXX&serverId=...
|
||||
// https://jellyfin.example.com/web/index.html#!/details?id=XXXXX
|
||||
let jellyfinId = null;
|
||||
|
||||
try {
|
||||
const idMatch = url.match(/[?&]id=([a-f0-9]+)/i);
|
||||
if (idMatch) {
|
||||
jellyfinId = idMatch[1];
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid URL format
|
||||
}
|
||||
|
||||
if (!jellyfinId) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Could not extract track ID from URL. Make sure it contains "?id=..."</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch track details to show preview
|
||||
document.getElementById('map-search-results').innerHTML = '<div class="loading"><span class="spinner"></span> Loading track details...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/jellyfin/track/' + jellyfinId);
|
||||
const track = await res.json();
|
||||
|
||||
if (res.ok && track.id) {
|
||||
document.getElementById('map-selected-jellyfin-id').value = track.id;
|
||||
document.getElementById('map-save-btn').disabled = false;
|
||||
|
||||
document.getElementById('map-search-results').innerHTML = `
|
||||
<div class="track-item" style="border: 2px solid var(--accent); background: var(--bg-tertiary);">
|
||||
<div class="track-info">
|
||||
<h4>${escapeHtml(track.title)}</h4>
|
||||
<span class="artists">${escapeHtml(track.artist)}</span>
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
${track.album ? escapeHtml(track.album) : ''}
|
||||
</div>
|
||||
</div>
|
||||
<p style="text-align: center; color: var(--success); padding: 12px; margin-top: 8px;">
|
||||
✓ Track loaded from URL. Click "Save Mapping" to confirm.
|
||||
</p>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Track not found in Jellyfin</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('map-search-results').innerHTML = '<p style="text-align: center; color: var(--error); padding: 20px;">Failed to load track details</p>';
|
||||
document.getElementById('map-selected-jellyfin-id').value = '';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function selectJellyfinTrack(jellyfinId, element) {
|
||||
// Remove selection from all tracks
|
||||
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
|
||||
el.style.border = '2px solid transparent';
|
||||
el.style.background = '';
|
||||
});
|
||||
|
||||
// Highlight selected track
|
||||
element.style.border = '2px solid var(--accent)';
|
||||
element.style.background = 'var(--bg-tertiary)';
|
||||
|
||||
// Store selected ID and enable save button
|
||||
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
|
||||
document.getElementById('map-save-btn').disabled = false;
|
||||
}
|
||||
|
||||
// Validate external mapping input
|
||||
function validateExternalMapping() {
|
||||
const externalId = document.getElementById('map-external-id').value.trim();
|
||||
const saveBtn = document.getElementById('map-save-btn');
|
||||
|
||||
// Enable save button if external ID is provided
|
||||
saveBtn.disabled = !externalId;
|
||||
}
|
||||
|
||||
// Open manual mapping modal (external only)
|
||||
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;
|
||||
|
||||
// Reset fields
|
||||
document.getElementById('map-external-id').value = '';
|
||||
document.getElementById('map-external-provider').value = 'SquidWTF';
|
||||
document.getElementById('map-save-btn').disabled = true;
|
||||
|
||||
openModal('manual-map-modal');
|
||||
}
|
||||
|
||||
// Alias for backward compatibility
|
||||
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||
}
|
||||
|
||||
// Save manual mapping (external only)
|
||||
async function saveManualMapping() {
|
||||
const playlistName = document.getElementById('map-playlist-name').value;
|
||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
|
||||
|
||||
const externalProvider = document.getElementById('map-external-provider').value;
|
||||
const externalId = document.getElementById('map-external-id').value.trim();
|
||||
|
||||
if (!externalId) {
|
||||
showToast('Please enter an external provider ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
spotifyId,
|
||||
externalProvider,
|
||||
externalId
|
||||
};
|
||||
|
||||
// Show loading state
|
||||
const saveBtn = document.getElementById('map-save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`✓ Track mapped to ${requestBody.externalProvider} - rebuilding playlist...`, 'success');
|
||||
closeModal('manual-map-modal');
|
||||
|
||||
// Show rebuilding indicator
|
||||
showPlaylistRebuildingIndicator(playlistName);
|
||||
|
||||
// Show detailed info toast after a moment
|
||||
setTimeout(() => {
|
||||
showToast(`🔄 Rebuilding playlist with your ${requestBody.externalProvider} mapping...`, 'info', 8000);
|
||||
}, 1000);
|
||||
|
||||
// Update the track in the UI without refreshing
|
||||
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
|
||||
if (trackItem) {
|
||||
const titleEl = trackItem.querySelector('.track-info h4');
|
||||
if (titleEl) {
|
||||
// Update status badge to show provider
|
||||
const currentTitle = titleEl.textContent.split(' - ')[0]; // Remove old status
|
||||
const capitalizedProvider = capitalizeProvider(requestBody.externalProvider);
|
||||
const newStatusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(capitalizedProvider)}</span>`;
|
||||
titleEl.innerHTML = escapeHtml(currentTitle) + newStatusBadge;
|
||||
}
|
||||
|
||||
// Remove search link since it's now mapped
|
||||
const searchLink = trackItem.querySelector('.track-meta a');
|
||||
if (searchLink) {
|
||||
searchLink.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Also refresh the playlist counts in the background
|
||||
fetchPlaylists();
|
||||
} else {
|
||||
showToast(data.error || 'Failed to save mapping', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
showToast('Request timed out - mapping may still be processing', 'warning');
|
||||
} else {
|
||||
showToast('Failed to save mapping', 'error');
|
||||
}
|
||||
} finally {
|
||||
// Reset button state
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showPlaylistRebuildingIndicator(playlistName) {
|
||||
// Find the playlist in the UI and show rebuilding state
|
||||
const playlistCards = document.querySelectorAll('.playlist-card');
|
||||
for (const card of playlistCards) {
|
||||
const nameEl = card.querySelector('h3');
|
||||
if (nameEl && nameEl.textContent.trim() === playlistName) {
|
||||
// Add rebuilding indicator
|
||||
const existingIndicator = card.querySelector('.rebuilding-indicator');
|
||||
if (!existingIndicator) {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'rebuilding-indicator';
|
||||
indicator.style.cssText = `
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: var(--warning);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 10;
|
||||
`;
|
||||
indicator.innerHTML = '<span class="spinner" style="width: 10px; height: 10px;"></span>Rebuilding...';
|
||||
card.style.position = 'relative';
|
||||
card.appendChild(indicator);
|
||||
|
||||
// Auto-remove after 30 seconds and refresh
|
||||
setTimeout(() => {
|
||||
indicator.remove();
|
||||
fetchPlaylists(); // Refresh to get updated counts
|
||||
}, 30000);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeJs(text) {
|
||||
if (!text) return '';
|
||||
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
// Lyrics ID mapping functions
|
||||
function openLyricsMap(artist, title, album, durationSeconds) {
|
||||
document.getElementById('lyrics-map-artist').textContent = artist;
|
||||
document.getElementById('lyrics-map-title').textContent = title;
|
||||
document.getElementById('lyrics-map-album').textContent = album || '(No album)';
|
||||
document.getElementById('lyrics-map-artist-value').value = artist;
|
||||
document.getElementById('lyrics-map-title-value').value = title;
|
||||
document.getElementById('lyrics-map-album-value').value = album || '';
|
||||
document.getElementById('lyrics-map-duration').value = durationSeconds;
|
||||
document.getElementById('lyrics-map-id').value = '';
|
||||
|
||||
openModal('lyrics-map-modal');
|
||||
}
|
||||
|
||||
async function saveLyricsMapping() {
|
||||
const artist = document.getElementById('lyrics-map-artist-value').value;
|
||||
const title = document.getElementById('lyrics-map-title-value').value;
|
||||
const album = document.getElementById('lyrics-map-album-value').value;
|
||||
const durationSeconds = parseInt(document.getElementById('lyrics-map-duration').value);
|
||||
const lyricsId = parseInt(document.getElementById('lyrics-map-id').value);
|
||||
|
||||
if (!lyricsId || lyricsId <= 0) {
|
||||
showToast('Please enter a valid lyrics ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const saveBtn = document.getElementById('lyrics-map-save-btn');
|
||||
const originalText = saveBtn.textContent;
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/lyrics/map', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
artist,
|
||||
title,
|
||||
album,
|
||||
durationSeconds,
|
||||
lyricsId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
if (data.cached && data.lyrics) {
|
||||
showToast(`✓ Lyrics mapped and cached: ${data.lyrics.trackName} by ${data.lyrics.artistName}`, 'success', 5000);
|
||||
} else {
|
||||
showToast('✓ Lyrics mapping saved successfully', 'success');
|
||||
}
|
||||
closeModal('lyrics-map-modal');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to save lyrics mapping', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to save lyrics mapping', 'error');
|
||||
} finally {
|
||||
saveBtn.textContent = originalText;
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load
|
||||
fetchStatus();
|
||||
fetchPlaylists();
|
||||
fetchTrackMappings();
|
||||
fetchMissingTracks();
|
||||
fetchDownloads();
|
||||
fetchJellyfinUsers();
|
||||
fetchJellyfinPlaylists();
|
||||
fetchConfig();
|
||||
fetchEndpointUsage();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(() => {
|
||||
fetchStatus();
|
||||
fetchPlaylists();
|
||||
fetchTrackMappings();
|
||||
fetchMissingTracks();
|
||||
fetchDownloads();
|
||||
|
||||
// Refresh endpoint usage if on that tab
|
||||
const endpointsTab = document.getElementById('tab-endpoints');
|
||||
if (endpointsTab && endpointsTab.classList.contains('active')) {
|
||||
fetchEndpointUsage();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Endpoint Usage Functions
|
||||
async function fetchEndpointUsage() {
|
||||
try {
|
||||
const topSelect = document.getElementById('endpoints-top-select');
|
||||
const top = topSelect ? topSelect.value : 50;
|
||||
|
||||
const res = await fetch(`/api/admin/debug/endpoint-usage?top=${top}`);
|
||||
const data = await res.json();
|
||||
|
||||
// Update summary stats
|
||||
document.getElementById('endpoints-total-requests').textContent = data.totalRequests?.toLocaleString() || '0';
|
||||
document.getElementById('endpoints-unique-count').textContent = data.totalEndpoints?.toLocaleString() || '0';
|
||||
|
||||
const mostCalled = data.endpoints && data.endpoints.length > 0
|
||||
? data.endpoints[0].endpoint
|
||||
: '-';
|
||||
document.getElementById('endpoints-most-called').textContent = mostCalled;
|
||||
|
||||
// Update table
|
||||
const tbody = document.getElementById('endpoints-table-body');
|
||||
|
||||
if (!data.endpoints || data.endpoints.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No endpoint usage data available yet. Data will appear as clients make requests.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.endpoints.map((ep, index) => {
|
||||
const percentage = data.totalRequests > 0
|
||||
? ((ep.count / data.totalRequests) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
|
||||
// Color code based on usage
|
||||
let countColor = 'var(--text-primary)';
|
||||
if (ep.count > 1000) countColor = 'var(--error)';
|
||||
else if (ep.count > 100) countColor = 'var(--warning)';
|
||||
else if (ep.count > 10) countColor = 'var(--accent)';
|
||||
|
||||
// Highlight common patterns
|
||||
let endpointDisplay = ep.endpoint;
|
||||
if (ep.endpoint.includes('/stream')) {
|
||||
endpointDisplay = `<span style="color:var(--success)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else if (ep.endpoint.includes('/Playing')) {
|
||||
endpointDisplay = `<span style="color:var(--accent)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else if (ep.endpoint.includes('/Search')) {
|
||||
endpointDisplay = `<span style="color:var(--warning)">${escapeHtml(ep.endpoint)}</span>`;
|
||||
} else {
|
||||
endpointDisplay = escapeHtml(ep.endpoint);
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="color:var(--text-secondary);text-align:center;">${index + 1}</td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${endpointDisplay}</td>
|
||||
<td style="text-align:right;font-weight:600;color:${countColor}">${ep.count.toLocaleString()}</td>
|
||||
<td style="text-align:right;color:var(--text-secondary)">${percentage}%</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch endpoint usage:', error);
|
||||
const tbody = document.getElementById('endpoints-table-body');
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--error);padding:40px;">Failed to load endpoint usage data</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function clearEndpointUsage() {
|
||||
if (!confirm('Are you sure you want to clear all endpoint usage data? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/debug/endpoint-usage', { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
|
||||
showToast(data.message || 'Endpoint usage data cleared', 'success');
|
||||
fetchEndpointUsage();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear endpoint usage:', error);
|
||||
showToast('Failed to clear endpoint usage data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
>>>>>>> dev
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
<<<<<<< HEAD
|
||||
volumes:
|
||||
- ${REDIS_DATA_PATH:-./redis-data}:/data
|
||||
networks:
|
||||
@@ -25,6 +26,25 @@ services:
|
||||
- "8365:8080"
|
||||
environment:
|
||||
- SP_DC=${SPOTIFY_API_SESSION_COOKIE:-}
|
||||
||||||| bc4e5d9
|
||||
=======
|
||||
volumes:
|
||||
- ${REDIS_DATA_PATH:-./redis-data}:/data
|
||||
networks:
|
||||
- allstarr-network
|
||||
|
||||
# Spotify Lyrics API sidecar service
|
||||
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
|
||||
spotify-lyrics:
|
||||
image: akashrchandran/spotify-lyrics-api:latest
|
||||
platform: linux/amd64
|
||||
container_name: allstarr-spotify-lyrics
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8365:8080"
|
||||
environment:
|
||||
- SP_DC=${SPOTIFY_API_SESSION_COOKIE:-}
|
||||
>>>>>>> dev
|
||||
networks:
|
||||
- allstarr-network
|
||||
|
||||
|
||||
Reference in New Issue
Block a user