mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-21 02:02:31 -04:00
v1.0.0: Lots of WebUI fixes, API fixes, refactored all of caching, general bug fixes, redid all log messages
This commit is contained in:
+50
-16
@@ -2,14 +2,50 @@
|
|||||||
# Choose which media server backend to use: Subsonic or Jellyfin
|
# Choose which media server backend to use: Subsonic or Jellyfin
|
||||||
BACKEND_TYPE=Subsonic
|
BACKEND_TYPE=Subsonic
|
||||||
|
|
||||||
# ===== REDIS CACHE =====
|
# ===== REDIS CACHE (REQUIRED) =====
|
||||||
# Enable Redis caching for metadata and images (default: true)
|
# Redis is the primary cache for all runtime data (search results, playlists, lyrics, etc.)
|
||||||
REDIS_ENABLED=true
|
# File cache (/app/cache) acts as a persistence layer for cold starts
|
||||||
|
# Redis snapshots to disk every 60 seconds + AOF for durability
|
||||||
|
|
||||||
# Redis data persistence directory (default: ./redis-data)
|
# Redis data persistence directory (default: ./redis-data)
|
||||||
# Redis will save snapshots and append-only logs here to persist cache across restarts
|
# Contains Redis RDB snapshots and AOF logs for crash recovery
|
||||||
REDIS_DATA_PATH=./redis-data
|
REDIS_DATA_PATH=./redis-data
|
||||||
|
|
||||||
|
# ===== CACHE TTL SETTINGS =====
|
||||||
|
# Configure how long different types of data are cached
|
||||||
|
# Longer durations reduce API calls but may show stale data
|
||||||
|
# All values are configurable via Web UI (Configuration tab > Cache Settings)
|
||||||
|
# Changes require container restart to apply
|
||||||
|
|
||||||
|
# Search results cache duration in minutes (default: 120 = 2 hours)
|
||||||
|
CACHE_SEARCH_RESULTS_MINUTES=120
|
||||||
|
|
||||||
|
# Playlist cover images cache duration in hours (default: 168 = 1 week)
|
||||||
|
CACHE_PLAYLIST_IMAGES_HOURS=168
|
||||||
|
|
||||||
|
# Spotify playlist items cache duration in hours (default: 168 = 1 week)
|
||||||
|
CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS=168
|
||||||
|
|
||||||
|
# Spotify matched tracks cache duration in days (default: 30 days)
|
||||||
|
# This is the mapping of Spotify IDs to local/external tracks
|
||||||
|
CACHE_SPOTIFY_MATCHED_TRACKS_DAYS=30
|
||||||
|
|
||||||
|
# Lyrics cache duration in days (default: 14 = 2 weeks)
|
||||||
|
CACHE_LYRICS_DAYS=14
|
||||||
|
|
||||||
|
# Genre data cache duration in days (default: 30 days)
|
||||||
|
CACHE_GENRE_DAYS=30
|
||||||
|
|
||||||
|
# External metadata (SquidWTF/Deezer/Qobuz) cache duration in days (default: 7 days)
|
||||||
|
CACHE_METADATA_DAYS=7
|
||||||
|
|
||||||
|
# Odesli URL conversion cache duration in days (default: 60 days)
|
||||||
|
CACHE_ODESLI_LOOKUP_DAYS=60
|
||||||
|
|
||||||
|
# Jellyfin proxy images cache duration in days (default: 14 = 2 weeks)
|
||||||
|
CACHE_PROXY_IMAGES_DAYS=14
|
||||||
|
|
||||||
|
|
||||||
# ===== SUBSONIC/NAVIDROME CONFIGURATION =====
|
# ===== SUBSONIC/NAVIDROME CONFIGURATION =====
|
||||||
# Server URL (required if using Subsonic backend)
|
# Server URL (required if using Subsonic backend)
|
||||||
SUBSONIC_URL=http://localhost:4533
|
SUBSONIC_URL=http://localhost:4533
|
||||||
@@ -40,11 +76,16 @@ MUSIC_SERVICE=SquidWTF
|
|||||||
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
||||||
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
||||||
# - downloads/kept/ - Favorited external tracks (always permanent)
|
# - downloads/kept/ - Favorited external tracks (always permanent)
|
||||||
DOWNLOAD_PATH=./downloads
|
Library__DownloadPath=./downloads
|
||||||
|
|
||||||
# ===== SQUIDWTF CONFIGURATION =====
|
# ===== SQUIDWTF CONFIGURATION =====
|
||||||
# Different quality options for SquidWTF. Only FLAC supported right now
|
# Preferred audio quality (optional, default: LOSSLESS)
|
||||||
SQUIDWTF_QUALITY=FLAC
|
# - HI_RES or HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest quality)
|
||||||
|
# - FLAC or LOSSLESS: 16-bit/44.1kHz FLAC (CD quality, recommended)
|
||||||
|
# - HIGH: 320kbps AAC (high quality, smaller files)
|
||||||
|
# - LOW: 96kbps AAC (low quality, smallest files)
|
||||||
|
# If not specified, LOSSLESS (16-bit FLAC) will be used
|
||||||
|
SQUIDWTF_QUALITY=LOSSLESS
|
||||||
|
|
||||||
# ===== DEEZER CONFIGURATION =====
|
# ===== DEEZER CONFIGURATION =====
|
||||||
# Deezer ARL token (required if using Deezer)
|
# Deezer ARL token (required if using Deezer)
|
||||||
@@ -95,12 +136,12 @@ EXPLICIT_FILTER=All
|
|||||||
# The played track is downloaded first, remaining tracks are queued
|
# The played track is downloaded first, remaining tracks are queued
|
||||||
DOWNLOAD_MODE=Track
|
DOWNLOAD_MODE=Track
|
||||||
|
|
||||||
# Storage mode (optional, default: Permanent)
|
# Storage mode (optional, default: Cache)
|
||||||
# - Permanent: Files are saved to the library permanently and registered in the media server
|
# - Permanent: Files are saved to the library permanently and registered in the media server
|
||||||
# - Cache: Files are stored in /tmp and automatically cleaned up after CACHE_DURATION_HOURS
|
# - Cache: Files are stored in /tmp and automatically cleaned up after CACHE_DURATION_HOURS
|
||||||
# Not registered in media server, ideal for streaming without library bloat
|
# Not registered in media server, ideal for streaming without library bloat
|
||||||
# Note: On Linux/Docker, you can customize cache location by setting TMPDIR environment variable
|
# Note: On Linux/Docker, you can customize cache location by setting TMPDIR environment variable
|
||||||
STORAGE_MODE=Permanent
|
STORAGE_MODE=Cache
|
||||||
|
|
||||||
# Cache duration in hours (optional, default: 1)
|
# Cache duration in hours (optional, default: 1)
|
||||||
# Files older than this duration will be automatically deleted when STORAGE_MODE=Cache
|
# Files older than this duration will be automatically deleted when STORAGE_MODE=Cache
|
||||||
@@ -143,13 +184,6 @@ SPOTIFY_IMPORT_PLAYLISTS=[]
|
|||||||
# Enable direct Spotify API access (default: false)
|
# Enable direct Spotify API access (default: false)
|
||||||
SPOTIFY_API_ENABLED=false
|
SPOTIFY_API_ENABLED=false
|
||||||
|
|
||||||
# Spotify Client ID from https://developer.spotify.com/dashboard
|
|
||||||
# Create an app in the Spotify Developer Dashboard to get this
|
|
||||||
SPOTIFY_API_CLIENT_ID=
|
|
||||||
|
|
||||||
# Spotify Client Secret (optional - only needed for certain OAuth flows)
|
|
||||||
SPOTIFY_API_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
|
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
|
||||||
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
|
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
|
||||||
# via session cookie because they're not accessible through the official API.
|
# via session cookie because they're not accessible through the official API.
|
||||||
|
|||||||
+3
-15
@@ -83,21 +83,9 @@ cache/
|
|||||||
# Docker volumes
|
# Docker volumes
|
||||||
redis-data/
|
redis-data/
|
||||||
|
|
||||||
# API keys and specs (ignore markdown docs, keep OpenAPI spec)
|
# Ignore everything in apis folder except jellyfin-openapi-stable.json
|
||||||
apis/steering/
|
apis/*
|
||||||
apis/api-calls/*.json
|
!apis/jellyfin-openapi-stable.json
|
||||||
!apis/api-calls/jellyfin-openapi-stable.json
|
|
||||||
apis/temp.json
|
|
||||||
|
|
||||||
# Temporary documentation files
|
|
||||||
apis/*.md
|
|
||||||
|
|
||||||
# Log files for debugging
|
|
||||||
apis/api-calls/*.log
|
|
||||||
|
|
||||||
# Endpoint usage tracking
|
|
||||||
apis/api-calls/endpoint-usage.json
|
|
||||||
/app/cache/endpoint-usage/
|
|
||||||
|
|
||||||
# Original source code for reference
|
# Original source code for reference
|
||||||
originals/
|
originals/
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ The proxy will be available at `http://localhost:5274`.
|
|||||||
## Web Dashboard
|
## Web Dashboard
|
||||||
|
|
||||||
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
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
|
### Features
|
||||||
|
|
||||||
@@ -74,8 +76,6 @@ 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).
|
**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).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Nginx Proxy Setup (Required)
|
### Nginx Proxy Setup (Required)
|
||||||
|
|
||||||
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
|
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
|
||||||
@@ -139,8 +139,14 @@ This project brings together all the music streaming providers into one unified
|
|||||||
**Compatible Jellyfin clients:**
|
**Compatible Jellyfin clients:**
|
||||||
|
|
||||||
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
|
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
|
||||||
- [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" />
|
||||||
- [Finamp](https://github.com/jmshrv/finamp) ()
|
|
||||||
|
|
||||||
|
- [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)
|
||||||
|
|
||||||
_Working on getting more currently_
|
_Working on getting more currently_
|
||||||
|
|
||||||
@@ -336,6 +342,9 @@ Subsonic__EnableExternalPlaylists=false
|
|||||||
|
|
||||||
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.
|
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
|
#### Prerequisites
|
||||||
|
|
||||||
1. **Install the Jellyfin Spotify Import Plugin**
|
1. **Install the Jellyfin Spotify Import Plugin**
|
||||||
@@ -914,4 +923,4 @@ GPL-3.0
|
|||||||
- [Deezer](https://www.deezer.com/) - Music streaming service
|
- [Deezer](https://www.deezer.com/) - Music streaming service
|
||||||
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
|
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
|
||||||
- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
|
- [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
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using Xunit;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class FuzzyMatcherTests
|
||||||
|
{
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Mr. Brightside", "Mr. Brightside", 100)]
|
||||||
|
[InlineData("Mr Brightside", "Mr. Brightside", 100)]
|
||||||
|
[InlineData("Mr. Brightside", "Mr Brightside", 100)]
|
||||||
|
[InlineData("The Killers", "Killers", 85)]
|
||||||
|
[InlineData("Dua Lipa", "Dua-Lipa", 100)]
|
||||||
|
public void CalculateSimilarity_ExactAndNearMatches_ReturnsHighScore(string str1, string str2, int expectedMin)
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(score >= expectedMin, $"Expected score >= {expectedMin}, got {score}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Mr. Brightside", "Somebody Told Me", 20)]
|
||||||
|
[InlineData("The Killers", "The Beatles", 40)]
|
||||||
|
[InlineData("Hot Fuss", "Sam's Town", 20)]
|
||||||
|
public void CalculateSimilarity_DifferentStrings_ReturnsLowScore(string str1, string str2, int expectedMax)
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(score <= expectedMax, $"Expected score <= {expectedMax}, got {score}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CalculateSimilarity_IgnoresPunctuation()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var str1 = "Don't Stop Believin'";
|
||||||
|
var str2 = "Dont Stop Believin";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(score >= 95, $"Expected high score for punctuation differences, got {score}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CalculateSimilarity_IgnoresCase()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var str1 = "Mr. Brightside";
|
||||||
|
var str2 = "mr. brightside";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(100, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CalculateSimilarity_HandlesArticles()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var str1 = "The Killers";
|
||||||
|
var str2 = "Killers";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(score >= 80, $"Expected high score when 'The' is removed, got {score}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CalculateSimilarity_HandlesFeaturedArtists()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var str1 = "Song Title (feat. Artist)";
|
||||||
|
var str2 = "Song Title";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(score >= 70, $"Expected decent score for featured artist variations, got {score}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CalculateSimilarity_HandlesRemixes()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var str1 = "Song Title - Radio Edit";
|
||||||
|
var str2 = "Song Title";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(score >= 70, $"Expected decent score for remix/edit variations, got {score}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("", "", 0)]
|
||||||
|
[InlineData("Test", "", 0)]
|
||||||
|
[InlineData("", "Test", 0)]
|
||||||
|
public void CalculateSimilarity_EmptyStrings_ReturnsZero(string str1, string str2, int expected)
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(expected, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CalculateSimilarity_TokenOrder_DoesNotMatter()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var str1 = "Bright Side Mr";
|
||||||
|
var str2 = "Mr Bright Side";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(score >= 90, $"Expected high score regardless of token order, got {score}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CalculateSimilarity_PartialTokenMatch_ReturnsModerateScore()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var str1 = "Mr. Brightside";
|
||||||
|
var str2 = "Mr. Brightside (Live)";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(score >= 70 && score < 100, $"Expected moderate score for partial match, got {score}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CalculateSimilarity_SpecialCharacters_AreNormalized()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var str1 = "Café del Mar";
|
||||||
|
var str2 = "Cafe del Mar";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(score >= 90, $"Expected high score for accented characters, got {score}");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Moq;
|
using Moq;
|
||||||
using Moq.Protected;
|
using Moq.Protected;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
@@ -40,13 +41,19 @@ public class JellyfinProxyServiceTests
|
|||||||
ClientName = "TestClient",
|
ClientName = "TestClient",
|
||||||
DeviceName = "TestDevice",
|
DeviceName = "TestDevice",
|
||||||
DeviceId = "test-device-id",
|
DeviceId = "test-device-id",
|
||||||
ClientVersion = "1.0.0"
|
ClientVersion = "1.0.1"
|
||||||
};
|
};
|
||||||
|
|
||||||
var httpContext = new DefaultHttpContext();
|
var httpContext = new DefaultHttpContext();
|
||||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||||
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
|
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
|
||||||
|
|
||||||
|
// Initialize cache settings for tests
|
||||||
|
var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
|
||||||
|
serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults
|
||||||
|
var serviceProvider = serviceCollection.BuildServiceProvider();
|
||||||
|
CacheExtensions.InitializeCacheSettings(serviceProvider);
|
||||||
|
|
||||||
_service = new JellyfinProxyService(
|
_service = new JellyfinProxyService(
|
||||||
_mockHttpClientFactory.Object,
|
_mockHttpClientFactory.Object,
|
||||||
Options.Create(_settings),
|
Options.Create(_settings),
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using Xunit;
|
||||||
|
using Moq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using allstarr.Services.Lyrics;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class LrclibServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<ILogger<LrclibService>> _mockLogger;
|
||||||
|
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
||||||
|
private readonly Mock<RedisCacheService> _mockCache;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
public LrclibServiceTests()
|
||||||
|
{
|
||||||
|
_mockLogger = new Mock<ILogger<LrclibService>>();
|
||||||
|
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
|
||||||
|
|
||||||
|
// Create mock Redis cache
|
||||||
|
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||||
|
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
||||||
|
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
||||||
|
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("https://lrclib.net")
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(_httpClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_InitializesWithDependencies()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLyricsAsync_RequiresValidParameters()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Act & Assert - Should handle empty parameters gracefully
|
||||||
|
var result = service.GetLyricsAsync("", "Artist", "Album", 180);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLyricsAsync_SupportsMultipleArtists()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||||
|
var artists = new[] { "Artist 1", "Artist 2", "Artist 3" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.GetLyricsAsync("Track Name", artists, "Album", 180);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLyricsByIdAsync_AcceptsValidId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.GetLyricsByIdAsync(123456);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLyricsCachedAsync_UsesCache()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.GetLyricsCachedAsync("Track", "Artist", "Album", 180);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,7 +151,7 @@ public class QobuzDownloadServiceTests : IDisposable
|
|||||||
var mockResponse = new HttpResponseMessage
|
var mockResponse = new HttpResponseMessage
|
||||||
{
|
{
|
||||||
StatusCode = HttpStatusCode.OK,
|
StatusCode = HttpStatusCode.OK,
|
||||||
Content = new StringContent(@"<html><script src=""/resources/1.0.0-b001/bundle.js""></script></html>")
|
Content = new StringContent(@"<html><script src=""/resources/1.0.1-b001/bundle.js""></script></html>")
|
||||||
};
|
};
|
||||||
|
|
||||||
_httpMessageHandlerMock.Protected()
|
_httpMessageHandlerMock.Protected()
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
using Xunit;
|
||||||
|
using Moq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class RedisCacheServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<ILogger<RedisCacheService>> _mockLogger;
|
||||||
|
private readonly IOptions<RedisSettings> _settings;
|
||||||
|
|
||||||
|
public RedisCacheServiceTests()
|
||||||
|
{
|
||||||
|
_mockLogger = new Mock<ILogger<RedisCacheService>>();
|
||||||
|
_settings = Options.Create(new RedisSettings
|
||||||
|
{
|
||||||
|
Enabled = false, // Disabled for unit tests to avoid requiring actual Redis
|
||||||
|
ConnectionString = "localhost:6379"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_InitializesWithSettings()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(service);
|
||||||
|
Assert.False(service.IsEnabled); // Should be disabled in tests
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WithEnabledSettings_AttemptsConnection()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var enabledSettings = Options.Create(new RedisSettings
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
ConnectionString = "localhost:6379"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act - Constructor will try to connect but should handle failure gracefully
|
||||||
|
var service = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Assert - Service should be created even if connection fails
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStringAsync_WhenDisabled_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.GetStringAsync("test:key");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_WhenDisabled_ReturnsNull()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.GetAsync<TestObject>("test:key");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.SetStringAsync("test:key", "test value");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAsync_WhenDisabled_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
var testObj = new TestObject { Id = 1, Name = "Test" };
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.SetAsync("test:key", testObj);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.DeleteAsync("test:key");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.ExistsAsync("test:key");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.DeleteByPatternAsync("test:*");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(0, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
var expiry = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.SetStringAsync("test:key", "value", expiry);
|
||||||
|
|
||||||
|
// Assert - Should return false when disabled, but not throw
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
var testObj = new TestObject { Id = 1, Name = "Test" };
|
||||||
|
var expiry = TimeSpan.FromDays(30);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.SetAsync("test:key", testObj, expiry);
|
||||||
|
|
||||||
|
// Assert - Should return false when disabled, but not throw
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsEnabled_ReflectsSettings()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var disabledService = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
|
||||||
|
var enabledSettings = Options.Create(new RedisSettings
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
ConnectionString = "localhost:6379"
|
||||||
|
});
|
||||||
|
var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(disabledService.IsEnabled);
|
||||||
|
// enabledService.IsEnabled may be false if connection fails, which is expected
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_DeserializesComplexObjects()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.GetAsync<ComplexTestObject>("test:complex");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Null(result); // Null when disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAsync_SerializesComplexObjects()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||||
|
var complexObj = new ComplexTestObject
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Name = "Test",
|
||||||
|
Items = new System.Collections.Generic.List<string> { "Item1", "Item2" },
|
||||||
|
Metadata = new System.Collections.Generic.Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "Key1", "Value1" },
|
||||||
|
{ "Key2", "Value2" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.SetAsync("test:complex", complexObj, TimeSpan.FromHours(1));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.False(result); // False when disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectionString_IsConfigurable()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var customSettings = Options.Create(new RedisSettings
|
||||||
|
{
|
||||||
|
Enabled = false,
|
||||||
|
ConnectionString = "redis-server:6380,password=secret,ssl=true"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var service = new RedisCacheService(customSettings, _mockLogger.Object);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestObject
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ComplexTestObject
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public System.Collections.Generic.List<string> Items { get; set; } = new();
|
||||||
|
public System.Collections.Generic.Dictionary<string, string> Metadata { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using Xunit;
|
||||||
|
using Moq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using allstarr.Services.Spotify;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class SpotifyApiClientTests
|
||||||
|
{
|
||||||
|
private readonly Mock<ILogger<SpotifyApiClient>> _mockLogger;
|
||||||
|
private readonly IOptions<SpotifyApiSettings> _settings;
|
||||||
|
|
||||||
|
public SpotifyApiClientTests()
|
||||||
|
{
|
||||||
|
_mockLogger = new Mock<ILogger<SpotifyApiClient>>();
|
||||||
|
_settings = Options.Create(new SpotifyApiSettings
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
SessionCookie = "test_session_cookie_value",
|
||||||
|
CacheDurationMinutes = 60,
|
||||||
|
RateLimitDelayMs = 100,
|
||||||
|
PreferIsrcMatching = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_InitializesWithSettings()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var client = new SpotifyApiClient(_mockLogger.Object, _settings);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Settings_AreConfiguredCorrectly()
|
||||||
|
{
|
||||||
|
// Arrange & Act
|
||||||
|
var client = new SpotifyApiClient(_mockLogger.Object, _settings);
|
||||||
|
|
||||||
|
// Assert - Constructor should not throw
|
||||||
|
Assert.NotNull(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionCookie_IsRequired_ForWebApiAccess()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var settingsWithoutCookie = Options.Create(new SpotifyApiSettings
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
SessionCookie = "" // Empty cookie
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var client = new SpotifyApiClient(_mockLogger.Object, settingsWithoutCookie);
|
||||||
|
|
||||||
|
// Assert - Constructor should not throw, but GetWebAccessTokenAsync will return null
|
||||||
|
Assert.NotNull(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RateLimitSettings_AreRespected()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var customSettings = Options.Create(new SpotifyApiSettings
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
SessionCookie = "test_cookie",
|
||||||
|
RateLimitDelayMs = 500
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var client = new SpotifyApiClient(_mockLogger.Object, customSettings);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
using Xunit;
|
||||||
|
using Moq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using allstarr.Services.SquidWTF;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class SquidWTFMetadataServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<ILogger<SquidWTFMetadataService>> _mockLogger;
|
||||||
|
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
||||||
|
private readonly IOptions<SubsonicSettings> _subsonicSettings;
|
||||||
|
private readonly IOptions<SquidWTFSettings> _squidwtfSettings;
|
||||||
|
private readonly Mock<RedisCacheService> _mockCache;
|
||||||
|
private readonly List<string> _apiUrls;
|
||||||
|
|
||||||
|
public SquidWTFMetadataServiceTests()
|
||||||
|
{
|
||||||
|
_mockLogger = new Mock<ILogger<SquidWTFMetadataService>>();
|
||||||
|
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
|
||||||
|
|
||||||
|
_subsonicSettings = Options.Create(new SubsonicSettings
|
||||||
|
{
|
||||||
|
ExplicitFilter = ExplicitFilter.All
|
||||||
|
});
|
||||||
|
|
||||||
|
_squidwtfSettings = Options.Create(new SquidWTFSettings
|
||||||
|
{
|
||||||
|
Quality = "FLAC"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mock Redis cache
|
||||||
|
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||||
|
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
||||||
|
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
||||||
|
|
||||||
|
_apiUrls = new List<string>
|
||||||
|
{
|
||||||
|
"https://squid.wtf",
|
||||||
|
"https://mirror1.squid.wtf",
|
||||||
|
"https://mirror2.squid.wtf"
|
||||||
|
};
|
||||||
|
|
||||||
|
var httpClient = new System.Net.Http.HttpClient();
|
||||||
|
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_InitializesWithDependencies()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_AcceptsOptionalGenreEnrichment()
|
||||||
|
{
|
||||||
|
// Arrange - GenreEnrichmentService is optional, just pass null
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls,
|
||||||
|
null); // GenreEnrichmentService is optional
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SearchSongsAsync_AcceptsQueryAndLimit()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.SearchSongsAsync("Mr. Brightside", 20);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SearchAlbumsAsync_AcceptsQueryAndLimit()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.SearchAlbumsAsync("Hot Fuss", 20);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SearchArtistsAsync_AcceptsQueryAndLimit()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.SearchArtistsAsync("The Killers", 20);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SearchPlaylistsAsync_AcceptsQueryAndLimit()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.SearchPlaylistsAsync("Rock Classics", 20);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSongAsync_RequiresProviderAndId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.GetSongAsync("squidwtf", "123456");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAlbumAsync_RequiresProviderAndId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.GetAlbumAsync("squidwtf", "789012");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetArtistAsync_RequiresProviderAndId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.GetArtistAsync("squidwtf", "345678");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetArtistAlbumsAsync_RequiresProviderAndId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.GetArtistAlbumsAsync("squidwtf", "345678");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPlaylistAsync_RequiresProviderAndId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.GetPlaylistAsync("squidwtf", "playlist123");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPlaylistTracksAsync_RequiresProviderAndId()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.GetPlaylistTracksAsync("squidwtf", "playlist123");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SearchAllAsync_CombinesAllSearchTypes()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = service.SearchAllAsync("The Killers", 20, 20, 20);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExplicitFilter_RespectsSettings()
|
||||||
|
{
|
||||||
|
// Arrange - Test with CleanOnly filter
|
||||||
|
var cleanOnlySettings = Options.Create(new SubsonicSettings
|
||||||
|
{
|
||||||
|
ExplicitFilter = ExplicitFilter.CleanOnly
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
cleanOnlySettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
_apiUrls);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MultipleApiUrls_EnablesRoundRobinFallback()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var multipleUrls = new List<string>
|
||||||
|
{
|
||||||
|
"https://primary.squid.wtf",
|
||||||
|
"https://backup1.squid.wtf",
|
||||||
|
"https://backup2.squid.wtf",
|
||||||
|
"https://backup3.squid.wtf"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var service = new SquidWTFMetadataService(
|
||||||
|
_mockHttpClientFactory.Object,
|
||||||
|
_subsonicSettings,
|
||||||
|
_squidwtfSettings,
|
||||||
|
_mockLogger.Object,
|
||||||
|
_mockCache.Object,
|
||||||
|
multipleUrls);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
version = "1.0.0",
|
version = "1.0.1",
|
||||||
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
||||||
jellyfinUrl = _jellyfinSettings.Url,
|
jellyfinUrl = _jellyfinSettings.Url,
|
||||||
spotify = new
|
spotify = new
|
||||||
@@ -207,6 +207,10 @@ public class AdminController : ControllerBase
|
|||||||
return Ok(new { baseUrl });
|
return Ok(new { baseUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get current configuration including cache settings
|
||||||
|
/// </summary>
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get list of configured playlists with their current data
|
/// Get list of configured playlists with their current data
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -232,17 +236,17 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogDebug("🔄 Cache expired (age: {Age:F1}m), refreshing...", age.TotalMinutes);
|
_logger.LogWarning("🔄 Cache expired (age: {Age:F1}m), refreshing...", age.TotalMinutes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to read cached playlist summary");
|
_logger.LogError(ex, "Failed to read cached playlist summary");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (refresh)
|
else if (refresh)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🔄 Force refresh requested for playlist summary");
|
_logger.LogDebug("🔄 Force refresh requested for playlist summary");
|
||||||
}
|
}
|
||||||
|
|
||||||
var playlists = new List<object>();
|
var playlists = new List<object>();
|
||||||
@@ -259,6 +263,7 @@ public class AdminController : ControllerBase
|
|||||||
["id"] = config.Id,
|
["id"] = config.Id,
|
||||||
["jellyfinId"] = config.JellyfinId,
|
["jellyfinId"] = config.JellyfinId,
|
||||||
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
||||||
|
["syncSchedule"] = config.SyncSchedule ?? "0 8 * * 1",
|
||||||
["trackCount"] = 0,
|
["trackCount"] = 0,
|
||||||
["localTracks"] = 0,
|
["localTracks"] = 0,
|
||||||
["externalTracks"] = 0,
|
["externalTracks"] = 0,
|
||||||
@@ -296,7 +301,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to read cache for playlist {Name}", config.Name);
|
_logger.LogError(ex, "Failed to read cache for playlist {Name}", config.Name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +368,7 @@ public class AdminController : ControllerBase
|
|||||||
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name);
|
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}",
|
_logger.LogDebug("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}",
|
||||||
config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||||
|
|
||||||
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||||
@@ -376,6 +381,7 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
// Check if it's external by looking for external provider in ProviderIds
|
// Check if it's external by looking for external provider in ProviderIds
|
||||||
// External providers: SquidWTF, Deezer, Qobuz, Tidal
|
// External providers: SquidWTF, Deezer, Qobuz, Tidal
|
||||||
|
// Local tracks: Have Jellyfin ID OR no external provider keys
|
||||||
var isExternal = false;
|
var isExternal = false;
|
||||||
|
|
||||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||||
@@ -398,7 +404,7 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
if (providerIds != null)
|
if (providerIds != null)
|
||||||
{
|
{
|
||||||
// Check for external provider keys (not MusicBrainz, ISRC, Spotify, etc)
|
// Check for external provider keys (not MusicBrainz, ISRC, Spotify, Jellyfin, etc)
|
||||||
isExternal = providerIds.Keys.Any(k =>
|
isExternal = providerIds.Keys.Any(k =>
|
||||||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase) ||
|
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase) ||
|
||||||
k.Equals("Deezer", StringComparison.OrdinalIgnoreCase) ||
|
k.Equals("Deezer", StringComparison.OrdinalIgnoreCase) ||
|
||||||
@@ -413,6 +419,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// Local track (has Jellyfin ID or no external provider)
|
||||||
localCount++;
|
localCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,7 +434,7 @@ public class AdminController : ControllerBase
|
|||||||
playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count;
|
playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count;
|
||||||
playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served
|
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",
|
_logger.LogDebug("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);
|
config.Name, spotifyTracks.Count, localCount, externalCount, externalMissingCount, localCount + externalCount);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -544,7 +551,7 @@ public class AdminController : ControllerBase
|
|||||||
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
|
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
|
||||||
playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served
|
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",
|
_logger.LogWarning("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);
|
config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount, localCount + externalMatchedCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,19 +562,19 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Failed to get Jellyfin playlist {Name}: {StatusCode}",
|
_logger.LogError("Failed to get Jellyfin playlist {Name}: {StatusCode}",
|
||||||
config.Name, response.StatusCode);
|
config.Name, response.StatusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name);
|
_logger.LogError(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Playlist {Name} has no JellyfinId configured", config.Name);
|
_logger.LogInformation("Playlist {Name} has no JellyfinId configured", config.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
playlists.Add(playlistInfo);
|
playlists.Add(playlistInfo);
|
||||||
@@ -588,7 +595,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to save playlist summary cache");
|
_logger.LogError(ex, "Failed to save playlist summary cache");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new { playlists });
|
return Ok(new { playlists });
|
||||||
@@ -621,7 +628,7 @@ public class AdminController : ControllerBase
|
|||||||
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName);
|
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}",
|
_logger.LogDebug("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}",
|
||||||
decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||||
|
|
||||||
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||||
@@ -629,8 +636,15 @@ public class AdminController : ControllerBase
|
|||||||
// Build a map of Spotify ID -> cached item for quick lookup
|
// Build a map of Spotify ID -> cached item for quick lookup
|
||||||
var spotifyIdToItem = new Dictionary<string, Dictionary<string, object?>>();
|
var spotifyIdToItem = new Dictionary<string, Dictionary<string, object?>>();
|
||||||
|
|
||||||
foreach (var item in cachedPlaylistItems)
|
// Also track items by position for fallback matching
|
||||||
|
var itemsByPosition = new Dictionary<int, Dictionary<string, object?>>();
|
||||||
|
|
||||||
|
for (int i = 0; i < cachedPlaylistItems.Count; i++)
|
||||||
{
|
{
|
||||||
|
var item = cachedPlaylistItems[i];
|
||||||
|
|
||||||
|
// Try to get Spotify ID from ProviderIds (works for both local and external)
|
||||||
|
bool hasSpotifyId = false;
|
||||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||||
{
|
{
|
||||||
Dictionary<string, string>? providerIds = null;
|
Dictionary<string, string>? providerIds = null;
|
||||||
@@ -651,8 +665,15 @@ public class AdminController : ControllerBase
|
|||||||
if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId))
|
if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId))
|
||||||
{
|
{
|
||||||
spotifyIdToItem[spotifyId] = item;
|
spotifyIdToItem[spotifyId] = item;
|
||||||
|
hasSpotifyId = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no Spotify ID found, use position-based matching as fallback
|
||||||
|
if (!hasSpotifyId)
|
||||||
|
{
|
||||||
|
itemsByPosition[i] = item;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match each Spotify track to its cached item
|
// Match each Spotify track to its cached item
|
||||||
@@ -664,7 +685,20 @@ public class AdminController : ControllerBase
|
|||||||
string? manualMappingType = null;
|
string? manualMappingType = null;
|
||||||
string? manualMappingId = null;
|
string? manualMappingId = null;
|
||||||
|
|
||||||
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out var cachedItem))
|
Dictionary<string, object?>? cachedItem = null;
|
||||||
|
|
||||||
|
// First try to match by Spotify ID
|
||||||
|
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out cachedItem))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Matched track {Title} by Spotify ID", track.Title);
|
||||||
|
}
|
||||||
|
// Fallback: Try position-based matching for items without Spotify ID
|
||||||
|
else if (itemsByPosition.TryGetValue(track.Position, out cachedItem))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Matched track {Title} by position {Position}", track.Title, track.Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedItem != null)
|
||||||
{
|
{
|
||||||
// Track is in the cache - determine if it's local or external
|
// Track is in the cache - determine if it's local or external
|
||||||
if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||||
@@ -688,31 +722,29 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
_logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys));
|
_logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys));
|
||||||
|
|
||||||
// Check for external provider keys (case-insensitive)
|
// Check for external provider keys (SquidWTF, Deezer, Qobuz, Tidal)
|
||||||
// External providers: squidwtf, deezer, qobuz, tidal (lowercase)
|
// If found, it's an external track
|
||||||
var providerKey = providerIds.Keys.FirstOrDefault(k =>
|
if (providerIds.Keys.Any(k =>
|
||||||
k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase) ||
|
k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase) ||
|
||||||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase));
|
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
|
||||||
if (providerKey != null)
|
|
||||||
{
|
{
|
||||||
isLocal = false;
|
isLocal = false;
|
||||||
externalProvider = "SquidWTF";
|
externalProvider = "SquidWTF";
|
||||||
_logger.LogDebug("✓ Track {Title} identified as SquidWTF", track.Title);
|
_logger.LogDebug("✓ Track {Title} identified as SquidWTF", track.Title);
|
||||||
}
|
}
|
||||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase))) != null)
|
else if (providerIds.Keys.Any(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
isLocal = false;
|
isLocal = false;
|
||||||
externalProvider = "Deezer";
|
externalProvider = "Deezer";
|
||||||
_logger.LogDebug("✓ Track {Title} identified as Deezer", track.Title);
|
_logger.LogDebug("✓ Track {Title} identified as Deezer", track.Title);
|
||||||
}
|
}
|
||||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase))) != null)
|
else if (providerIds.Keys.Any(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
isLocal = false;
|
isLocal = false;
|
||||||
externalProvider = "Qobuz";
|
externalProvider = "Qobuz";
|
||||||
_logger.LogDebug("✓ Track {Title} identified as Qobuz", track.Title);
|
_logger.LogDebug("✓ Track {Title} identified as Qobuz", track.Title);
|
||||||
}
|
}
|
||||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase))) != null)
|
else if (providerIds.Keys.Any(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
isLocal = false;
|
isLocal = false;
|
||||||
externalProvider = "Tidal";
|
externalProvider = "Tidal";
|
||||||
@@ -720,22 +752,25 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No external provider key found - it's a local track
|
// No external provider key found - it's a local Jellyfin track
|
||||||
// Local tracks have MusicBrainz, ISRC, Spotify IDs but no external provider
|
// Local tracks may have: Jellyfin ID, MusicBrainz IDs, ISRC, etc.
|
||||||
isLocal = true;
|
isLocal = true;
|
||||||
_logger.LogDebug("✓ Track {Title} identified as LOCAL (has ProviderIds but no external provider)", track.Title);
|
_logger.LogDebug("✓ Track {Title} identified as LOCAL (has ProviderIds but no external provider)", track.Title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Track {Title} has ProviderIds object but it's null after parsing", track.Title);
|
// ProviderIds exists but is null after parsing - treat as local
|
||||||
|
isLocal = true;
|
||||||
|
_logger.LogDebug("✓ Track {Title} identified as LOCAL (ProviderIds null)", track.Title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Track {Title} in cache but has NO ProviderIds - treating as missing", track.Title);
|
// Track is in cache but has NO ProviderIds property at all
|
||||||
isLocal = null;
|
// This is typical for local Jellyfin tracks - treat as local
|
||||||
externalProvider = null;
|
isLocal = true;
|
||||||
|
_logger.LogDebug("✓ Track {Title} identified as LOCAL (in cache, no ProviderIds)", track.Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a manual mapping
|
// Check if this is a manual mapping
|
||||||
@@ -861,7 +896,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
_logger.LogError(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||||
@@ -916,13 +951,14 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Trigger track matching for a specific playlist
|
/// Re-match tracks when LOCAL library has changed (checks if Jellyfin playlist changed).
|
||||||
|
/// This is a lightweight operation that reuses cached Spotify data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("playlists/{name}/match")]
|
[HttpPost("playlists/{name}/match")]
|
||||||
public async Task<IActionResult> MatchPlaylistTracks(string name)
|
public async Task<IActionResult> MatchPlaylistTracks(string name)
|
||||||
{
|
{
|
||||||
var decodedName = Uri.UnescapeDataString(name);
|
var decodedName = Uri.UnescapeDataString(name);
|
||||||
_logger.LogInformation("Manual track matching triggered for playlist: {Name}", decodedName);
|
_logger.LogInformation("Re-match tracks triggered for playlist: {Name} (checking for local changes)", decodedName);
|
||||||
|
|
||||||
if (_matchingService == null)
|
if (_matchingService == null)
|
||||||
{
|
{
|
||||||
@@ -931,12 +967,31 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Clear the Jellyfin playlist signature cache to force re-checking if local tracks changed
|
||||||
|
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{decodedName}";
|
||||||
|
await _cache.DeleteAsync(jellyfinSignatureCacheKey);
|
||||||
|
_logger.LogDebug("Cleared Jellyfin signature cache to force change detection");
|
||||||
|
|
||||||
|
// Clear the matched results cache to force re-matching
|
||||||
|
var matchedTracksKey = $"spotify:matched:ordered:{decodedName}";
|
||||||
|
await _cache.DeleteAsync(matchedTracksKey);
|
||||||
|
_logger.LogDebug("Cleared matched tracks cache");
|
||||||
|
|
||||||
|
// Clear the playlist items cache
|
||||||
|
var playlistItemsCacheKey = $"spotify:playlist:items:{decodedName}";
|
||||||
|
await _cache.DeleteAsync(playlistItemsCacheKey);
|
||||||
|
_logger.LogDebug("Cleared playlist items cache");
|
||||||
|
|
||||||
|
// Trigger matching (will use cached Spotify data if still valid)
|
||||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||||
|
|
||||||
// Invalidate playlist summary cache
|
// Invalidate playlist summary cache
|
||||||
InvalidatePlaylistSummaryCache();
|
InvalidatePlaylistSummaryCache();
|
||||||
|
|
||||||
return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow });
|
return Ok(new {
|
||||||
|
message = $"Re-matching tracks for {decodedName} (checking local changes)",
|
||||||
|
timestamp = DateTime.UtcNow
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -946,13 +1001,14 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clear cache and rebuild for a specific playlist
|
/// Rebuild playlist from scratch when REMOTE (Spotify) playlist has changed.
|
||||||
|
/// Clears all caches including Spotify data and forces fresh fetch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("playlists/{name}/clear-cache")]
|
[HttpPost("playlists/{name}/clear-cache")]
|
||||||
public async Task<IActionResult> ClearPlaylistCache(string name)
|
public async Task<IActionResult> ClearPlaylistCache(string name)
|
||||||
{
|
{
|
||||||
var decodedName = Uri.UnescapeDataString(name);
|
var decodedName = Uri.UnescapeDataString(name);
|
||||||
_logger.LogInformation("Clear cache & rebuild triggered for playlist: {Name}", decodedName);
|
_logger.LogInformation("Rebuild from scratch triggered for playlist: {Name} (clearing Spotify cache)", decodedName);
|
||||||
|
|
||||||
if (_matchingService == null)
|
if (_matchingService == null)
|
||||||
{
|
{
|
||||||
@@ -961,13 +1017,15 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Clear all cache keys for this playlist
|
// Clear ALL cache keys for this playlist (including Spotify data)
|
||||||
var cacheKeys = new[]
|
var cacheKeys = new[]
|
||||||
{
|
{
|
||||||
$"spotify:playlist:items:{decodedName}", // Pre-built items cache
|
$"spotify:playlist:items:{decodedName}", // Pre-built items cache
|
||||||
$"spotify:matched:ordered:{decodedName}", // Ordered matched tracks
|
$"spotify:matched:ordered:{decodedName}", // Ordered matched tracks
|
||||||
$"spotify:matched:{decodedName}", // Legacy matched tracks
|
$"spotify:matched:{decodedName}", // Legacy matched tracks
|
||||||
$"spotify:missing:{decodedName}" // Missing tracks
|
$"spotify:missing:{decodedName}", // Missing tracks
|
||||||
|
$"spotify:playlist:jellyfin-signature:{decodedName}", // Jellyfin signature
|
||||||
|
$"spotify:playlist:{decodedName}" // Spotify playlist data
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var key in cacheKeys)
|
foreach (var key in cacheKeys)
|
||||||
@@ -993,9 +1051,9 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("✓ Cleared all caches for playlist: {Name}", decodedName);
|
_logger.LogInformation("✓ Cleared all caches for playlist: {Name} (including Spotify data)", decodedName);
|
||||||
|
|
||||||
// Trigger rebuild
|
// Trigger rebuild (will fetch fresh Spotify data)
|
||||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||||
|
|
||||||
// Invalidate playlist summary cache
|
// Invalidate playlist summary cache
|
||||||
@@ -1003,10 +1061,10 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
message = $"Cache cleared and rebuild triggered for {decodedName}",
|
message = $"Rebuilding {decodedName} from scratch (fetching fresh Spotify data)",
|
||||||
timestamp = DateTime.UtcNow,
|
timestamp = DateTime.UtcNow,
|
||||||
clearedKeys = cacheKeys.Length,
|
clearedKeys = cacheKeys.Length,
|
||||||
clearedFiles = filesToDelete.Count(System.IO.File.Exists)
|
clearedFiles = filesToDelete.Count(f => System.IO.File.Exists(f))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -1047,7 +1105,7 @@ public class AdminController : ControllerBase
|
|||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var errorBody = await response.Content.ReadAsStringAsync();
|
var errorBody = await response.Content.ReadAsStringAsync();
|
||||||
_logger.LogWarning("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
|
_logger.LogError("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
|
||||||
return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" });
|
return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1063,7 +1121,7 @@ public class AdminController : ControllerBase
|
|||||||
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
||||||
if (type != "Audio")
|
if (type != "Audio")
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Skipping non-audio item: {Type}", type);
|
_logger.LogWarning("Skipping non-audio item: {Type}", type);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1124,7 +1182,7 @@ public class AdminController : ControllerBase
|
|||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var errorBody = await response.Content.ReadAsStringAsync();
|
var errorBody = await response.Content.ReadAsStringAsync();
|
||||||
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}",
|
_logger.LogError("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}",
|
||||||
id, response.StatusCode, errorBody);
|
id, response.StatusCode, errorBody);
|
||||||
return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" });
|
return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" });
|
||||||
}
|
}
|
||||||
@@ -1245,7 +1303,7 @@ public class AdminController : ControllerBase
|
|||||||
if (System.IO.File.Exists(matchedFile))
|
if (System.IO.File.Exists(matchedFile))
|
||||||
{
|
{
|
||||||
System.IO.File.Delete(matchedFile);
|
System.IO.File.Delete(matchedFile);
|
||||||
_logger.LogDebug("Deleted matched tracks file cache for {Playlist}", decodedName);
|
_logger.LogInformation("Deleted matched tracks file cache for {Playlist}", decodedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (System.IO.File.Exists(itemsFile))
|
if (System.IO.File.Exists(itemsFile))
|
||||||
@@ -1256,7 +1314,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to delete file caches for {Playlist}", decodedName);
|
_logger.LogError(ex, "Failed to delete file caches for {Playlist}", decodedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
|
||||||
@@ -1282,13 +1340,13 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
|
_logger.LogError("Failed to fetch external track metadata for {Provider} ID {Id}",
|
||||||
normalizedProvider, request.ExternalId);
|
normalizedProvider, request.ExternalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
|
_logger.LogError(ex, "Failed to fetch external track metadata, but mapping was saved");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1379,6 +1437,12 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
return Ok(new
|
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
|
spotifyApi = new
|
||||||
{
|
{
|
||||||
enabled = _spotifyApiSettings.Enabled,
|
enabled = _spotifyApiSettings.Enabled,
|
||||||
@@ -1454,7 +1518,7 @@ public class AdminController : ControllerBase
|
|||||||
return BadRequest(new { error = "No updates provided" });
|
return BadRequest(new { error = "No updates provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Config update requested: {Count} changes", request.Updates.Count);
|
_logger.LogDebug("Config update requested: {Count} changes", request.Updates.Count);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1483,7 +1547,7 @@ public class AdminController : ControllerBase
|
|||||||
envContent[key] = value;
|
envContent[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_logger.LogInformation("Loaded {Count} existing env vars from {Path}", envContent.Count, _envFilePath);
|
_logger.LogDebug("Loaded {Count} existing env vars from {Path}", envContent.Count, _envFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply updates with validation
|
// Apply updates with validation
|
||||||
@@ -1519,7 +1583,13 @@ public class AdminController : ControllerBase
|
|||||||
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
|
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
|
||||||
await System.IO.File.WriteAllTextAsync(_envFilePath, newContent + "\n");
|
await System.IO.File.WriteAllTextAsync(_envFilePath, newContent + "\n");
|
||||||
|
|
||||||
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
_logger.LogDebug("Config file updated successfully at {Path}", _envFilePath);
|
||||||
|
|
||||||
|
// Invalidate playlist summary cache if playlists were updated
|
||||||
|
if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS"))
|
||||||
|
{
|
||||||
|
InvalidatePlaylistSummaryCache();
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -1641,7 +1711,7 @@ public class AdminController : ControllerBase
|
|||||||
[HttpPost("cache/clear")]
|
[HttpPost("cache/clear")]
|
||||||
public async Task<IActionResult> ClearCache()
|
public async Task<IActionResult> ClearCache()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Cache clear requested from admin UI");
|
_logger.LogDebug("Cache clear requested from admin UI");
|
||||||
|
|
||||||
var clearedFiles = 0;
|
var clearedFiles = 0;
|
||||||
var clearedRedisKeys = 0;
|
var clearedRedisKeys = 0;
|
||||||
@@ -1658,7 +1728,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to delete cache file {File}", file);
|
_logger.LogError(ex, "Failed to delete cache file {File}", file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1710,7 +1780,7 @@ public class AdminController : ControllerBase
|
|||||||
[HttpPost("restart")]
|
[HttpPost("restart")]
|
||||||
public async Task<IActionResult> RestartContainer()
|
public async Task<IActionResult> RestartContainer()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Container restart requested from admin UI");
|
_logger.LogDebug("Container restart requested from admin UI");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1731,7 +1801,7 @@ public class AdminController : ControllerBase
|
|||||||
var containerId = Environment.MachineName;
|
var containerId = Environment.MachineName;
|
||||||
var containerName = "allstarr";
|
var containerName = "allstarr";
|
||||||
|
|
||||||
_logger.LogInformation("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName);
|
_logger.LogDebug("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName);
|
||||||
|
|
||||||
// Create Unix socket HTTP client
|
// Create Unix socket HTTP client
|
||||||
var handler = new SocketsHttpHandler
|
var handler = new SocketsHttpHandler
|
||||||
@@ -1919,6 +1989,53 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
/// Get all playlists from Jellyfin
|
/// Get all playlists from Jellyfin
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1990,11 +2107,16 @@ public class AdminController : ControllerBase
|
|||||||
trackStats = await GetPlaylistTrackStats(id!);
|
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
|
playlists.Add(new
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
trackCount = childCount,
|
trackCount = actualTrackCount,
|
||||||
linkedSpotifyId,
|
linkedSpotifyId,
|
||||||
isConfigured,
|
isConfigured,
|
||||||
localTracks = trackStats.LocalTracks,
|
localTracks = trackStats.LocalTracks,
|
||||||
@@ -2056,7 +2178,7 @@ public class AdminController : ControllerBase
|
|||||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode);
|
_logger.LogError("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode);
|
||||||
return (0, 0, 0);
|
return (0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2163,12 +2285,19 @@ public class AdminController : ControllerBase
|
|||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
Id = request.SpotifyPlaylistId,
|
Id = request.SpotifyPlaylistId,
|
||||||
JellyfinId = jellyfinPlaylistId,
|
JellyfinId = jellyfinPlaylistId,
|
||||||
LocalTracksPosition = LocalTracksPosition.First // Use Spotify order
|
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"],...]
|
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
|
||||||
var playlistsJson = JsonSerializer.Serialize(
|
var playlistsJson = JsonSerializer.Serialize(
|
||||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
currentPlaylists.Select(p => new[] {
|
||||||
|
p.Name,
|
||||||
|
p.Id,
|
||||||
|
p.JellyfinId,
|
||||||
|
p.LocalTracksPosition.ToString().ToLower(),
|
||||||
|
p.SyncSchedule ?? "0 8 * * 1"
|
||||||
|
}).ToArray()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update .env file
|
// Update .env file
|
||||||
@@ -2193,9 +2322,63 @@ public class AdminController : ControllerBase
|
|||||||
return await RemovePlaylist(decodedName);
|
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()
|
private string GetJellyfinAuthHeader()
|
||||||
{
|
{
|
||||||
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.1\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -2224,7 +2407,7 @@ public class AdminController : ControllerBase
|
|||||||
return playlists;
|
return playlists;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
|
||||||
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
|
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
|
||||||
if (playlistArrays != null)
|
if (playlistArrays != null)
|
||||||
{
|
{
|
||||||
@@ -2240,7 +2423,8 @@ public class AdminController : ControllerBase
|
|||||||
LocalTracksPosition = arr.Length >= 4 &&
|
LocalTracksPosition = arr.Length >= 4 &&
|
||||||
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
||||||
? LocalTracksPosition.Last
|
? LocalTracksPosition.Last
|
||||||
: LocalTracksPosition.First
|
: LocalTracksPosition.First,
|
||||||
|
SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * 1"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2333,7 +2517,7 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
var backupPath = $"{_envFilePath}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}";
|
var backupPath = $"{_envFilePath}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||||
System.IO.File.Copy(_envFilePath, backupPath, true);
|
System.IO.File.Copy(_envFilePath, backupPath, true);
|
||||||
_logger.LogInformation("Backed up existing .env to {BackupPath}", backupPath);
|
_logger.LogDebug("Backed up existing .env to {BackupPath}", backupPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write new .env file
|
// Write new .env file
|
||||||
@@ -2643,7 +2827,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count);
|
_logger.LogDebug("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count);
|
||||||
|
|
||||||
return Ok(new {
|
return Ok(new {
|
||||||
message = "Spotify cache cleared successfully",
|
message = "Spotify cache cleared successfully",
|
||||||
@@ -2748,7 +2932,7 @@ public class AdminController : ControllerBase
|
|||||||
if (System.IO.File.Exists(logFile))
|
if (System.IO.File.Exists(logFile))
|
||||||
{
|
{
|
||||||
System.IO.File.Delete(logFile);
|
System.IO.File.Delete(logFile);
|
||||||
_logger.LogInformation("Cleared endpoint usage log via admin endpoint");
|
_logger.LogDebug("Cleared endpoint usage log via admin endpoint");
|
||||||
|
|
||||||
return Ok(new {
|
return Ok(new {
|
||||||
message = "Endpoint usage log cleared successfully",
|
message = "Endpoint usage log cleared successfully",
|
||||||
@@ -2917,7 +3101,7 @@ public class AdminController : ControllerBase
|
|||||||
// Cache the lyrics using the standard cache key
|
// Cache the lyrics using the standard cache key
|
||||||
var lyricsCacheKey = $"lyrics:{request.Artist}:{request.Title}:{request.Album ?? ""}:{request.DurationSeconds}";
|
var lyricsCacheKey = $"lyrics:{request.Artist}:{request.Title}:{request.Album ?? ""}:{request.DurationSeconds}";
|
||||||
await _cache.SetAsync(lyricsCacheKey, lyricsInfo.PlainLyrics);
|
await _cache.SetAsync(lyricsCacheKey, lyricsInfo.PlainLyrics);
|
||||||
_logger.LogInformation("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
|
_logger.LogDebug("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -2939,7 +3123,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch lyrics after mapping, but mapping was saved");
|
_logger.LogError(ex, "Failed to fetch lyrics after mapping, but mapping was saved");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
@@ -3030,7 +3214,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to read mapping file {File}", file);
|
_logger.LogError(ex, "Failed to read mapping file {File}", file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3237,7 +3421,7 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to invalidate playlist summary cache");
|
_logger.LogError(ex, "Failed to invalidate playlist summary cache");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3295,6 +3479,12 @@ public class LinkPlaylistRequest
|
|||||||
{
|
{
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string SpotifyPlaylistId { 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>
|
/// <summary>
|
||||||
@@ -3308,7 +3498,7 @@ public class LinkPlaylistRequest
|
|||||||
{
|
{
|
||||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||||
|
|
||||||
_logger.LogInformation("📂 Checking kept folder: {Path}", keptPath);
|
_logger.LogDebug("📂 Checking kept folder: {Path}", keptPath);
|
||||||
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
|
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
|
||||||
|
|
||||||
if (!Directory.Exists(keptPath))
|
if (!Directory.Exists(keptPath))
|
||||||
@@ -3327,7 +3517,7 @@ public class LinkPlaylistRequest
|
|||||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
_logger.LogInformation("📂 Found {Count} audio files in kept folder", allFiles.Count);
|
_logger.LogDebug("📂 Found {Count} audio files in kept folder", allFiles.Count);
|
||||||
|
|
||||||
foreach (var filePath in allFiles)
|
foreach (var filePath in allFiles)
|
||||||
{
|
{
|
||||||
@@ -3358,7 +3548,7 @@ public class LinkPlaylistRequest
|
|||||||
totalSize += fileInfo.Length;
|
totalSize += fileInfo.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("📂 Returning {Count} kept files, total size: {Size}", files.Count, FormatFileSize(totalSize));
|
_logger.LogDebug("📂 Returning {Count} kept files, total size: {Size}", files.Count, FormatFileSize(totalSize));
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -3392,7 +3582,7 @@ public class LinkPlaylistRequest
|
|||||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||||
var fullPath = Path.Combine(keptPath, path);
|
var fullPath = Path.Combine(keptPath, path);
|
||||||
|
|
||||||
_logger.LogInformation("🗑️ Delete request for: {Path}", fullPath);
|
_logger.LogDebug("🗑️ Delete request for: {Path}", fullPath);
|
||||||
|
|
||||||
// Security: Ensure the path is within the kept directory
|
// Security: Ensure the path is within the kept directory
|
||||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||||
@@ -3411,7 +3601,7 @@ public class LinkPlaylistRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
System.IO.File.Delete(fullPath);
|
System.IO.File.Delete(fullPath);
|
||||||
_logger.LogInformation("🗑️ Deleted file: {Path}", fullPath);
|
_logger.LogDebug("🗑️ Deleted file: {Path}", fullPath);
|
||||||
|
|
||||||
// Clean up empty directories (Album folder, then Artist folder if empty)
|
// Clean up empty directories (Album folder, then Artist folder if empty)
|
||||||
var directory = Path.GetDirectoryName(fullPath);
|
var directory = Path.GetDirectoryName(fullPath);
|
||||||
@@ -3425,7 +3615,7 @@ public class LinkPlaylistRequest
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogDebug("🗑️ Directory not empty or doesn't exist, stopping cleanup: {Dir}", directory);
|
_logger.LogInformation("🗑️ Directory not empty or doesn't exist, stopping cleanup: {Dir}", directory);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3494,4 +3684,12 @@ public class LinkPlaylistRequest
|
|||||||
}
|
}
|
||||||
return $"{len:0.##} {sizes[order]}";
|
return $"{len:0.##} {sizes[order]}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request model for updating configuration
|
||||||
|
/// </summary>
|
||||||
|
public class ConfigUpdateRequest
|
||||||
|
{
|
||||||
|
public Dictionary<string, string> Updates { get; set; } = new();
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly PlaylistSyncService? _playlistSyncService;
|
private readonly PlaylistSyncService? _playlistSyncService;
|
||||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||||
|
private readonly LyricsPlusService? _lyricsPlusService;
|
||||||
private readonly LrclibService? _lrclibService;
|
private readonly LrclibService? _lrclibService;
|
||||||
|
private readonly LyricsOrchestrator? _lyricsOrchestrator;
|
||||||
private readonly OdesliService _odesliService;
|
private readonly OdesliService _odesliService;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
@@ -64,7 +66,9 @@ public class JellyfinController : ControllerBase
|
|||||||
PlaylistSyncService? playlistSyncService = null,
|
PlaylistSyncService? playlistSyncService = null,
|
||||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||||
SpotifyLyricsService? spotifyLyricsService = null,
|
SpotifyLyricsService? spotifyLyricsService = null,
|
||||||
LrclibService? lrclibService = null)
|
LyricsPlusService? lyricsPlusService = null,
|
||||||
|
LrclibService? lrclibService = null,
|
||||||
|
LyricsOrchestrator? lyricsOrchestrator = null)
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_spotifySettings = spotifySettings.Value;
|
_spotifySettings = spotifySettings.Value;
|
||||||
@@ -80,7 +84,9 @@ public class JellyfinController : ControllerBase
|
|||||||
_playlistSyncService = playlistSyncService;
|
_playlistSyncService = playlistSyncService;
|
||||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||||
_spotifyLyricsService = spotifyLyricsService;
|
_spotifyLyricsService = spotifyLyricsService;
|
||||||
|
_lyricsPlusService = lyricsPlusService;
|
||||||
_lrclibService = lrclibService;
|
_lrclibService = lrclibService;
|
||||||
|
_lyricsOrchestrator = lyricsOrchestrator;
|
||||||
_odesliService = odesliService;
|
_odesliService = odesliService;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
@@ -111,7 +117,7 @@ public class JellyfinController : ControllerBase
|
|||||||
[FromQuery] bool recursive = true,
|
[FromQuery] bool recursive = true,
|
||||||
string? userId = null)
|
string? userId = null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
|
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
|
||||||
searchTerm, includeItemTypes, parentId, artistIds, userId);
|
searchTerm, includeItemTypes, parentId, artistIds, userId);
|
||||||
|
|
||||||
// Cache search results in Redis only (no file persistence, 15 min TTL)
|
// Cache search results in Redis only (no file persistence, 15 min TTL)
|
||||||
@@ -210,14 +216,14 @@ public class JellyfinController : ControllerBase
|
|||||||
return Unauthorized(new { error = "Authentication required" });
|
return Unauthorized(new { error = "Authentication required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Jellyfin returned {StatusCode}, returning empty result", statusCode);
|
_logger.LogDebug("Jellyfin returned {StatusCode}, returning empty result", statusCode);
|
||||||
return new JsonResult(new { Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
|
return new JsonResult(new { Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Spotify playlist counts if enabled and response contains playlists
|
// Update Spotify playlist counts if enabled and response contains playlists
|
||||||
if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _))
|
if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Browse result has Items, checking for Spotify playlists to update counts");
|
_logger.LogDebug("Browse result has Items, checking for Spotify playlists to update counts");
|
||||||
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
|
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +254,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
var cleanQuery = searchTerm?.Trim().Trim('"') ?? "";
|
var cleanQuery = searchTerm?.Trim().Trim('"') ?? "";
|
||||||
_logger.LogInformation("Performing integrated search for: {Query}", cleanQuery);
|
_logger.LogDebug("Performing integrated search for: {Query}", cleanQuery);
|
||||||
|
|
||||||
// Run local and external searches in parallel
|
// Run local and external searches in parallel
|
||||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||||
@@ -269,7 +275,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var externalResult = await externalTask;
|
var externalResult = await externalTask;
|
||||||
var playlistResult = await playlistTask;
|
var playlistResult = await playlistTask;
|
||||||
|
|
||||||
_logger.LogInformation("Search results: Jellyfin={JellyfinCount}, External Songs={ExtSongs}, Albums={ExtAlbums}, Artists={ExtArtists}, Playlists={Playlists}",
|
_logger.LogDebug("Search results: Jellyfin={JellyfinCount}, External Songs={ExtSongs}, Albums={ExtAlbums}, Artists={ExtArtists}, Playlists={Playlists}",
|
||||||
jellyfinResult != null ? "found" : "null",
|
jellyfinResult != null ? "found" : "null",
|
||||||
externalResult.Songs.Count,
|
externalResult.Songs.Count,
|
||||||
externalResult.Albums.Count,
|
externalResult.Albums.Count,
|
||||||
@@ -279,53 +285,50 @@ public class JellyfinController : ControllerBase
|
|||||||
// Parse Jellyfin results into domain models
|
// Parse Jellyfin results into domain models
|
||||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||||
|
|
||||||
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
|
// Sort all results by match score (local tracks get +10 boost)
|
||||||
// Just interleave local and external results based on which source has better overall match
|
// This ensures best matches appear first regardless of source
|
||||||
|
var allSongs = localSongs.Concat(externalResult.Songs)
|
||||||
// Calculate average match score for each source to determine which should come first
|
.Select(s => new { Song = s, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + (s.IsLocal ? 10.0 : 0.0) })
|
||||||
var localSongsAvgScore = localSongs.Any()
|
.OrderByDescending(x => x.Score)
|
||||||
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
.Select(x => x.Song)
|
||||||
: 0.0;
|
.ToList();
|
||||||
var externalSongsAvgScore = externalResult.Songs.Any()
|
|
||||||
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
var localAlbumsAvgScore = localAlbums.Any()
|
var allAlbums = localAlbums.Concat(externalResult.Albums)
|
||||||
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
.Select(a => new { Album = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + (a.IsLocal ? 10.0 : 0.0) })
|
||||||
: 0.0;
|
.OrderByDescending(x => x.Score)
|
||||||
var externalAlbumsAvgScore = externalResult.Albums.Any()
|
.Select(x => x.Album)
|
||||||
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
.ToList();
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
var localArtistsAvgScore = localArtists.Any()
|
var allArtists = localArtists.Concat(externalResult.Artists)
|
||||||
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
.Select(a => new { Artist = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + (a.IsLocal ? 10.0 : 0.0) })
|
||||||
: 0.0;
|
.OrderByDescending(x => x.Score)
|
||||||
var externalArtistsAvgScore = externalResult.Artists.Any()
|
.Select(x => x.Artist)
|
||||||
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
.ToList();
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
// Interleave results: put better-matching source first, preserve original ordering within each source
|
// Log top results for debugging
|
||||||
var allSongs = localSongsAvgScore >= externalSongsAvgScore
|
|
||||||
? localSongs.Concat(externalResult.Songs).ToList()
|
|
||||||
: externalResult.Songs.Concat(localSongs).ToList();
|
|
||||||
|
|
||||||
var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
|
|
||||||
? localAlbums.Concat(externalResult.Albums).ToList()
|
|
||||||
: externalResult.Albums.Concat(localAlbums).ToList();
|
|
||||||
|
|
||||||
var allArtists = localArtistsAvgScore >= externalArtistsAvgScore
|
|
||||||
? localArtists.Concat(externalResult.Artists).ToList()
|
|
||||||
: externalResult.Artists.Concat(localArtists).ToList();
|
|
||||||
|
|
||||||
// Log results for debugging
|
|
||||||
if (_logger.IsEnabled(LogLevel.Debug))
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
if (allSongs.Any())
|
||||||
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
|
{
|
||||||
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
var topSong = allSongs.First();
|
||||||
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
|
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topSong.Title) + (topSong.IsLocal ? 10.0 : 0.0);
|
||||||
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
_logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal}, score={Score:F2})",
|
||||||
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
|
topSong.Title, topSong.IsLocal, topScore);
|
||||||
|
}
|
||||||
|
if (allAlbums.Any())
|
||||||
|
{
|
||||||
|
var topAlbum = allAlbums.First();
|
||||||
|
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topAlbum.Title) + (topAlbum.IsLocal ? 10.0 : 0.0);
|
||||||
|
_logger.LogDebug("💿 Top album: '{Title}' (local={IsLocal}, score={Score:F2})",
|
||||||
|
topAlbum.Title, topAlbum.IsLocal, topScore);
|
||||||
|
}
|
||||||
|
if (allArtists.Any())
|
||||||
|
{
|
||||||
|
var topArtist = allArtists.First();
|
||||||
|
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topArtist.Name) + (topArtist.IsLocal ? 10.0 : 0.0);
|
||||||
|
_logger.LogDebug("🎤 Top artist: '{Name}' (local={IsLocal}, score={Score:F2})",
|
||||||
|
topArtist.Name, topArtist.IsLocal, topScore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to Jellyfin format
|
// Convert to Jellyfin format
|
||||||
@@ -343,7 +346,7 @@ public class JellyfinController : ControllerBase
|
|||||||
mergedAlbums.AddRange(playlistItems);
|
mergedAlbums.AddRange(playlistItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Merged results (preserving source order): Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
_logger.LogDebug("Merged and sorted results by score: Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||||
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
||||||
|
|
||||||
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
||||||
@@ -374,7 +377,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to pre-fetch lyrics for search results");
|
_logger.LogError(ex, "Failed to pre-fetch lyrics for search results");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -382,28 +385,28 @@ public class JellyfinController : ControllerBase
|
|||||||
// Filter by item types if specified
|
// Filter by item types if specified
|
||||||
var items = new List<Dictionary<string, object?>>();
|
var items = new List<Dictionary<string, object?>>();
|
||||||
|
|
||||||
_logger.LogInformation("Filtering by item types: {ItemTypes}", itemTypes == null ? "null" : string.Join(",", itemTypes));
|
_logger.LogDebug("Filtering by item types: {ItemTypes}", itemTypes == null ? "null" : string.Join(",", itemTypes));
|
||||||
|
|
||||||
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicArtist"))
|
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicArtist"))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Adding {Count} artists to results", mergedArtists.Count);
|
_logger.LogDebug("Adding {Count} artists to results", mergedArtists.Count);
|
||||||
items.AddRange(mergedArtists);
|
items.AddRange(mergedArtists);
|
||||||
}
|
}
|
||||||
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicAlbum") || itemTypes.Contains("Playlist"))
|
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicAlbum") || itemTypes.Contains("Playlist"))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Adding {Count} albums to results", mergedAlbums.Count);
|
_logger.LogDebug("Adding {Count} albums to results", mergedAlbums.Count);
|
||||||
items.AddRange(mergedAlbums);
|
items.AddRange(mergedAlbums);
|
||||||
}
|
}
|
||||||
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio"))
|
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio"))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Adding {Count} songs to results", mergedSongs.Count);
|
_logger.LogDebug("Adding {Count} songs to results", mergedSongs.Count);
|
||||||
items.AddRange(mergedSongs);
|
items.AddRange(mergedSongs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply pagination
|
// Apply pagination
|
||||||
var pagedItems = items.Skip(startIndex).Take(limit).ToList();
|
var pagedItems = items.Skip(startIndex).Take(limit).ToList();
|
||||||
|
|
||||||
_logger.LogInformation("Returning {Count} items (total: {Total})", pagedItems.Count, items.Count);
|
_logger.LogDebug("Returning {Count} items (total: {Total})", pagedItems.Count, items.Count);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -419,11 +422,11 @@ public class JellyfinController : ControllerBase
|
|||||||
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
|
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
|
||||||
{
|
{
|
||||||
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
|
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
|
||||||
await _cache.SetAsync(cacheKey, response, TimeSpan.FromMinutes(15));
|
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
|
||||||
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' (15 min TTL)", searchTerm);
|
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm, CacheExtensions.SearchResultsTTL.TotalMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("About to serialize response...");
|
_logger.LogDebug("About to serialize response...");
|
||||||
|
|
||||||
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
||||||
{
|
{
|
||||||
@@ -612,7 +615,7 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||||
|
|
||||||
_logger.LogInformation("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}",
|
_logger.LogDebug("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}",
|
||||||
provider, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
provider, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
||||||
|
|
||||||
// Check if asking for audio (album tracks)
|
// Check if asking for audio (album tracks)
|
||||||
@@ -633,7 +636,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
|
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
|
||||||
var artist = await _metadataService.GetArtistAsync(provider, externalId);
|
var artist = await _metadataService.GetArtistAsync(provider, externalId);
|
||||||
|
|
||||||
_logger.LogInformation("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
|
_logger.LogDebug("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
|
||||||
|
|
||||||
// Fill artist info
|
// Fill artist info
|
||||||
if (artist != null)
|
if (artist != null)
|
||||||
@@ -664,13 +667,13 @@ public class JellyfinController : ControllerBase
|
|||||||
[FromQuery] int limit = 50,
|
[FromQuery] int limit = 50,
|
||||||
[FromQuery] int startIndex = 0)
|
[FromQuery] int startIndex = 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("GetArtists called: searchTerm={SearchTerm}, limit={Limit}", searchTerm, limit);
|
_logger.LogDebug("GetArtists called: searchTerm={SearchTerm}, limit={Limit}", searchTerm, limit);
|
||||||
|
|
||||||
// If there's a search term, integrate external results
|
// If there's a search term, integrate external results
|
||||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||||
{
|
{
|
||||||
var cleanQuery = searchTerm.Trim().Trim('"');
|
var cleanQuery = searchTerm.Trim().Trim('"');
|
||||||
_logger.LogInformation("Searching artists for: {Query}", cleanQuery);
|
_logger.LogDebug("Searching artists for: {Query}", cleanQuery);
|
||||||
|
|
||||||
// Run local and external searches in parallel
|
// Run local and external searches in parallel
|
||||||
var jellyfinTask = _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers);
|
var jellyfinTask = _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers);
|
||||||
@@ -681,7 +684,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var (jellyfinResult, _) = await jellyfinTask;
|
var (jellyfinResult, _) = await jellyfinTask;
|
||||||
var externalArtists = await externalTask;
|
var externalArtists = await externalTask;
|
||||||
|
|
||||||
_logger.LogInformation("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}",
|
_logger.LogDebug("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}",
|
||||||
jellyfinResult != null ? "found" : "null", externalArtists.Count);
|
jellyfinResult != null ? "found" : "null", externalArtists.Count);
|
||||||
|
|
||||||
// Parse Jellyfin artists
|
// Parse Jellyfin artists
|
||||||
@@ -698,7 +701,7 @@ public class JellyfinController : ControllerBase
|
|||||||
// Show ALL matches (local + external) sorted by best match first
|
// Show ALL matches (local + external) sorted by best match first
|
||||||
var mergedArtists = localArtists.Concat(externalArtists).ToList();
|
var mergedArtists = localArtists.Concat(externalArtists).ToList();
|
||||||
|
|
||||||
_logger.LogInformation("Returning {Count} total artists (local + external, no deduplication)", mergedArtists.Count);
|
_logger.LogDebug("Returning {Count} total artists (local + external, no deduplication)", mergedArtists.Count);
|
||||||
|
|
||||||
// Convert to Jellyfin format
|
// Convert to Jellyfin format
|
||||||
var artistItems = mergedArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
var artistItems = mergedArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
||||||
@@ -963,14 +966,14 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (localPath != null && System.IO.File.Exists(localPath))
|
if (localPath != null && System.IO.File.Exists(localPath))
|
||||||
{
|
{
|
||||||
// Update last access time for cache cleanup
|
// Update last write time for cache cleanup (extends cache lifetime)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow);
|
System.IO.File.SetLastWriteTimeUtc(localPath, DateTime.UtcNow);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to update last access time for {Path}", localPath);
|
_logger.LogError(ex, "Failed to update last write time for {Path}", localPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
var stream = System.IO.File.OpenRead(localPath);
|
var stream = System.IO.File.OpenRead(localPath);
|
||||||
@@ -1103,7 +1106,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch cover art from {Url}", coverUrl);
|
_logger.LogError(ex, "Failed to fetch cover art from {Url}", coverUrl);
|
||||||
// Return placeholder on exception
|
// Return placeholder on exception
|
||||||
return await GetPlaceholderImageAsync();
|
return await GetPlaceholderImageAsync();
|
||||||
}
|
}
|
||||||
@@ -1144,7 +1147,7 @@ public class JellyfinController : ControllerBase
|
|||||||
[HttpGet("Items/{itemId}/Lyrics")]
|
[HttpGet("Items/{itemId}/Lyrics")]
|
||||||
public async Task<IActionResult> GetLyrics(string itemId)
|
public async Task<IActionResult> GetLyrics(string itemId)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🎵 GetLyrics called for itemId: {ItemId}", itemId);
|
_logger.LogDebug("🎵 GetLyrics called for itemId: {ItemId}", itemId);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(itemId))
|
if (string.IsNullOrWhiteSpace(itemId))
|
||||||
{
|
{
|
||||||
@@ -1153,18 +1156,18 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
|
||||||
|
|
||||||
_logger.LogInformation("🎵 Lyrics request: itemId={ItemId}, isExternal={IsExternal}, provider={Provider}, externalId={ExternalId}",
|
_logger.LogDebug("🎵 Lyrics request: itemId={ItemId}, isExternal={IsExternal}, provider={Provider}, externalId={ExternalId}",
|
||||||
itemId, isExternal, provider, externalId);
|
itemId, isExternal, provider, externalId);
|
||||||
|
|
||||||
// For local tracks, check if Jellyfin already has embedded lyrics
|
// For local tracks, check if Jellyfin already has embedded lyrics
|
||||||
if (!isExternal)
|
if (!isExternal)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId);
|
_logger.LogDebug("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId);
|
||||||
|
|
||||||
// Try to get lyrics from Jellyfin first (it reads embedded lyrics from files)
|
// Try to get lyrics from Jellyfin first (it reads embedded lyrics from files)
|
||||||
var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers);
|
var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers);
|
||||||
|
|
||||||
_logger.LogInformation("Jellyfin lyrics check result: statusCode={StatusCode}, hasLyrics={HasLyrics}",
|
_logger.LogDebug("Jellyfin lyrics check result: statusCode={StatusCode}, hasLyrics={HasLyrics}",
|
||||||
statusCode, jellyfinLyrics != null);
|
statusCode, jellyfinLyrics != null);
|
||||||
|
|
||||||
if (jellyfinLyrics != null && statusCode == 200)
|
if (jellyfinLyrics != null && statusCode == 200)
|
||||||
@@ -1173,7 +1176,7 @@ public class JellyfinController : ControllerBase
|
|||||||
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
|
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode);
|
_logger.LogWarning("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get song metadata for lyrics search
|
// Get song metadata for lyrics search
|
||||||
@@ -1197,7 +1200,7 @@ public class JellyfinController : ControllerBase
|
|||||||
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache",
|
_logger.LogDebug("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache",
|
||||||
spotifyTrackId, provider, externalId);
|
spotifyTrackId, provider, externalId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -1225,7 +1228,7 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
|
_logger.LogDebug("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
|
||||||
provider, externalId, spotifyTrackId);
|
provider, externalId, spotifyTrackId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1274,50 +1277,53 @@ public class JellyfinController : ControllerBase
|
|||||||
searchArtists.Add(searchArtist);
|
searchArtists.Add(searchArtist);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use orchestrator for clean, modular lyrics fetching
|
||||||
LyricsInfo? lyrics = null;
|
LyricsInfo? lyrics = null;
|
||||||
|
|
||||||
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
|
if (_lyricsOrchestrator != null)
|
||||||
// Spotify lyrics only work for tracks from injected playlists that have been matched
|
|
||||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
|
||||||
{
|
{
|
||||||
// Validate that this is a real Spotify ID (not spotify:local or other invalid formats)
|
lyrics = await _lyricsOrchestrator.GetLyricsAsync(
|
||||||
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
trackName: searchTitle,
|
||||||
|
artistNames: searchArtists.ToArray(),
|
||||||
// Spotify track IDs are 22 characters, base62 encoded
|
albumName: searchAlbum,
|
||||||
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
|
durationSeconds: song.Duration ?? 0,
|
||||||
{
|
spotifyTrackId: spotifyTrackId);
|
||||||
_logger.LogInformation("Trying Spotify lyrics for track ID: {SpotifyId} ({Artist} - {Title})",
|
|
||||||
cleanSpotifyId, searchArtist, searchTitle);
|
|
||||||
|
|
||||||
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
|
||||||
|
|
||||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
|
|
||||||
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
|
||||||
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
// Fall back to LRCLIB if no Spotify lyrics
|
|
||||||
if (lyrics == null)
|
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
// Fallback to manual fetching if orchestrator not available
|
||||||
string.Join(", ", searchArtists),
|
_logger.LogWarning("LyricsOrchestrator not available, using fallback method");
|
||||||
searchTitle);
|
|
||||||
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
|
||||||
if (lrclibService != null)
|
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||||
{
|
{
|
||||||
lyrics = await lrclibService.GetLyricsAsync(
|
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
||||||
|
|
||||||
|
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
|
||||||
|
{
|
||||||
|
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
||||||
|
|
||||||
|
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||||
|
{
|
||||||
|
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to LyricsPlus
|
||||||
|
if (lyrics == null && _lyricsPlusService != null)
|
||||||
|
{
|
||||||
|
lyrics = await _lyricsPlusService.GetLyricsAsync(
|
||||||
|
searchTitle,
|
||||||
|
searchArtists.ToArray(),
|
||||||
|
searchAlbum,
|
||||||
|
song.Duration ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to LRCLIB
|
||||||
|
if (lyrics == null && _lrclibService != null)
|
||||||
|
{
|
||||||
|
lyrics = await _lrclibService.GetLyricsAsync(
|
||||||
searchTitle,
|
searchTitle,
|
||||||
searchArtists.ToArray(),
|
searchArtists.ToArray(),
|
||||||
searchAlbum,
|
searchAlbum,
|
||||||
@@ -1342,7 +1348,7 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics))
|
if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Parsing synced lyrics (LRC format)");
|
_logger.LogDebug("Parsing synced lyrics (LRC format)");
|
||||||
// Parse LRC format: [mm:ss.xx] text
|
// Parse LRC format: [mm:ss.xx] text
|
||||||
// Skip ID tags like [ar:Artist], [ti:Title], etc.
|
// Skip ID tags like [ar:Artist], [ti:Title], etc.
|
||||||
var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||||
@@ -1370,7 +1376,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
// Skip ID tags like [ar:Artist], [ti:Title], [length:2:23], etc.
|
// Skip ID tags like [ar:Artist], [ti:Title], [length:2:23], etc.
|
||||||
}
|
}
|
||||||
_logger.LogInformation("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count);
|
_logger.LogDebug("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count);
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrEmpty(lyricsText))
|
else if (!string.IsNullOrEmpty(lyricsText))
|
||||||
{
|
{
|
||||||
@@ -1386,7 +1392,7 @@ public class JellyfinController : ControllerBase
|
|||||||
["Text"] = line.Trim()
|
["Text"] = line.Trim()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_logger.LogInformation("Split into {Count} plain lyric lines", lyricLines.Count);
|
_logger.LogDebug("Split into {Count} plain lyric lines", lyricLines.Count);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1411,14 +1417,14 @@ public class JellyfinController : ControllerBase
|
|||||||
Lyrics = lyricLines
|
Lyrics = lyricLines
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogInformation("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced);
|
_logger.LogDebug("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced);
|
||||||
|
|
||||||
// Log a sample of the response for debugging
|
// Log a sample of the response for debugging
|
||||||
if (lyricLines.Count > 0)
|
if (lyricLines.Count > 0)
|
||||||
{
|
{
|
||||||
var sampleLine = lyricLines[0];
|
var sampleLine = lyricLines[0];
|
||||||
var hasStart = sampleLine.ContainsKey("Start");
|
var hasStart = sampleLine.ContainsKey("Start");
|
||||||
_logger.LogInformation("Sample line: Text='{Text}', HasStart={HasStart}",
|
_logger.LogDebug("Sample line: Text='{Text}', HasStart={HasStart}",
|
||||||
sampleLine.GetValueOrDefault("Text"), hasStart);
|
sampleLine.GetValueOrDefault("Text"), hasStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1498,6 +1504,21 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
|
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
|
||||||
|
|
||||||
|
// Use orchestrator for prefetching
|
||||||
|
if (_lyricsOrchestrator != null)
|
||||||
|
{
|
||||||
|
await _lyricsOrchestrator.PrefetchLyricsAsync(
|
||||||
|
trackName: searchTitle,
|
||||||
|
artistNames: searchArtists.ToArray(),
|
||||||
|
albumName: searchAlbum,
|
||||||
|
durationSeconds: song.Duration ?? 0,
|
||||||
|
spotifyTrackId: spotifyTrackId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to manual prefetching if orchestrator not available
|
||||||
|
_logger.LogWarning("LyricsOrchestrator not available for prefetch, using fallback method");
|
||||||
|
|
||||||
// Try Spotify lyrics if we have a valid Spotify track ID
|
// Try Spotify lyrics if we have a valid Spotify track ID
|
||||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||||
{
|
{
|
||||||
@@ -1516,6 +1537,22 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to LyricsPlus
|
||||||
|
if (_lyricsPlusService != null)
|
||||||
|
{
|
||||||
|
var lyrics = await _lyricsPlusService.GetLyricsAsync(
|
||||||
|
searchTitle,
|
||||||
|
searchArtists.ToArray(),
|
||||||
|
searchAlbum,
|
||||||
|
song.Duration ?? 0);
|
||||||
|
|
||||||
|
if (lyrics != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ Prefetched LyricsPlus lyrics for {Artist} - {Title}", searchArtist, searchTitle);
|
||||||
|
return; // Success, lyrics are now cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to LRCLIB
|
// Fall back to LRCLIB
|
||||||
if (_lrclibService != null)
|
if (_lrclibService != null)
|
||||||
{
|
{
|
||||||
@@ -1537,7 +1574,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Error prefetching lyrics for track {ItemId}", itemId);
|
_logger.LogError(ex, "Error prefetching lyrics for track {ItemId}", itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1559,7 +1596,7 @@ public class JellyfinController : ControllerBase
|
|||||||
userId = Request.Query["userId"].ToString();
|
userId = Request.Query["userId"].ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
_logger.LogDebug("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
||||||
userId, itemId, Request.Path);
|
userId, itemId, Request.Path);
|
||||||
|
|
||||||
// Check if this is an external playlist - trigger download
|
// Check if this is an external playlist - trigger download
|
||||||
@@ -1628,7 +1665,7 @@ public class JellyfinController : ControllerBase
|
|||||||
endpoint = $"{endpoint}?userId={userId}";
|
endpoint = $"{endpoint}?userId={userId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
|
_logger.LogDebug("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
|
||||||
|
|
||||||
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
|
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
|
||||||
|
|
||||||
@@ -1649,7 +1686,7 @@ public class JellyfinController : ControllerBase
|
|||||||
userId = Request.Query["userId"].ToString();
|
userId = Request.Query["userId"].ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
_logger.LogDebug("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
||||||
userId, itemId, Request.Path);
|
userId, itemId, Request.Path);
|
||||||
|
|
||||||
// External items - remove from kept folder if it exists
|
// External items - remove from kept folder if it exists
|
||||||
@@ -1686,7 +1723,7 @@ public class JellyfinController : ControllerBase
|
|||||||
endpoint = $"{endpoint}?userId={userId}";
|
endpoint = $"{endpoint}?userId={userId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
|
_logger.LogDebug("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
|
||||||
|
|
||||||
var (result, statusCode) = await _proxyService.DeleteAsync(endpoint, Request.Headers);
|
var (result, statusCode) = await _proxyService.DeleteAsync(endpoint, Request.Headers);
|
||||||
|
|
||||||
@@ -1748,7 +1785,7 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId);
|
_logger.LogDebug("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId);
|
||||||
|
|
||||||
// Check if this is an external playlist (Deezer/Qobuz) first
|
// Check if this is an external playlist (Deezer/Qobuz) first
|
||||||
if (PlaylistIdHelper.IsExternalPlaylist(playlistId))
|
if (PlaylistIdHelper.IsExternalPlaylist(playlistId))
|
||||||
@@ -1788,7 +1825,7 @@ public class JellyfinController : ControllerBase
|
|||||||
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Proxying to Jellyfin: {Endpoint}", endpoint);
|
_logger.LogDebug("Proxying to Jellyfin: {Endpoint}", endpoint);
|
||||||
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||||
|
|
||||||
return HandleProxyResponse(result, statusCode);
|
return HandleProxyResponse(result, statusCode);
|
||||||
@@ -1834,15 +1871,15 @@ public class JellyfinController : ControllerBase
|
|||||||
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
||||||
|
|
||||||
// Cache for 1 hour (playlists can change, so don't cache too long)
|
// Cache for configurable duration (playlists can change)
|
||||||
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
|
await _cache.SetAsync(cacheKey, imageBytes, CacheExtensions.PlaylistImagesTTL);
|
||||||
_logger.LogDebug("Cached playlist image for {PlaylistId}", playlistId);
|
_logger.LogDebug("Cached playlist image for {PlaylistId}", playlistId);
|
||||||
|
|
||||||
return File(imageBytes, contentType);
|
return File(imageBytes, contentType);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to get playlist image {PlaylistId}", playlistId);
|
_logger.LogError(ex, "Failed to get playlist image {PlaylistId}", playlistId);
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1870,7 +1907,7 @@ public class JellyfinController : ControllerBase
|
|||||||
// Reset stream position
|
// Reset stream position
|
||||||
Request.Body.Position = 0;
|
Request.Body.Position = 0;
|
||||||
|
|
||||||
_logger.LogInformation("Authentication request received");
|
_logger.LogDebug("Authentication request received");
|
||||||
// DO NOT log request body or detailed headers - contains password
|
// DO NOT log request body or detailed headers - contains password
|
||||||
|
|
||||||
// Forward to Jellyfin server with client headers - completely transparent proxy
|
// Forward to Jellyfin server with client headers - completely transparent proxy
|
||||||
@@ -1933,14 +1970,14 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to post session capabilities after auth");
|
_logger.LogError(ex, "Failed to post session capabilities after auth");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
|
_logger.LogError("Authentication failed - status {StatusCode}", statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return Jellyfin's exact response
|
// Return Jellyfin's exact response
|
||||||
@@ -2023,7 +2060,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to get similar items for external song {ItemId}", itemId);
|
_logger.LogError(ex, "Failed to get similar items for external song {ItemId}", itemId);
|
||||||
return _responseBuilder.CreateJsonResponse(new
|
return _responseBuilder.CreateJsonResponse(new
|
||||||
{
|
{
|
||||||
Items = Array.Empty<object>(),
|
Items = Array.Empty<object>(),
|
||||||
@@ -2132,7 +2169,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to create instant mix for external song {ItemId}", itemId);
|
_logger.LogError(ex, "Failed to create instant mix for external song {ItemId}", itemId);
|
||||||
return _responseBuilder.CreateJsonResponse(new
|
return _responseBuilder.CreateJsonResponse(new
|
||||||
{
|
{
|
||||||
Items = Array.Empty<object>(),
|
Items = Array.Empty<object>(),
|
||||||
@@ -2212,7 +2249,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
else if (statusCode == 401)
|
else if (statusCode == 401)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("⚠ Jellyfin returned 401 for capabilities (token expired)");
|
_logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -2294,7 +2331,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to prefetch lyrics for external track {ItemId}", itemId);
|
_logger.LogError(ex, "Failed to prefetch lyrics for external track {ItemId}", itemId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2344,7 +2381,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId);
|
_logger.LogError(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2388,7 +2425,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId);
|
_logger.LogError("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -2414,7 +2451,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to send playback start, trying basic");
|
_logger.LogError(ex, "Failed to send playback start, trying basic");
|
||||||
// Fall back to basic playback start
|
// Fall back to basic playback start
|
||||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
|
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
|
||||||
if (statusCode == 204 || statusCode == 200)
|
if (statusCode == 204 || statusCode == 200)
|
||||||
@@ -2427,7 +2464,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to report playback start");
|
_logger.LogError(ex, "Failed to report playback start");
|
||||||
return NoContent(); // Return success anyway to not break playback
|
return NoContent(); // Return success anyway to not break playback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2540,7 +2577,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to report playback progress");
|
_logger.LogError(ex, "Failed to report playback progress");
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2561,7 +2598,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
Request.Body.Position = 0;
|
Request.Body.Position = 0;
|
||||||
|
|
||||||
_logger.LogDebug("⏹️ Playback STOPPED reported");
|
_logger.LogInformation("⏹️ Playback STOPPED reported");
|
||||||
|
|
||||||
// Parse the body to check if it's an external track
|
// Parse the body to check if it's an external track
|
||||||
var doc = JsonDocument.Parse(body);
|
var doc = JsonDocument.Parse(body);
|
||||||
@@ -2657,7 +2694,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
else if (statusCode == 401)
|
else if (statusCode == 401)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Playback stop returned 401 (token expired)");
|
_logger.LogWarning("Playback stop returned 401 (token expired)");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -2668,7 +2705,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to report playback stopped");
|
_logger.LogError(ex, "Failed to report playback stopped");
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2690,7 +2727,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to ping playback session");
|
_logger.LogError(ex, "Failed to ping playback session");
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2778,7 +2815,7 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
LocalAddress = Request.Host.ToString(),
|
LocalAddress = Request.Host.ToString(),
|
||||||
ServerName = serverName ?? "Allstarr",
|
ServerName = serverName ?? "Allstarr",
|
||||||
Version = version ?? "1.0.0",
|
Version = version ?? "1.0.1",
|
||||||
ProductName = "Allstarr (Jellyfin Proxy)",
|
ProductName = "Allstarr (Jellyfin Proxy)",
|
||||||
OperatingSystem = Environment.OSVersion.Platform.ToString(),
|
OperatingSystem = Environment.OSVersion.Platform.ToString(),
|
||||||
Id = _settings.DeviceId,
|
Id = _settings.DeviceId,
|
||||||
@@ -2862,7 +2899,7 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
var playlistId = parts[1];
|
var playlistId = parts[1];
|
||||||
|
|
||||||
_logger.LogInformation("=== PLAYLIST REQUEST ===");
|
_logger.LogDebug("=== PLAYLIST REQUEST ===");
|
||||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||||
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
||||||
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
|
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
|
||||||
@@ -2938,7 +2975,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to proxy binary request for {Path}", path);
|
_logger.LogError(ex, "Failed to proxy binary request for {Path}", path);
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2948,12 +2985,12 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("ProxyRequest intercepting search request: Path={Path}, SearchTerm={SearchTerm}", path, searchTerm);
|
_logger.LogDebug("ProxyRequest intercepting search request: Path={Path}, SearchTerm={SearchTerm}", path, searchTerm);
|
||||||
|
|
||||||
// Item search: /users/{userId}/items or /items
|
// Item search: /users/{userId}/items or /items
|
||||||
if (path.EndsWith("/items", StringComparison.OrdinalIgnoreCase) || path.Equals("items", StringComparison.OrdinalIgnoreCase))
|
if (path.EndsWith("/items", StringComparison.OrdinalIgnoreCase) || path.Equals("items", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Redirecting to SearchItems");
|
_logger.LogDebug("Redirecting to SearchItems");
|
||||||
return await SearchItems(
|
return await SearchItems(
|
||||||
searchTerm: searchTerm,
|
searchTerm: searchTerm,
|
||||||
includeItemTypes: Request.Query["IncludeItemTypes"],
|
includeItemTypes: Request.Query["IncludeItemTypes"],
|
||||||
@@ -2994,7 +3031,7 @@ public class JellyfinController : ControllerBase
|
|||||||
Request.EnableBuffering();
|
Request.EnableBuffering();
|
||||||
|
|
||||||
// Log request details for debugging
|
// Log request details for debugging
|
||||||
_logger.LogInformation("POST request to {Path}: Method={Method}, ContentType={ContentType}, ContentLength={ContentLength}",
|
_logger.LogDebug("POST request to {Path}: Method={Method}, ContentType={ContentType}, ContentLength={ContentLength}",
|
||||||
fullPath, Request.Method, Request.ContentType, Request.ContentLength);
|
fullPath, Request.Method, Request.ContentType, Request.ContentLength);
|
||||||
|
|
||||||
// Read body using StreamReader with proper encoding
|
// Read body using StreamReader with proper encoding
|
||||||
@@ -3018,7 +3055,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation("POST body received from client for {Path}: {BodyLength} bytes, ContentType={ContentType}",
|
_logger.LogDebug("POST body received from client for {Path}: {BodyLength} bytes, ContentType={ContentType}",
|
||||||
fullPath, body.Length, Request.ContentType);
|
fullPath, body.Length, Request.ContentType);
|
||||||
|
|
||||||
// Always log body content for playback endpoints to debug the issue
|
// Always log body content for playback endpoints to debug the issue
|
||||||
@@ -3075,7 +3112,7 @@ public class JellyfinController : ControllerBase
|
|||||||
result.RootElement.ValueKind == JsonValueKind.Object &&
|
result.RootElement.ValueKind == JsonValueKind.Object &&
|
||||||
result.RootElement.TryGetProperty("Items", out var items))
|
result.RootElement.TryGetProperty("Items", out var items))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Response has Items property, checking for Spotify playlists to update counts");
|
_logger.LogDebug("Response has Items property, checking for Spotify playlists to update counts");
|
||||||
result = await UpdateSpotifyPlaylistCounts(result);
|
result = await UpdateSpotifyPlaylistCounts(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3084,7 +3121,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Proxy request failed for {Path}", path);
|
_logger.LogError(ex, "Proxy request failed for {Path}", path);
|
||||||
return _responseBuilder.CreateError(502, $"Proxy error: {ex.Message}");
|
return _responseBuilder.CreateError(502, $"Proxy error: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3146,7 +3183,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var modified = false;
|
var modified = false;
|
||||||
var updatedItems = new List<Dictionary<string, object>>();
|
var updatedItems = new List<Dictionary<string, object>>();
|
||||||
|
|
||||||
_logger.LogInformation("Checking {Count} items for Spotify playlists", itemsArray.Count);
|
_logger.LogDebug("Checking {Count} items for Spotify playlists", itemsArray.Count);
|
||||||
|
|
||||||
foreach (var item in itemsArray)
|
foreach (var item in itemsArray)
|
||||||
{
|
{
|
||||||
@@ -3179,7 +3216,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
||||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||||
|
|
||||||
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
|
_logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks",
|
||||||
matchedTracksKey, matchedTracks?.Count ?? 0);
|
matchedTracksKey, matchedTracks?.Count ?? 0);
|
||||||
|
|
||||||
// Fallback to legacy cache format
|
// Fallback to legacy cache format
|
||||||
@@ -3204,7 +3241,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
|
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
|
||||||
if (fileItems != null && fileItems.Count > 0)
|
if (fileItems != null && fileItems.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count);
|
_logger.LogDebug("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count);
|
||||||
// Use file cache count directly
|
// Use file cache count directly
|
||||||
itemDict["ChildCount"] = fileItems.Count;
|
itemDict["ChildCount"] = fileItems.Count;
|
||||||
modified = true;
|
modified = true;
|
||||||
@@ -3237,13 +3274,13 @@ public class JellyfinController : ControllerBase
|
|||||||
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
||||||
{
|
{
|
||||||
localTracksCount = localItems.GetArrayLength();
|
localTracksCount = localItems.GetArrayLength();
|
||||||
_logger.LogInformation("Found {Count} total items in Jellyfin playlist {Name}",
|
_logger.LogDebug("Found {Count} total items in Jellyfin playlist {Name}",
|
||||||
localTracksCount, playlistName);
|
localTracksCount, playlistName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to get local tracks count for {Name}", playlistName);
|
_logger.LogError(ex, "Failed to get local tracks count for {Name}", playlistName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count external matched tracks (not local)
|
// Count external matched tracks (not local)
|
||||||
@@ -3262,7 +3299,7 @@ public class JellyfinController : ControllerBase
|
|||||||
// Update ChildCount to show actual available tracks
|
// Update ChildCount to show actual available tracks
|
||||||
itemDict["ChildCount"] = totalAvailableCount;
|
itemDict["ChildCount"] = totalAvailableCount;
|
||||||
modified = true;
|
modified = true;
|
||||||
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
|
_logger.LogDebug("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
|
||||||
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
|
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -3288,7 +3325,7 @@ public class JellyfinController : ControllerBase
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Modified {Count} Spotify playlists, rebuilding response",
|
_logger.LogDebug("Modified {Count} Spotify playlists, rebuilding response",
|
||||||
updatedItems.Count(i => i.ContainsKey("ChildCount")));
|
updatedItems.Count(i => i.ContainsKey("ChildCount")));
|
||||||
|
|
||||||
// Rebuild the response with updated items
|
// Rebuild the response with updated items
|
||||||
@@ -3304,7 +3341,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to update Spotify playlist counts");
|
_logger.LogError(ex, "Failed to update Spotify playlist counts");
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3336,7 +3373,7 @@ public class JellyfinController : ControllerBase
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Don't let logging failures break the request
|
// Don't let logging failures break the request
|
||||||
_logger.LogDebug(ex, "Failed to log endpoint usage");
|
_logger.LogError(ex, "Failed to log endpoint usage");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3460,26 +3497,26 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// New mode: Gets playlist tracks with correct ordering using direct Spotify API data.
|
/// New mode: Gets playlist tracks with correct ordering using direct Spotify API data.
|
||||||
|
/// Optimized to only re-match when Jellyfin playlist changes (cheap check).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
|
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
|
||||||
{
|
{
|
||||||
// Check Redis cache first for fast serving
|
// Check if Jellyfin playlist has changed (cheap API call)
|
||||||
|
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}";
|
||||||
|
var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
|
||||||
|
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
|
||||||
|
|
||||||
|
var jellyfinPlaylistChanged = cachedJellyfinSignature != currentJellyfinSignature;
|
||||||
|
|
||||||
|
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
|
||||||
var cacheKey = $"spotify:playlist:items:{spotifyPlaylistName}";
|
var cacheKey = $"spotify:playlist:items:{spotifyPlaylistName}";
|
||||||
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
|
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
|
||||||
|
|
||||||
if (cachedItems != null && cachedItems.Count > 0)
|
if (cachedItems != null && cachedItems.Count > 0 && !jellyfinPlaylistChanged)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✅ Loaded {Count} playlist items from Redis cache for {Playlist}",
|
_logger.LogDebug("✅ Loaded {Count} playlist items from Redis cache for {Playlist} (Jellyfin unchanged)",
|
||||||
cachedItems.Count, spotifyPlaylistName);
|
cachedItems.Count, spotifyPlaylistName);
|
||||||
|
|
||||||
// Log sample item to verify Spotify IDs are present
|
|
||||||
if (cachedItems.Count > 0 && cachedItems[0].ContainsKey("ProviderIds"))
|
|
||||||
{
|
|
||||||
var providerIds = cachedItems[0]["ProviderIds"] as Dictionary<string, object>;
|
|
||||||
var hasSpotifyId = providerIds?.ContainsKey("Spotify") ?? false;
|
|
||||||
_logger.LogDebug("Sample cached item has Spotify ID: {HasSpotifyId}", hasSpotifyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResult(new
|
return new JsonResult(new
|
||||||
{
|
{
|
||||||
Items = cachedItems,
|
Items = cachedItems,
|
||||||
@@ -3488,15 +3525,20 @@ public class JellyfinController : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (jellyfinPlaylistChanged)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🔄 Jellyfin playlist changed for {Playlist} - re-matching tracks", spotifyPlaylistName);
|
||||||
|
}
|
||||||
|
|
||||||
// Check file cache as fallback
|
// Check file cache as fallback
|
||||||
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
|
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
|
||||||
if (fileItems != null && fileItems.Count > 0)
|
if (fileItems != null && fileItems.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✅ Loaded {Count} playlist items from file cache for {Playlist}",
|
_logger.LogDebug("✅ Loaded {Count} playlist items from file cache for {Playlist}",
|
||||||
fileItems.Count, spotifyPlaylistName);
|
fileItems.Count, spotifyPlaylistName);
|
||||||
|
|
||||||
// Restore to Redis cache
|
// Restore to Redis cache
|
||||||
await _cache.SetAsync(cacheKey, fileItems, TimeSpan.FromHours(24));
|
await _cache.SetAsync(cacheKey, fileItems, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||||
|
|
||||||
return new JsonResult(new
|
return new JsonResult(new
|
||||||
{
|
{
|
||||||
@@ -3512,12 +3554,12 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (orderedTracks == null || orderedTracks.Count == 0)
|
if (orderedTracks == null || orderedTracks.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
|
_logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
|
||||||
spotifyPlaylistName);
|
spotifyPlaylistName);
|
||||||
return null; // Fall back to legacy mode
|
return null; // Fall back to legacy mode
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
_logger.LogInformation("Using {Count} ordered matched tracks for {Playlist}",
|
||||||
orderedTracks.Count, spotifyPlaylistName);
|
orderedTracks.Count, spotifyPlaylistName);
|
||||||
|
|
||||||
// Get existing Jellyfin playlist items (RAW - don't convert!)
|
// Get existing Jellyfin playlist items (RAW - don't convert!)
|
||||||
@@ -3529,8 +3571,17 @@ public class JellyfinController : ControllerBase
|
|||||||
return null; // Fall back to legacy mode
|
return null; // Fall back to legacy mode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request MediaSources field to get bitrate info
|
// Pass through all requested fields from the original request
|
||||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
|
var queryString = Request.QueryString.Value ?? "";
|
||||||
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
|
||||||
|
|
||||||
|
// Append the original query string (which includes Fields parameter)
|
||||||
|
if (!string.IsNullOrEmpty(queryString))
|
||||||
|
{
|
||||||
|
// Remove the leading ? if present
|
||||||
|
queryString = queryString.TrimStart('?');
|
||||||
|
playlistItemsUrl = $"{playlistItemsUrl}&{queryString}";
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
||||||
playlistId, userId);
|
playlistId, userId);
|
||||||
@@ -3597,7 +3648,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var localUsedCount = 0;
|
var localUsedCount = 0;
|
||||||
var externalUsedCount = 0;
|
var externalUsedCount = 0;
|
||||||
|
|
||||||
_logger.LogInformation("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
|
_logger.LogDebug("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
|
||||||
|
|
||||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||||
{
|
{
|
||||||
@@ -3681,15 +3732,17 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogDebug("🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||||
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
|
||||||
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
|
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
|
||||||
|
|
||||||
// Save to file cache for persistence across restarts
|
// Save to file cache for persistence across restarts
|
||||||
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
||||||
|
|
||||||
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
|
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
|
||||||
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||||
|
|
||||||
|
// Cache the Jellyfin playlist signature to detect future changes
|
||||||
|
await _cache.SetAsync(jellyfinSignatureCacheKey, currentJellyfinSignature, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||||
|
|
||||||
// Return raw Jellyfin response format
|
// Return raw Jellyfin response format
|
||||||
return new JsonResult(new
|
return new JsonResult(new
|
||||||
@@ -3710,7 +3763,7 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (cachedTracks != null && cachedTracks.Count > 0)
|
if (cachedTracks != null && cachedTracks.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Returning {Count} cached matched tracks from Redis for {Playlist}",
|
_logger.LogInformation("Returning {Count} cached matched tracks from Redis for {Playlist}",
|
||||||
cachedTracks.Count, spotifyPlaylistName);
|
cachedTracks.Count, spotifyPlaylistName);
|
||||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||||
}
|
}
|
||||||
@@ -3721,8 +3774,8 @@ public class JellyfinController : ControllerBase
|
|||||||
cachedTracks = await LoadMatchedTracksFromFile(spotifyPlaylistName);
|
cachedTracks = await LoadMatchedTracksFromFile(spotifyPlaylistName);
|
||||||
if (cachedTracks != null && cachedTracks.Count > 0)
|
if (cachedTracks != null && cachedTracks.Count > 0)
|
||||||
{
|
{
|
||||||
// Restore to Redis with 1 hour TTL
|
// Restore to Redis with configurable TTL
|
||||||
await _cache.SetAsync(cacheKey, cachedTracks, TimeSpan.FromHours(1));
|
await _cache.SetAsync(cacheKey, cachedTracks, CacheExtensions.SpotifyMatchedTracksTTL);
|
||||||
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist}",
|
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist}",
|
||||||
cachedTracks.Count, spotifyPlaylistName);
|
cachedTracks.Count, spotifyPlaylistName);
|
||||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||||
@@ -3739,7 +3792,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks");
|
_logger.LogInformation("No UserId configured - may not be able to fetch existing playlist tracks");
|
||||||
}
|
}
|
||||||
|
|
||||||
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
|
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
|
||||||
@@ -3784,7 +3837,7 @@ public class JellyfinController : ControllerBase
|
|||||||
if (missingTracks != null && missingTracks.Count > 0)
|
if (missingTracks != null && missingTracks.Count > 0)
|
||||||
{
|
{
|
||||||
await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365));
|
await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365));
|
||||||
_logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)",
|
_logger.LogDebug("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)",
|
||||||
missingTracks.Count, spotifyPlaylistName);
|
missingTracks.Count, spotifyPlaylistName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3796,7 +3849,7 @@ public class JellyfinController : ControllerBase
|
|||||||
return _responseBuilder.CreateItemsResponse(existingTracks);
|
return _responseBuilder.CreateItemsResponse(existingTracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Matching {Count} missing tracks for {Playlist}",
|
_logger.LogDebug("Matching {Count} missing tracks for {Playlist}",
|
||||||
missingTracks.Count, spotifyPlaylistName);
|
missingTracks.Count, spotifyPlaylistName);
|
||||||
|
|
||||||
// Match missing tracks sequentially with rate limiting (excluding ones we already have locally)
|
// Match missing tracks sequentially with rate limiting (excluding ones we already have locally)
|
||||||
@@ -3855,7 +3908,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
_logger.LogError(ex, "Failed to match track: {Title} - {Artist}",
|
||||||
track.Title, track.PrimaryArtist);
|
track.Title, track.PrimaryArtist);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3879,7 +3932,7 @@ public class JellyfinController : ControllerBase
|
|||||||
finalTracks.AddRange(existingTracks);
|
finalTracks.AddRange(existingTracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
await _cache.SetAsync(cacheKey, finalTracks, CacheExtensions.SpotifyMatchedTracksTTL);
|
||||||
|
|
||||||
// Also save to file cache for persistence across restarts
|
// Also save to file cache for persistence across restarts
|
||||||
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
|
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
|
||||||
@@ -3948,7 +4001,7 @@ public class JellyfinController : ControllerBase
|
|||||||
if (cacheFiles.Length > 0)
|
if (cacheFiles.Length > 0)
|
||||||
{
|
{
|
||||||
sourceFilePath = cacheFiles[0];
|
sourceFilePath = cacheFiles[0];
|
||||||
_logger.LogInformation("Found track in cache folder: {Path}", sourceFilePath);
|
_logger.LogDebug("Found track in cache folder: {Path}", sourceFilePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3962,7 +4015,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
|
_logger.LogError(ex, "Failed to download track {ItemId}", itemId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3983,7 +4036,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
|
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
|
||||||
_logger.LogInformation("✓ Copied track to kept folder: {Path}", keptFilePath);
|
_logger.LogDebug("✓ Copied track to kept folder: {Path}", keptFilePath);
|
||||||
|
|
||||||
// Also copy cover art if it exists
|
// Also copy cover art if it exists
|
||||||
var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
|
var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
|
||||||
@@ -4044,7 +4097,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to check favorite status for {ItemId}", itemId);
|
_logger.LogError(ex, "Failed to check favorite status for {ItemId}", itemId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4083,7 +4136,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to mark track as favorited: {ItemId}", itemId);
|
_logger.LogError(ex, "Failed to mark track as favorited: {ItemId}", itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4109,7 +4162,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to remove track from favorites: {ItemId}", itemId);
|
_logger.LogError(ex, "Failed to remove track from favorites: {ItemId}", itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4145,7 +4198,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to mark track for deletion: {ItemId}", itemId);
|
_logger.LogError(ex, "Failed to mark track for deletion: {ItemId}", itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4190,7 +4243,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var updatedJson = JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
|
var updatedJson = JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
|
||||||
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
|
||||||
|
|
||||||
_logger.LogInformation("Processed {Count} pending deletions", toDelete.Count);
|
_logger.LogDebug("Processed {Count} pending deletions", toDelete.Count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -4224,7 +4277,7 @@ public class JellyfinController : ControllerBase
|
|||||||
foreach (var trackFile in trackFiles)
|
foreach (var trackFile in trackFiles)
|
||||||
{
|
{
|
||||||
System.IO.File.Delete(trackFile);
|
System.IO.File.Delete(trackFile);
|
||||||
_logger.LogInformation("✓ Deleted track from kept folder: {Path}", trackFile);
|
_logger.LogDebug("✓ Deleted track from kept folder: {Path}", trackFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up empty directories
|
// Clean up empty directories
|
||||||
@@ -4242,7 +4295,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to delete track {ItemId}", itemId);
|
_logger.LogError(ex, "Failed to delete track {ItemId}", itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4271,14 +4324,14 @@ public class JellyfinController : ControllerBase
|
|||||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||||
var tracks = JsonSerializer.Deserialize<List<allstarr.Models.Spotify.MissingTrack>>(json);
|
var tracks = JsonSerializer.Deserialize<List<allstarr.Models.Spotify.MissingTrack>>(json);
|
||||||
|
|
||||||
_logger.LogInformation("Loaded {Count} missing tracks from file cache for {Playlist} (age: {Age:F1}h)",
|
_logger.LogDebug("Loaded {Count} missing tracks from file cache for {Playlist} (age: {Age:F1}h)",
|
||||||
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
|
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
|
||||||
|
|
||||||
return tracks;
|
return tracks;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to load missing tracks from file for {Playlist}", playlistName);
|
_logger.LogError(ex, "Failed to load missing tracks from file for {Playlist}", playlistName);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4295,7 +4348,7 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (!System.IO.File.Exists(filePath))
|
if (!System.IO.File.Exists(filePath))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath);
|
_logger.LogInformation("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4309,7 +4362,7 @@ public class JellyfinController : ControllerBase
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
|
_logger.LogInformation("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
|
||||||
|
|
||||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||||
var tracks = JsonSerializer.Deserialize<List<Song>>(json);
|
var tracks = JsonSerializer.Deserialize<List<Song>>(json);
|
||||||
@@ -4321,7 +4374,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to load matched tracks from file for {Playlist}", playlistName);
|
_logger.LogError(ex, "Failed to load matched tracks from file for {Playlist}", playlistName);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4351,6 +4404,54 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a signature (hash) of the Jellyfin playlist to detect changes.
|
||||||
|
/// This is a cheap operation compared to re-matching all tracks.
|
||||||
|
/// Signature includes: track count + concatenated track IDs.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> GetJellyfinPlaylistSignatureAsync(string playlistId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userId = _settings.UserId;
|
||||||
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
playlistItemsUrl += $"&UserId={userId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var (response, _) = await _proxyService.GetJsonAsync(playlistItemsUrl, null, Request.Headers);
|
||||||
|
|
||||||
|
if (response != null && response.RootElement.TryGetProperty("Items", out var items))
|
||||||
|
{
|
||||||
|
var trackIds = new List<string>();
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (item.TryGetProperty("Id", out var idEl))
|
||||||
|
{
|
||||||
|
trackIds.Add(idEl.GetString() ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create signature: count + sorted IDs (sorted for consistency)
|
||||||
|
trackIds.Sort();
|
||||||
|
var signature = $"{trackIds.Count}:{string.Join(",", trackIds)}";
|
||||||
|
|
||||||
|
// Hash it to keep it compact
|
||||||
|
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||||
|
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(signature));
|
||||||
|
return Convert.ToHexString(hashBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to get Jellyfin playlist signature for {PlaylistId}", playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return empty string if failed (will trigger re-match)
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -4367,7 +4468,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||||
|
|
||||||
_logger.LogInformation("💾 Saved {Count} playlist items to file cache for {Playlist}",
|
_logger.LogDebug("💾 Saved {Count} playlist items to file cache for {Playlist}",
|
||||||
items.Count, playlistName);
|
items.Count, playlistName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -4397,7 +4498,7 @@ public class JellyfinController : ControllerBase
|
|||||||
// Check if cache is too old (more than 24 hours)
|
// Check if cache is too old (more than 24 hours)
|
||||||
if (fileAge.TotalHours > 24)
|
if (fileAge.TotalHours > 24)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
|
_logger.LogDebug("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
|
||||||
playlistName, fileAge.TotalHours);
|
playlistName, fileAge.TotalHours);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -4407,14 +4508,14 @@ public class JellyfinController : ControllerBase
|
|||||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||||
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
|
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
|
||||||
|
|
||||||
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
|
_logger.LogDebug("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
|
||||||
items?.Count ?? 0, playlistName, fileAge.TotalHours);
|
items?.Count ?? 0, playlistName, fileAge.TotalHours);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
|
_logger.LogError(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4571,7 +4672,7 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Error finding Spotify ID for external track");
|
_logger.LogError(ex, "Error finding Spotify ID for external track");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,14 +145,14 @@ public class SubsonicController : ControllerBase
|
|||||||
|
|
||||||
if (localPath != null && System.IO.File.Exists(localPath))
|
if (localPath != null && System.IO.File.Exists(localPath))
|
||||||
{
|
{
|
||||||
// Update last access time for cache cleanup
|
// Update last write time for cache cleanup (extends cache lifetime)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow);
|
System.IO.File.SetLastWriteTimeUtc(localPath, DateTime.UtcNow);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to update last access time for {Path}", localPath);
|
_logger.LogError(ex, "Failed to update last write time for {Path}", localPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
var stream = System.IO.File.OpenRead(localPath);
|
var stream = System.IO.File.OpenRead(localPath);
|
||||||
@@ -590,8 +590,8 @@ public class SubsonicController : ControllerBase
|
|||||||
var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync();
|
var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync();
|
||||||
var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
||||||
|
|
||||||
// Cache for 1 hour (playlists can change, so don't cache too long)
|
// Cache for configurable duration (playlists can change)
|
||||||
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
|
await _cache.SetAsync(cacheKey, imageBytes, CacheExtensions.PlaylistImagesTTL);
|
||||||
_logger.LogDebug("Cached playlist cover art for {Id}", id);
|
_logger.LogDebug("Cached playlist cover art for {Id}", id);
|
||||||
|
|
||||||
return File(imageBytes, contentType);
|
return File(imageBytes, contentType);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ public class ApiKeyAuthFilter : IAsyncActionFilter
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("API key authentication successful for {Path}", request.Path);
|
_logger.LogInformation("API key authentication successful for {Path}", request.Path);
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class WebSocketProxyMiddleware
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
|
|
||||||
_logger.LogDebug("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
|
_logger.LogInformation("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
@@ -139,10 +139,10 @@ public class WebSocketProxyMiddleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set user agent
|
// Set user agent
|
||||||
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
|
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0.1");
|
||||||
|
|
||||||
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
||||||
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
||||||
|
|
||||||
// Start bidirectional proxying
|
// Start bidirectional proxying
|
||||||
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
||||||
@@ -158,7 +158,7 @@ public class WebSocketProxyMiddleware
|
|||||||
// 403 is expected when tokens expire or session ends - don't spam logs
|
// 403 is expected when tokens expire or session ends - don't spam logs
|
||||||
if (wsEx.Message.Contains("403"))
|
if (wsEx.Message.Contains("403"))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
|
_logger.LogWarning("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -180,7 +180,7 @@ public class WebSocketProxyMiddleware
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Error closing client WebSocket");
|
_logger.LogError(ex, "Error closing client WebSocket");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ public class WebSocketProxyMiddleware
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Error closing server WebSocket");
|
_logger.LogError(ex, "Error closing server WebSocket");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ public class WebSocketProxyMiddleware
|
|||||||
// CRITICAL: Notify session manager that client disconnected
|
// CRITICAL: Notify session manager that client disconnected
|
||||||
if (!string.IsNullOrEmpty(deviceId))
|
if (!string.IsNullOrEmpty(deviceId))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
||||||
await _sessionManager.RemoveSessionAsync(deviceId);
|
await _sessionManager.RemoveSessionAsync(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ public class WebSocketProxyMiddleware
|
|||||||
if (direction == "Server→Client")
|
if (direction == "Server→Client")
|
||||||
{
|
{
|
||||||
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
|
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
|
||||||
_logger.LogTrace("📥 WEBSOCKET {Direction}: {Preview}",
|
_logger.LogDebug("📥 WEBSOCKET {Direction}: {Preview}",
|
||||||
direction,
|
direction,
|
||||||
messageText.Length > 500 ? messageText[..500] + "..." : messageText);
|
messageText.Length > 500 ? messageText[..500] + "..." : messageText);
|
||||||
}
|
}
|
||||||
@@ -282,7 +282,7 @@ public class WebSocketProxyMiddleware
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction);
|
_logger.LogError(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ public class Song
|
|||||||
/// All artists for this track (main + featured). For display in Jellyfin clients.
|
/// All artists for this track (main + featured). For display in Jellyfin clients.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> Artists { get; set; } = new();
|
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();
|
||||||
|
|
||||||
public string Album { get; set; } = string.Empty;
|
public string Album { get; set; } = string.Empty;
|
||||||
public string? AlbumId { get; set; }
|
public string? AlbumId { get; set; }
|
||||||
public int? Duration { get; set; } // In seconds
|
public int? Duration { get; set; } // In seconds
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
namespace allstarr.Models.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cache TTL (Time To Live) settings for various data types.
|
||||||
|
/// All values are configurable via Web UI and require restart to apply.
|
||||||
|
/// </summary>
|
||||||
|
public class CacheSettings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Search results cache duration in minutes.
|
||||||
|
/// Default: 120 minutes (2 hours)
|
||||||
|
/// </summary>
|
||||||
|
public int SearchResultsMinutes { get; set; } = 120;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playlist cover images cache duration in hours.
|
||||||
|
/// Default: 168 hours (1 week)
|
||||||
|
/// </summary>
|
||||||
|
public int PlaylistImagesHours { get; set; } = 168;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spotify playlist items cache duration in hours.
|
||||||
|
/// Default: 168 hours (1 week, until next cron job)
|
||||||
|
/// </summary>
|
||||||
|
public int SpotifyPlaylistItemsHours { get; set; } = 168;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spotify matched tracks cache duration in days.
|
||||||
|
/// This is the mapping of Spotify IDs to local/external tracks.
|
||||||
|
/// Default: 30 days
|
||||||
|
/// </summary>
|
||||||
|
public int SpotifyMatchedTracksDays { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lyrics cache duration in days.
|
||||||
|
/// Default: 14 days (2 weeks)
|
||||||
|
/// </summary>
|
||||||
|
public int LyricsDays { get; set; } = 14;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Genre data cache duration in days.
|
||||||
|
/// Default: 30 days
|
||||||
|
/// </summary>
|
||||||
|
public int GenreDays { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// External metadata (SquidWTF albums/artists) cache duration in days.
|
||||||
|
/// Default: 7 days
|
||||||
|
/// </summary>
|
||||||
|
public int MetadataDays { get; set; } = 7;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Odesli Spotify ID lookup cache duration in days.
|
||||||
|
/// Default: 60 days
|
||||||
|
/// </summary>
|
||||||
|
public int OdesliLookupDays { get; set; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Jellyfin proxy images cache duration in days.
|
||||||
|
/// Default: 14 days (2 weeks)
|
||||||
|
/// </summary>
|
||||||
|
public int ProxyImagesDays { get; set; } = 14;
|
||||||
|
|
||||||
|
// Helper methods to get TimeSpan values
|
||||||
|
public TimeSpan SearchResultsTTL => TimeSpan.FromMinutes(SearchResultsMinutes);
|
||||||
|
public TimeSpan PlaylistImagesTTL => TimeSpan.FromHours(PlaylistImagesHours);
|
||||||
|
public TimeSpan SpotifyPlaylistItemsTTL => TimeSpan.FromHours(SpotifyPlaylistItemsHours);
|
||||||
|
public TimeSpan SpotifyMatchedTracksTTL => TimeSpan.FromDays(SpotifyMatchedTracksDays);
|
||||||
|
public TimeSpan LyricsTTL => TimeSpan.FromDays(LyricsDays);
|
||||||
|
public TimeSpan GenreTTL => TimeSpan.FromDays(GenreDays);
|
||||||
|
public TimeSpan MetadataTTL => TimeSpan.FromDays(MetadataDays);
|
||||||
|
public TimeSpan OdesliLookupTTL => TimeSpan.FromDays(OdesliLookupDays);
|
||||||
|
public TimeSpan ProxyImagesTTL => TimeSpan.FromDays(ProxyImagesDays);
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ public class JellyfinSettings
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Client version reported to Jellyfin
|
/// Client version reported to Jellyfin
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ClientVersion { get; set; } = "1.0.0";
|
public string ClientVersion { get; set; } = "1.0.1";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Device ID reported to Jellyfin
|
/// Device ID reported to Jellyfin
|
||||||
|
|||||||
@@ -18,18 +18,6 @@ public class SpotifyApiSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Spotify Client ID from https://developer.spotify.com/dashboard
|
|
||||||
/// Used for OAuth token refresh and API access.
|
|
||||||
/// </summary>
|
|
||||||
public string ClientId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Spotify Client Secret from https://developer.spotify.com/dashboard
|
|
||||||
/// Optional - only needed for certain OAuth flows.
|
|
||||||
/// </summary>
|
|
||||||
public string ClientSecret { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Spotify session cookie (sp_dc).
|
/// Spotify session cookie (sp_dc).
|
||||||
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.
|
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ public class SpotifyPlaylistConfig
|
|||||||
/// Where to position local tracks: "first" or "last"
|
/// Where to position local tracks: "first" or "last"
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ namespace allstarr.Models.Settings;
|
|||||||
public class SquidWTFSettings
|
public class SquidWTFSettings
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// No user auth should be needed for this site.
|
/// Preferred audio quality:
|
||||||
/// </summary>
|
/// - HI_RES or HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest quality)
|
||||||
|
/// - FLAC or LOSSLESS: 16-bit/44.1kHz FLAC (CD quality, default)
|
||||||
/// <summary>
|
/// - HIGH: 320kbps AAC (high quality, smaller files)
|
||||||
/// Preferred audio quality: FLAC, MP3_320, MP3_128
|
/// - LOW: 96kbps AAC (low quality, smallest files)
|
||||||
/// If not specified or unavailable, the highest available quality will be used.
|
/// If not specified or unavailable, LOSSLESS will be used.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Quality { get; set; }
|
public string? Quality { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-14
@@ -13,9 +13,28 @@ using allstarr.Middleware;
|
|||||||
using allstarr.Filters;
|
using allstarr.Filters;
|
||||||
using Microsoft.Extensions.Http;
|
using Microsoft.Extensions.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
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
|
// Decode SquidWTF API base URLs once at startup
|
||||||
var squidWtfApiUrls = DecodeSquidWtfUrls();
|
var squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||||
static List<string> DecodeSquidWtfUrls()
|
static List<string> DecodeSquidWtfUrls()
|
||||||
@@ -131,6 +150,8 @@ builder.Services.Configure<SquidWTFSettings>(
|
|||||||
builder.Configuration.GetSection("SquidWTF"));
|
builder.Configuration.GetSection("SquidWTF"));
|
||||||
builder.Services.Configure<RedisSettings>(
|
builder.Services.Configure<RedisSettings>(
|
||||||
builder.Configuration.GetSection("Redis"));
|
builder.Configuration.GetSection("Redis"));
|
||||||
|
builder.Services.Configure<CacheSettings>(
|
||||||
|
builder.Configuration.GetSection("Cache"));
|
||||||
// Configure Spotify Import settings with custom playlist parsing from env var
|
// Configure Spotify Import settings with custom playlist parsing from env var
|
||||||
builder.Services.Configure<SpotifyImportSettings>(options =>
|
builder.Services.Configure<SpotifyImportSettings>(options =>
|
||||||
{
|
{
|
||||||
@@ -454,7 +475,8 @@ else if (musicService == MusicService.SquidWTF)
|
|||||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||||
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
|
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
|
||||||
sp.GetRequiredService<RedisCacheService>(),
|
sp.GetRequiredService<RedisCacheService>(),
|
||||||
squidWtfApiUrls));
|
squidWtfApiUrls,
|
||||||
|
sp.GetRequiredService<GenreEnrichmentService>()));
|
||||||
builder.Services.AddSingleton<IDownloadService>(sp =>
|
builder.Services.AddSingleton<IDownloadService>(sp =>
|
||||||
new SquidWTFDownloadService(
|
new SquidWTFDownloadService(
|
||||||
sp.GetRequiredService<IHttpClientFactory>(),
|
sp.GetRequiredService<IHttpClientFactory>(),
|
||||||
@@ -505,6 +527,9 @@ builder.Services.AddHostedService<CacheCleanupService>();
|
|||||||
// Register cache warming service (loads file caches into Redis on startup)
|
// Register cache warming service (loads file caches into Redis on startup)
|
||||||
builder.Services.AddHostedService<CacheWarmingService>();
|
builder.Services.AddHostedService<CacheWarmingService>();
|
||||||
|
|
||||||
|
// Register Redis persistence service (snapshots Redis to files periodically)
|
||||||
|
builder.Services.AddHostedService<RedisPersistenceService>();
|
||||||
|
|
||||||
// Register Spotify API client, lyrics service, and settings for direct API access
|
// Register Spotify API client, lyrics service, and settings for direct API access
|
||||||
// Configure from environment variables with SPOTIFY_API_ prefix
|
// Configure from environment variables with SPOTIFY_API_ prefix
|
||||||
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
|
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
|
||||||
@@ -518,18 +543,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
|
|||||||
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientId = builder.Configuration.GetValue<string>("SpotifyApi:ClientId");
|
|
||||||
if (!string.IsNullOrEmpty(clientId))
|
|
||||||
{
|
|
||||||
options.ClientId = clientId;
|
|
||||||
}
|
|
||||||
|
|
||||||
var clientSecret = builder.Configuration.GetValue<string>("SpotifyApi:ClientSecret");
|
|
||||||
if (!string.IsNullOrEmpty(clientSecret))
|
|
||||||
{
|
|
||||||
options.ClientSecret = clientSecret;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
|
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
|
||||||
if (!string.IsNullOrEmpty(sessionCookie))
|
if (!string.IsNullOrEmpty(sessionCookie))
|
||||||
{
|
{
|
||||||
@@ -557,7 +570,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
|
|||||||
// Log configuration (mask sensitive values)
|
// Log configuration (mask sensitive values)
|
||||||
Console.WriteLine($"SpotifyApi Configuration:");
|
Console.WriteLine($"SpotifyApi Configuration:");
|
||||||
Console.WriteLine($" Enabled: {options.Enabled}");
|
Console.WriteLine($" Enabled: {options.Enabled}");
|
||||||
Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}");
|
|
||||||
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
|
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
|
||||||
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
|
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
|
||||||
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");
|
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");
|
||||||
@@ -568,6 +580,12 @@ builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyApiClient>();
|
|||||||
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
|
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
|
||||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
||||||
|
|
||||||
|
// Register LyricsPlus service (multi-source lyrics API)
|
||||||
|
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
|
||||||
|
|
||||||
|
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
|
||||||
|
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
|
||||||
|
|
||||||
// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled)
|
// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled)
|
||||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyPlaylistFetcher>();
|
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyPlaylistFetcher>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyPlaylistFetcher>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyPlaylistFetcher>());
|
||||||
@@ -626,7 +644,26 @@ builder.Services.AddCors(options =>
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Initialize cache settings for static access
|
||||||
|
CacheExtensions.InitializeCacheSettings(app.Services);
|
||||||
|
|
||||||
|
// 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.
|
// 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
|
app.UseExceptionHandler(_ => { }); // Global exception handler
|
||||||
|
|
||||||
// Enable response compression EARLY in the pipeline
|
// Enable response compression EARLY in the pipeline
|
||||||
|
|||||||
@@ -104,10 +104,10 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||||
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
|
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
|
||||||
|
|
||||||
// Update access time for cache cleanup
|
// Update write time for cache cleanup (extends cache lifetime)
|
||||||
if (SubsonicSettings.StorageMode == StorageMode.Cache)
|
if (SubsonicSettings.StorageMode == StorageMode.Cache)
|
||||||
{
|
{
|
||||||
IOFile.SetLastAccessTime(localPath, DateTime.UtcNow);
|
IOFile.SetLastWriteTime(localPath, DateTime.UtcNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start background Odesli conversion for lyrics (if not already cached)
|
// Start background Odesli conversion for lyrics (if not already cached)
|
||||||
@@ -264,6 +264,7 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
|
|
||||||
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
|
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
|
||||||
await DownloadLock.WaitAsync(cancellationToken);
|
await DownloadLock.WaitAsync(cancellationToken);
|
||||||
|
var lockHeld = true;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -273,10 +274,10 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
{
|
{
|
||||||
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
||||||
|
|
||||||
// For cache mode, update file access time for cache cleanup logic
|
// For cache mode, update file write time to extend cache lifetime
|
||||||
if (isCache)
|
if (isCache)
|
||||||
{
|
{
|
||||||
IOFile.SetLastAccessTime(existingPath, DateTime.UtcNow);
|
IOFile.SetLastWriteTime(existingPath, DateTime.UtcNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
return existingPath;
|
return existingPath;
|
||||||
@@ -288,6 +289,7 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
|
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
|
||||||
// Release lock while waiting
|
// Release lock while waiting
|
||||||
DownloadLock.Release();
|
DownloadLock.Release();
|
||||||
|
lockHeld = false;
|
||||||
|
|
||||||
// Wait for download to complete, checking every 100ms (faster than 500ms)
|
// Wait for download to complete, checking every 100ms (faster than 500ms)
|
||||||
// Also respect cancellation token so client timeouts are handled immediately
|
// Also respect cancellation token so client timeouts are handled immediately
|
||||||
@@ -444,7 +446,10 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
DownloadLock.Release();
|
if (lockHeld)
|
||||||
|
{
|
||||||
|
DownloadLock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ public class CacheCleanupService : BackgroundService
|
|||||||
|
|
||||||
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
|
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))
|
if (!Directory.Exists(cachePath))
|
||||||
{
|
{
|
||||||
@@ -78,7 +80,7 @@ public class CacheCleanupService : BackgroundService
|
|||||||
var deletedCount = 0;
|
var deletedCount = 0;
|
||||||
var totalSize = 0L;
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -108,7 +110,7 @@ public class CacheCleanupService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to delete cached file: {Path}", filePath);
|
_logger.LogError(ex, "Failed to delete cached file: {Path}", filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +125,7 @@ public class CacheCleanupService : BackgroundService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Cache cleanup completed: no files to delete");
|
_logger.LogInformation("Cache cleanup completed: no files to delete");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -154,13 +156,13 @@ public class CacheCleanupService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to delete empty directory: {Path}", directory);
|
_logger.LogError(ex, "Failed to delete empty directory: {Path}", directory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Error cleaning up empty directories");
|
_logger.LogError(ex, "Error cleaning up empty directories");
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for cache TTL management.
|
||||||
|
/// Provides centralized access to configurable cache durations.
|
||||||
|
/// </summary>
|
||||||
|
public static class CacheExtensions
|
||||||
|
{
|
||||||
|
private static CacheSettings? _cacheSettings;
|
||||||
|
private static readonly object _lock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialize cache settings (called once at startup).
|
||||||
|
/// </summary>
|
||||||
|
public static void InitializeCacheSettings(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_cacheSettings == null)
|
||||||
|
{
|
||||||
|
var options = serviceProvider.GetService<IOptions<CacheSettings>>();
|
||||||
|
_cacheSettings = options?.Value ?? new CacheSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the current cache settings.
|
||||||
|
/// </summary>
|
||||||
|
public static CacheSettings GetCacheSettings()
|
||||||
|
{
|
||||||
|
if (_cacheSettings == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Cache settings not initialized. Call InitializeCacheSettings first.");
|
||||||
|
}
|
||||||
|
return _cacheSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience methods for getting TTLs
|
||||||
|
public static TimeSpan SearchResultsTTL => GetCacheSettings().SearchResultsTTL;
|
||||||
|
public static TimeSpan PlaylistImagesTTL => GetCacheSettings().PlaylistImagesTTL;
|
||||||
|
public static TimeSpan SpotifyPlaylistItemsTTL => GetCacheSettings().SpotifyPlaylistItemsTTL;
|
||||||
|
public static TimeSpan SpotifyMatchedTracksTTL => GetCacheSettings().SpotifyMatchedTracksTTL;
|
||||||
|
public static TimeSpan LyricsTTL => GetCacheSettings().LyricsTTL;
|
||||||
|
public static TimeSpan GenreTTL => GetCacheSettings().GenreTTL;
|
||||||
|
public static TimeSpan MetadataTTL => GetCacheSettings().MetadataTTL;
|
||||||
|
public static TimeSpan OdesliLookupTTL => GetCacheSettings().OdesliLookupTTL;
|
||||||
|
public static TimeSpan ProxyImagesTTL => GetCacheSettings().ProxyImagesTTL;
|
||||||
|
}
|
||||||
@@ -105,19 +105,19 @@ public class CacheWarmingService : IHostedService
|
|||||||
if (cacheEntry != null && !string.IsNullOrEmpty(cacheEntry.CacheKey))
|
if (cacheEntry != null && !string.IsNullOrEmpty(cacheEntry.CacheKey))
|
||||||
{
|
{
|
||||||
var redisKey = $"genre:{cacheEntry.CacheKey}";
|
var redisKey = $"genre:{cacheEntry.CacheKey}";
|
||||||
await _cache.SetAsync(redisKey, cacheEntry.Genre, TimeSpan.FromDays(30));
|
await _cache.SetAsync(redisKey, cacheEntry.Genre, CacheExtensions.GenreTTL);
|
||||||
warmedCount++;
|
warmedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to warm genre cache from file: {File}", file);
|
_logger.LogError(ex, "Failed to warm genre cache from file: {File}", file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (warmedCount > 0)
|
if (warmedCount > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🔥 Warmed {Count} genre entries from file cache", warmedCount);
|
_logger.LogDebug("🔥 Warmed {Count} genre entries from file cache", warmedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
return warmedCount;
|
return warmedCount;
|
||||||
@@ -162,7 +162,7 @@ public class CacheWarmingService : IHostedService
|
|||||||
var playlistName = fileName.Replace("_items", "");
|
var playlistName = fileName.Replace("_items", "");
|
||||||
|
|
||||||
var redisKey = $"spotify:playlist:items:{playlistName}";
|
var redisKey = $"spotify:playlist:items:{playlistName}";
|
||||||
await _cache.SetAsync(redisKey, items, TimeSpan.FromHours(24));
|
await _cache.SetAsync(redisKey, items, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||||
warmedCount++;
|
warmedCount++;
|
||||||
|
|
||||||
_logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)",
|
_logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)",
|
||||||
@@ -171,7 +171,7 @@ public class CacheWarmingService : IHostedService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to warm playlist items cache from file: {File}", file);
|
_logger.LogError(ex, "Failed to warm playlist items cache from file: {File}", file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,22 +200,22 @@ public class CacheWarmingService : IHostedService
|
|||||||
var playlistName = fileName.Replace("_matched", "");
|
var playlistName = fileName.Replace("_matched", "");
|
||||||
|
|
||||||
var redisKey = $"spotify:matched:ordered:{playlistName}";
|
var redisKey = $"spotify:matched:ordered:{playlistName}";
|
||||||
await _cache.SetAsync(redisKey, matchedTracks, TimeSpan.FromHours(1));
|
await _cache.SetAsync(redisKey, matchedTracks, CacheExtensions.SpotifyMatchedTracksTTL);
|
||||||
warmedCount++;
|
warmedCount++;
|
||||||
|
|
||||||
_logger.LogDebug("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
|
_logger.LogInformation("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
|
||||||
playlistName, matchedTracks.Count);
|
playlistName, matchedTracks.Count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to warm matched tracks cache from file: {File}", file);
|
_logger.LogError(ex, "Failed to warm matched tracks cache from file: {File}", file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (warmedCount > 0)
|
if (warmedCount > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🔥 Warmed {Count} playlist caches from file system", warmedCount);
|
_logger.LogDebug("🔥 Warmed {Count} playlist caches from file system", warmedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
return warmedCount;
|
return warmedCount;
|
||||||
@@ -276,13 +276,13 @@ public class CacheWarmingService : IHostedService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to warm manual mappings from file: {File}", file);
|
_logger.LogError(ex, "Failed to warm manual mappings from file: {File}", file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (warmedCount > 0)
|
if (warmedCount > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🔥 Warmed {Count} manual mappings from file system", warmedCount);
|
_logger.LogDebug("🔥 Warmed {Count} manual mappings from file system", warmedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
return warmedCount;
|
return warmedCount;
|
||||||
@@ -318,13 +318,13 @@ public class CacheWarmingService : IHostedService
|
|||||||
await _cache.SetStringAsync(redisKey, mapping.LyricsId.ToString());
|
await _cache.SetStringAsync(redisKey, mapping.LyricsId.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("🔥 Warmed {Count} lyrics mappings from file system", mappings.Count);
|
_logger.LogDebug("🔥 Warmed {Count} lyrics mappings from file system", mappings.Count);
|
||||||
return mappings.Count;
|
return mappings.Count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to warm lyrics mappings from file: {File}", mappingsFile);
|
_logger.LogError(ex, "Failed to warm lyrics mappings from file: {File}", mappingsFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
@@ -356,7 +356,7 @@ public class CacheWarmingService : IHostedService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to warm lyrics cache");
|
_logger.LogError(ex, "Failed to warm lyrics cache");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public class EndpointBenchmarkService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Benchmarks a list of endpoints by making test requests.
|
/// Benchmarks a list of endpoints by making test requests.
|
||||||
/// Returns endpoints sorted by average response time (fastest first).
|
/// 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>
|
/// </summary>
|
||||||
public async Task<List<string>> BenchmarkEndpointsAsync(
|
public async Task<List<string>> BenchmarkEndpointsAsync(
|
||||||
List<string> endpoints,
|
List<string> endpoints,
|
||||||
@@ -27,7 +30,7 @@ public class EndpointBenchmarkService
|
|||||||
int pingCount = 3,
|
int pingCount = 3,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🏁 Benchmarking {Count} endpoints with {Pings} pings each...", endpoints.Count, pingCount);
|
_logger.LogDebug("🏁 Benchmarking {Count} endpoints with {Pings} pings each...", endpoints.Count, pingCount);
|
||||||
|
|
||||||
var tasks = endpoints.Select(async endpoint =>
|
var tasks = endpoints.Select(async endpoint =>
|
||||||
{
|
{
|
||||||
@@ -51,7 +54,7 @@ public class EndpointBenchmarkService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Benchmark ping failed for {Endpoint}", endpoint);
|
_logger.LogError(ex, "Benchmark ping failed for {Endpoint}", endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay between pings
|
// Small delay between pings
|
||||||
@@ -82,7 +85,7 @@ public class EndpointBenchmarkService
|
|||||||
_lock.Release();
|
_lock.Release();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(" {Endpoint}: {AvgMs}ms avg, {SuccessRate:P0} success rate",
|
_logger.LogDebug(" {Endpoint}: {AvgMs}ms avg, {SuccessRate:P0} success rate",
|
||||||
endpoint, avgMs, metrics.SuccessRate);
|
endpoint, avgMs, metrics.SuccessRate);
|
||||||
|
|
||||||
return metrics;
|
return metrics;
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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.LogWarning("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.LogDebug("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate old SquidWTF quality values to new format
|
||||||
|
if (line.StartsWith("SQUIDWTF_QUALITY="))
|
||||||
|
{
|
||||||
|
var value = line.Substring("SQUIDWTF_QUALITY=".Length).Trim();
|
||||||
|
var newValue = value.ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"FLAC" => "LOSSLESS",
|
||||||
|
"HI_RES" => "HI_RES_LOSSLESS",
|
||||||
|
"MP3_320" => "HIGH",
|
||||||
|
"MP3_128" => "LOW",
|
||||||
|
_ => null // Keep as-is if already correct
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newValue != null)
|
||||||
|
{
|
||||||
|
lines[i] = $"SQUIDWTF_QUALITY={newValue}";
|
||||||
|
modified = true;
|
||||||
|
_logger.LogInformation("Migrated SQUIDWTF_QUALITY from {Old} to {New} in .env file", value, newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified)
|
||||||
|
{
|
||||||
|
File.WriteAllLines(_envFilePath, lines);
|
||||||
|
_logger.LogInformation("✅ .env file migration completed successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to migrate .env file - please manually update DOWNLOAD_PATH to Library__DownloadPath");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,8 @@ public static class FuzzyMatcher
|
|||||||
/// Calculates similarity score following OPTIMAL ORDER:
|
/// Calculates similarity score following OPTIMAL ORDER:
|
||||||
/// 1. Strip decorators (already done by caller)
|
/// 1. Strip decorators (already done by caller)
|
||||||
/// 2. Substring matching (cheap, high-precision)
|
/// 2. Substring matching (cheap, high-precision)
|
||||||
/// 3. Levenshtein distance (expensive, fuzzy)
|
/// 3. Token-based matching (handles word order)
|
||||||
|
/// 4. Levenshtein distance (expensive, fuzzy)
|
||||||
/// Returns score 0-100.
|
/// Returns score 0-100.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static int CalculateSimilarity(string query, string target)
|
public static int CalculateSimilarity(string query, string target)
|
||||||
@@ -103,11 +104,71 @@ public static class FuzzyMatcher
|
|||||||
return 85;
|
return 85;
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
// STEP 3: TOKEN-BASED MATCHING (handles word order)
|
||||||
// Only use this for candidates that survived substring checks
|
var tokens1 = queryNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var tokens2 = targetNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
var distance = LevenshteinDistance(queryNorm, targetNorm);
|
|
||||||
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
|
if (tokens1.Length > 0 && tokens2.Length > 0)
|
||||||
|
{
|
||||||
|
// Calculate how many tokens match (order-independent)
|
||||||
|
var matchedTokens = 0.0; // Use double for partial matches
|
||||||
|
var usedTokens = new HashSet<int>();
|
||||||
|
|
||||||
|
foreach (var token1 in tokens1)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < tokens2.Length; i++)
|
||||||
|
{
|
||||||
|
if (usedTokens.Contains(i)) continue;
|
||||||
|
|
||||||
|
var token2 = tokens2[i];
|
||||||
|
|
||||||
|
// Exact token match
|
||||||
|
if (token1 == token2)
|
||||||
|
{
|
||||||
|
matchedTokens++;
|
||||||
|
usedTokens.Add(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Partial token match (one contains the other)
|
||||||
|
else if (token1.Contains(token2) || token2.Contains(token1))
|
||||||
|
{
|
||||||
|
matchedTokens += 0.8; // Partial credit
|
||||||
|
usedTokens.Add(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate token match percentage
|
||||||
|
var maxTokens = Math.Max(tokens1.Length, tokens2.Length);
|
||||||
|
var tokenMatchScore = (matchedTokens / maxTokens) * 100.0;
|
||||||
|
|
||||||
|
// If token match is very high (90%+), return it
|
||||||
|
if (tokenMatchScore >= 90)
|
||||||
|
{
|
||||||
|
return (int)Math.Round(tokenMatchScore, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If token match is decent (70%+), use it as a floor for Levenshtein
|
||||||
|
if (tokenMatchScore >= 70)
|
||||||
|
{
|
||||||
|
var levenshteinScore = CalculateLevenshteinScore(queryNorm, targetNorm);
|
||||||
|
return (int)Math.Max(tokenMatchScore, levenshteinScore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 4: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
||||||
|
return CalculateLevenshteinScore(queryNorm, targetNorm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates similarity score based on Levenshtein distance.
|
||||||
|
/// Returns score 0-75 (reserve 75-100 for substring/token matches).
|
||||||
|
/// </summary>
|
||||||
|
private static int CalculateLevenshteinScore(string str1, string str2)
|
||||||
|
{
|
||||||
|
var distance = LevenshteinDistance(str1, str2);
|
||||||
|
var maxLength = Math.Max(str1.Length, str2.Length);
|
||||||
|
|
||||||
if (maxLength == 0)
|
if (maxLength == 0)
|
||||||
{
|
{
|
||||||
@@ -117,8 +178,9 @@ public static class FuzzyMatcher
|
|||||||
// Normalize distance by length: score = 1 - (distance / max_length)
|
// Normalize distance by length: score = 1 - (distance / max_length)
|
||||||
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
||||||
|
|
||||||
// Convert to 0-80 range (reserve 80-100 for substring matches)
|
// Convert to 0-75 range (reserve 75-100 for substring/token matches)
|
||||||
var score = (int)(normalizedSimilarity * 80);
|
// Using 75 instead of 80 to be slightly stricter
|
||||||
|
var score = (int)(normalizedSimilarity * 75);
|
||||||
|
|
||||||
return Math.Max(0, score);
|
return Math.Max(0, score);
|
||||||
}
|
}
|
||||||
@@ -154,7 +216,9 @@ public static class FuzzyMatcher
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Normalizes a string for matching by:
|
/// Normalizes a string for matching by:
|
||||||
/// - Converting to lowercase
|
/// - Converting to lowercase
|
||||||
/// - Normalizing apostrophes (', ', ') to standard '
|
/// - Removing accents/diacritics
|
||||||
|
/// - Converting hyphens/underscores to spaces (for word separation)
|
||||||
|
/// - Removing other punctuation (periods, apostrophes, commas, etc.)
|
||||||
/// - Removing extra whitespace
|
/// - Removing extra whitespace
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string NormalizeForMatching(string text)
|
private static string NormalizeForMatching(string text)
|
||||||
@@ -166,18 +230,42 @@ public static class FuzzyMatcher
|
|||||||
|
|
||||||
var normalized = text.ToLowerInvariant().Trim();
|
var normalized = text.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
// Normalize different apostrophe types to standard apostrophe
|
// Remove accents/diacritics (é -> e, ñ -> n, etc.)
|
||||||
normalized = normalized
|
normalized = RemoveDiacritics(normalized);
|
||||||
.Replace("\u2019", "'") // Right single quotation mark (')
|
|
||||||
.Replace("\u2018", "'") // Left single quotation mark (')
|
// Replace hyphens and underscores with spaces (for word separation)
|
||||||
.Replace("`", "'") // Grave accent
|
// This ensures "Dua-Lipa" becomes "Dua Lipa" not "DuaLipa"
|
||||||
.Replace("\u00B4", "'"); // Acute accent (´)
|
normalized = normalized.Replace('-', ' ').Replace('_', ' ');
|
||||||
|
|
||||||
|
// Remove all other punctuation: periods, apostrophes, commas, etc.
|
||||||
|
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", "");
|
||||||
|
|
||||||
// Normalize whitespace
|
// Normalize whitespace
|
||||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
|
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes diacritics (accents) from characters.
|
||||||
|
/// Example: é -> e, ñ -> n, ü -> u
|
||||||
|
/// </summary>
|
||||||
|
private static string RemoveDiacritics(string text)
|
||||||
|
{
|
||||||
|
var normalizedString = text.Normalize(System.Text.NormalizationForm.FormD);
|
||||||
|
var stringBuilder = new System.Text.StringBuilder();
|
||||||
|
|
||||||
|
foreach (var c in normalizedString)
|
||||||
|
{
|
||||||
|
var unicodeCategory = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
|
||||||
|
if (unicodeCategory != System.Globalization.UnicodeCategory.NonSpacingMark)
|
||||||
|
{
|
||||||
|
stringBuilder.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringBuilder.ToString().Normalize(System.Text.NormalizationForm.FormC);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates Levenshtein distance between two strings.
|
/// Calculates Levenshtein distance between two strings.
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ public class GenreEnrichmentService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to enrich genre for {Title} - {Artist}",
|
_logger.LogError(ex, "Failed to enrich genre for {Title} - {Artist}",
|
||||||
song.Title, song.Artist);
|
song.Title, song.Artist);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,7 @@ public class GenreEnrichmentService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to read genre from file cache for {Key}", cacheKey);
|
_logger.LogError(ex, "Failed to read genre from file cache for {Key}", cacheKey);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,7 +201,7 @@ public class GenreEnrichmentService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to save genre to file cache for {Key}", cacheKey);
|
_logger.LogError(ex, "Failed to save genre to file cache for {Key}", cacheKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,10 +63,10 @@ public class OdesliService
|
|||||||
if (match.Success)
|
if (match.Success)
|
||||||
{
|
{
|
||||||
var spotifyId = match.Groups[1].Value;
|
var spotifyId = match.Groups[1].Value;
|
||||||
_logger.LogInformation("✓ Converted Tidal/{TidalId} → Spotify ID {SpotifyId}", tidalTrackId, spotifyId);
|
_logger.LogDebug("✓ Converted Tidal/{TidalId} → Spotify ID {SpotifyId}", tidalTrackId, spotifyId);
|
||||||
|
|
||||||
// Cache for 7 days
|
// Cache for configurable duration
|
||||||
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
|
await _cache.SetAsync(cacheKey, spotifyId, CacheExtensions.OdesliLookupTTL);
|
||||||
|
|
||||||
return spotifyId;
|
return spotifyId;
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ public class OdesliService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to convert Tidal track to Spotify ID via Odesli");
|
_logger.LogError(ex, "Failed to convert Tidal track to Spotify ID via Odesli");
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -122,10 +122,10 @@ public class OdesliService
|
|||||||
if (match.Success)
|
if (match.Success)
|
||||||
{
|
{
|
||||||
var spotifyId = match.Groups[1].Value;
|
var spotifyId = match.Groups[1].Value;
|
||||||
_logger.LogInformation("✓ Converted URL → Spotify ID {SpotifyId}", spotifyId);
|
_logger.LogDebug("✓ Converted URL → Spotify ID {SpotifyId}", spotifyId);
|
||||||
|
|
||||||
// Cache for 7 days
|
// Cache for configurable duration
|
||||||
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
|
await _cache.SetAsync(cacheKey, spotifyId, CacheExtensions.OdesliLookupTTL);
|
||||||
|
|
||||||
return spotifyId;
|
return spotifyId;
|
||||||
}
|
}
|
||||||
@@ -135,7 +135,7 @@ public class OdesliService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to convert URL to Spotify ID via Odesli");
|
_logger.LogError(ex, "Failed to convert URL to Spotify ID via Odesli");
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public class ParallelMetadataService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "❌ {Provider} search failed", providerName);
|
_logger.LogError(ex, "❌ {Provider} search failed", providerName);
|
||||||
return (Success: false, Result: new SearchResult(), Provider: providerName, ElapsedMs: 0L);
|
return (Success: false, Result: new SearchResult(), Provider: providerName, ElapsedMs: 0L);
|
||||||
}
|
}
|
||||||
}).ToList();
|
}).ToList();
|
||||||
@@ -64,7 +64,7 @@ public class ParallelMetadataService
|
|||||||
|
|
||||||
if (result.Success && (result.Result.Songs.Any() || result.Result.Albums.Any() || result.Result.Artists.Any()))
|
if (result.Success && (result.Result.Songs.Any() || result.Result.Albums.Any() || result.Result.Artists.Any()))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🏆 Using results from {Provider} ({Ms}ms) - fastest with results",
|
_logger.LogDebug("🏆 Using results from {Provider} ({Ms}ms) - fastest with results",
|
||||||
result.Provider, result.ElapsedMs);
|
result.Provider, result.ElapsedMs);
|
||||||
return result.Result;
|
return result.Result;
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ public class ParallelMetadataService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "❌ {Provider} song search failed", providerName);
|
_logger.LogError(ex, "❌ {Provider} song search failed", providerName);
|
||||||
return (Success: false, Song: (Song?)null, Provider: providerName, ElapsedMs: 0L);
|
return (Success: false, Song: (Song?)null, Provider: providerName, ElapsedMs: 0L);
|
||||||
}
|
}
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ public static class PathHelper
|
|||||||
/// <param name="title">Track title (will be sanitized).</param>
|
/// <param name="title">Track title (will be sanitized).</param>
|
||||||
/// <param name="trackNumber">Optional track number for prefix.</param>
|
/// <param name="trackNumber">Optional track number for prefix.</param>
|
||||||
/// <param name="extension">File extension (e.g., ".flac", ".mp3").</param>
|
/// <param name="extension">File extension (e.g., ".flac", ".mp3").</param>
|
||||||
|
/// <param name="provider">Optional provider name (e.g., "squidwtf", "deezer").</param>
|
||||||
|
/// <param name="externalId">Optional external ID from the provider.</param>
|
||||||
/// <returns>Full path for the track file.</returns>
|
/// <returns>Full path for the track file.</returns>
|
||||||
public static string BuildTrackPath(string downloadPath, string artist, string album, string title, int? trackNumber, string extension)
|
public static string BuildTrackPath(string downloadPath, string artist, string album, string title, int? trackNumber, string extension, string? provider = null, string? externalId = null)
|
||||||
{
|
{
|
||||||
var safeArtist = SanitizeFolderName(artist);
|
var safeArtist = SanitizeFolderName(artist);
|
||||||
var safeAlbum = SanitizeFolderName(album);
|
var safeAlbum = SanitizeFolderName(album);
|
||||||
@@ -39,7 +41,10 @@ public static class PathHelper
|
|||||||
var albumFolder = Path.Combine(artistFolder, safeAlbum);
|
var albumFolder = Path.Combine(artistFolder, safeAlbum);
|
||||||
|
|
||||||
var trackPrefix = trackNumber.HasValue ? $"{trackNumber:D2} - " : "";
|
var trackPrefix = trackNumber.HasValue ? $"{trackNumber:D2} - " : "";
|
||||||
var fileName = $"{trackPrefix}{safeTitle}{extension}";
|
var idSuffix = !string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId)
|
||||||
|
? $" [{provider}-{externalId}]"
|
||||||
|
: "";
|
||||||
|
var fileName = $"{trackPrefix}{safeTitle}{idSuffix}{extension}";
|
||||||
|
|
||||||
return Path.Combine(albumFolder, fileName);
|
return Path.Combine(albumFolder, fileName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class RedisCacheService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Redis connection failed. Caching disabled.");
|
_logger.LogError(ex, "Redis connection failed. Caching disabled.");
|
||||||
_redis = null;
|
_redis = null;
|
||||||
_db = null;
|
_db = null;
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ public class RedisCacheService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Redis GET failed for key: {Key}", key);
|
_logger.LogError(ex, "Redis GET failed for key: {Key}", key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@ public class RedisCacheService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to deserialize cached value for key: {Key}", key);
|
_logger.LogError(ex, "Failed to deserialize cached value for key: {Key}", key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ public class RedisCacheService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Redis SET failed for key: {Key}", key);
|
_logger.LogError(ex, "Redis SET failed for key: {Key}", key);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ public class RedisCacheService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to serialize value for key: {Key}", key);
|
_logger.LogError(ex, "Failed to serialize value for key: {Key}", key);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ public class RedisCacheService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Redis DELETE failed for key: {Key}", key);
|
_logger.LogError(ex, "Redis DELETE failed for key: {Key}", key);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,7 +165,7 @@ public class RedisCacheService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Redis EXISTS failed for key: {Key}", key);
|
_logger.LogError(ex, "Redis EXISTS failed for key: {Key}", key);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,12 +190,12 @@ public class RedisCacheService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var deleted = await _db!.KeyDeleteAsync(keys);
|
var deleted = await _db!.KeyDeleteAsync(keys);
|
||||||
_logger.LogInformation("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
|
_logger.LogDebug("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
|
||||||
return (int)deleted;
|
return (int)deleted;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
|
_logger.LogError(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Periodically snapshots Redis cache to file system for cold start recovery.
|
||||||
|
/// Redis is the primary cache, files are the persistence layer.
|
||||||
|
/// </summary>
|
||||||
|
public class RedisPersistenceService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly RedisCacheService _cache;
|
||||||
|
private readonly ILogger<RedisPersistenceService> _logger;
|
||||||
|
private readonly TimeSpan _snapshotInterval = TimeSpan.FromMinutes(5);
|
||||||
|
private const string SnapshotDirectory = "/app/cache/redis-snapshots";
|
||||||
|
|
||||||
|
public RedisPersistenceService(
|
||||||
|
RedisCacheService cache,
|
||||||
|
ILogger<RedisPersistenceService> logger)
|
||||||
|
{
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// Wait 2 minutes after startup before first snapshot (let cache warm up)
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CreateSnapshotAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error creating Redis snapshot");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(_snapshotInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateSnapshotAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!_cache.IsEnabled)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Redis is disabled, skipping snapshot");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(SnapshotDirectory);
|
||||||
|
|
||||||
|
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss");
|
||||||
|
var snapshotFile = Path.Combine(SnapshotDirectory, $"snapshot_{timestamp}.json");
|
||||||
|
|
||||||
|
// For now, we'll rely on Redis's built-in RDB + AOF persistence
|
||||||
|
// This service is a placeholder for future enhancements like:
|
||||||
|
// - Exporting specific key patterns to JSON
|
||||||
|
// - Creating human-readable backups
|
||||||
|
// - Syncing to external storage
|
||||||
|
|
||||||
|
_logger.LogDebug("Redis snapshot service running (using Redis native persistence)");
|
||||||
|
|
||||||
|
// Clean up old snapshots (keep last 10)
|
||||||
|
await CleanupOldSnapshotsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create Redis snapshot");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CleanupOldSnapshotsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(SnapshotDirectory))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var files = Directory.GetFiles(SnapshotDirectory, "snapshot_*.json")
|
||||||
|
.OrderByDescending(f => f)
|
||||||
|
.Skip(10)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
File.Delete(file);
|
||||||
|
_logger.LogDebug("Deleted old snapshot: {File}", Path.GetFileName(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to cleanup old snapshots");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ public class RoundRobinFallbackHelper
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl);
|
_logger.LogError(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl);
|
||||||
|
|
||||||
// Cache as unhealthy
|
// Cache as unhealthy
|
||||||
lock (_healthCacheLock)
|
lock (_healthCacheLock)
|
||||||
@@ -137,7 +137,7 @@ public class RoundRobinFallbackHelper
|
|||||||
_apiUrls.AddRange(reordered);
|
_apiUrls.AddRange(reordered);
|
||||||
_currentUrlIndex = 0;
|
_currentUrlIndex = 0;
|
||||||
|
|
||||||
_logger.LogInformation("📊 {Service} endpoints reordered by benchmark: {Endpoints}",
|
_logger.LogDebug("📊 {Service} endpoints reordered by benchmark: {Endpoints}",
|
||||||
_serviceName, string.Join(", ", _apiUrls.Take(3)));
|
_serviceName, string.Join(", ", _apiUrls.Take(3)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@ public class RoundRobinFallbackHelper
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
_logger.LogError(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||||
_serviceName, baseUrl);
|
_serviceName, baseUrl);
|
||||||
|
|
||||||
// Mark as unhealthy in cache
|
// Mark as unhealthy in cache
|
||||||
@@ -227,7 +227,7 @@ public class RoundRobinFallbackHelper
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "{Service} race failed for endpoint {Endpoint}", _serviceName, baseUrl);
|
_logger.LogError(ex, "{Service} race failed for endpoint {Endpoint}", _serviceName, baseUrl);
|
||||||
return (default(T)!, baseUrl, false);
|
return (default(T)!, baseUrl, false);
|
||||||
}
|
}
|
||||||
}, raceCts.Token);
|
}, raceCts.Token);
|
||||||
@@ -243,7 +243,7 @@ public class RoundRobinFallbackHelper
|
|||||||
|
|
||||||
if (success)
|
if (success)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
|
_logger.LogDebug("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
|
||||||
raceCts.Cancel(); // Cancel all other requests
|
raceCts.Cancel(); // Cancel all other requests
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -291,7 +291,7 @@ public class RoundRobinFallbackHelper
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
_logger.LogError(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||||
_serviceName, baseUrl);
|
_serviceName, baseUrl);
|
||||||
|
|
||||||
// Mark as unhealthy in cache
|
// Mark as unhealthy in cache
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||||
? Path.Combine("downloads", "cache")
|
? Path.Combine("downloads", "cache")
|
||||||
: Path.Combine("downloads", "permanent");
|
: Path.Combine("downloads", "permanent");
|
||||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "deezer", trackId);
|
||||||
|
|
||||||
// Create directories if they don't exist
|
// Create directories if they don't exist
|
||||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
|||||||
using allstarr.Models.Download;
|
using allstarr.Models.Download;
|
||||||
using allstarr.Models.Search;
|
using allstarr.Models.Search;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
|
using allstarr.Services.Common;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -15,12 +16,17 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SubsonicSettings _settings;
|
private readonly SubsonicSettings _settings;
|
||||||
|
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||||
private const string BaseUrl = "https://api.deezer.com";
|
private const string BaseUrl = "https://api.deezer.com";
|
||||||
|
|
||||||
public DeezerMetadataService(IHttpClientFactory httpClientFactory, IOptions<SubsonicSettings> settings)
|
public DeezerMetadataService(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IOptions<SubsonicSettings> settings,
|
||||||
|
GenreEnrichmentService? genreEnrichment = null)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
|
_genreEnrichment = genreEnrichment;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||||
@@ -203,6 +209,23 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich with MusicBrainz genres if missing
|
||||||
|
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
|
||||||
|
{
|
||||||
|
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _genreEnrichment.EnrichSongGenreAsync(song);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Silently ignore genre enrichment failures
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return song;
|
return song;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,17 +407,23 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contributors
|
// Contributors (all artists including features)
|
||||||
var contributors = new List<string>();
|
var contributors = new List<string>();
|
||||||
|
var contributorIds = new List<string>();
|
||||||
if (track.TryGetProperty("contributors", out var contribs))
|
if (track.TryGetProperty("contributors", out var contribs))
|
||||||
{
|
{
|
||||||
foreach (var contrib in contribs.EnumerateArray())
|
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 name = contribName.GetString();
|
||||||
|
var id = contribId.GetInt64();
|
||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
contributors.Add(name);
|
contributors.Add(name);
|
||||||
|
contributorIds.Add($"ext-deezer-artist-{id}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,6 +466,8 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
ArtistId = track.TryGetProperty("artist", out var artistForId)
|
ArtistId = track.TryGetProperty("artist", out var artistForId)
|
||||||
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
|
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
|
||||||
: null,
|
: 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 = track.TryGetProperty("album", out var album)
|
||||||
? album.GetProperty("title").GetString() ?? ""
|
? album.GetProperty("title").GetString() ?? ""
|
||||||
: "",
|
: "",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ public class JellyfinModelMapper
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Error parsing Jellyfin items response");
|
_logger.LogError(ex, "Error parsing Jellyfin items response");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (songs, albums, artists);
|
return (songs, albums, artists);
|
||||||
@@ -126,7 +126,7 @@ public class JellyfinModelMapper
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Error parsing Jellyfin search hints response");
|
_logger.LogError(ex, "Error parsing Jellyfin search hints response");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (songs, albums, artists);
|
return (songs, albums, artists);
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ public class JellyfinProxyService
|
|||||||
// Forward as X-Emby-Authorization (Jellyfin's expected header)
|
// Forward as X-Emby-Authorization (Jellyfin's expected header)
|
||||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||||
authHeaderAdded = true;
|
authHeaderAdded = true;
|
||||||
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
|
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -265,11 +265,11 @@ public class JellyfinProxyService
|
|||||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||||
{
|
{
|
||||||
// 401 means token expired or invalid - client needs to re-authenticate
|
// 401 means token expired or invalid - client needs to re-authenticate
|
||||||
_logger.LogInformation("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
|
_logger.LogDebug("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
|
||||||
}
|
}
|
||||||
else if (!isBrowserStaticRequest && !isPublicEndpoint)
|
else if (!isBrowserStaticRequest && !isPublicEndpoint)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
|
_logger.LogError("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse error response to pass through to client
|
// Try to parse error response to pass through to client
|
||||||
@@ -374,7 +374,7 @@ public class JellyfinProxyService
|
|||||||
{
|
{
|
||||||
// Forward as X-Emby-Authorization
|
// Forward as X-Emby-Authorization
|
||||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||||
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
|
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -418,11 +418,11 @@ public class JellyfinProxyService
|
|||||||
// 401 is expected when tokens expire - don't spam logs
|
// 401 is expected when tokens expire - don't spam logs
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
|
_logger.LogDebug("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
_logger.LogError("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||||
response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent);
|
response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,12 +579,12 @@ public class JellyfinProxyService
|
|||||||
|
|
||||||
if (!authHeaderAdded)
|
if (!authHeaderAdded)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No client auth provided for DELETE {Url} - forwarding without auth", url);
|
_logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
_logger.LogInformation("DELETE to Jellyfin: {Url}", url);
|
_logger.LogDebug("DELETE to Jellyfin: {Url}", url);
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request);
|
var response = await _httpClient.SendAsync(request);
|
||||||
|
|
||||||
@@ -593,7 +593,7 @@ public class JellyfinProxyService
|
|||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var errorContent = await response.Content.ReadAsStringAsync();
|
var errorContent = await response.Content.ReadAsStringAsync();
|
||||||
_logger.LogWarning("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
|
_logger.LogError("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||||
response.StatusCode, url, errorContent);
|
response.StatusCode, url, errorContent);
|
||||||
return (null, statusCode);
|
return (null, statusCode);
|
||||||
}
|
}
|
||||||
@@ -629,7 +629,7 @@ public class JellyfinProxyService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to get bytes from {Endpoint}", endpoint);
|
_logger.LogError(ex, "Failed to get bytes from {Endpoint}", endpoint);
|
||||||
return (null, null, false);
|
return (null, null, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -662,7 +662,7 @@ public class JellyfinProxyService
|
|||||||
if (!string.IsNullOrEmpty(_settings.LibraryId))
|
if (!string.IsNullOrEmpty(_settings.LibraryId))
|
||||||
{
|
{
|
||||||
queryParams["parentId"] = _settings.LibraryId;
|
queryParams["parentId"] = _settings.LibraryId;
|
||||||
_logger.LogDebug("Searching within configured LibraryId {LibraryId}", _settings.LibraryId);
|
_logger.LogInformation("Searching within configured LibraryId {LibraryId}", _settings.LibraryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeItemTypes != null && includeItemTypes.Length > 0)
|
if (includeItemTypes != null && includeItemTypes.Length > 0)
|
||||||
@@ -932,7 +932,7 @@ public class JellyfinProxyService
|
|||||||
if (result.Success && result.Body != null)
|
if (result.Success && result.Body != null)
|
||||||
{
|
{
|
||||||
var cacheValue = $"{Convert.ToBase64String(result.Body)}|{result.ContentType}";
|
var cacheValue = $"{Convert.ToBase64String(result.Body)}|{result.ContentType}";
|
||||||
await _cache.SetStringAsync(cacheKey, cacheValue, TimeSpan.FromDays(7));
|
await _cache.SetStringAsync(cacheKey, cacheValue, CacheExtensions.ProxyImagesTTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (result.Body, result.ContentType);
|
return (result.Body, result.ContentType);
|
||||||
|
|||||||
@@ -263,9 +263,11 @@ public class JellyfinResponseBuilder
|
|||||||
["Name"] = songTitle,
|
["Name"] = songTitle,
|
||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Id"] = song.Id,
|
["Id"] = song.Id,
|
||||||
|
["PlaylistItemId"] = song.Id, // Required for playlist items
|
||||||
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
||||||
["Container"] = "flac",
|
["Container"] = "flac",
|
||||||
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
|
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
|
||||||
|
["DateCreated"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"),
|
||||||
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
||||||
["ProductionYear"] = song.Year,
|
["ProductionYear"] = song.Year,
|
||||||
["IndexNumber"] = song.Track,
|
["IndexNumber"] = song.Track,
|
||||||
@@ -273,6 +275,7 @@ public class JellyfinResponseBuilder
|
|||||||
["IsFolder"] = false,
|
["IsFolder"] = false,
|
||||||
["Type"] = "Audio",
|
["Type"] = "Audio",
|
||||||
["ChannelId"] = (object?)null,
|
["ChannelId"] = (object?)null,
|
||||||
|
["ParentId"] = song.AlbumId,
|
||||||
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
||||||
? new[] { song.Genre }
|
? new[] { song.Genre }
|
||||||
: new string[0],
|
: new string[0],
|
||||||
@@ -286,6 +289,9 @@ public class JellyfinResponseBuilder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
: new Dictionary<string, object?>[0],
|
: new Dictionary<string, object?>[0],
|
||||||
|
["Tags"] = new string[0],
|
||||||
|
["People"] = new object[0],
|
||||||
|
["SortName"] = songTitle,
|
||||||
["ParentLogoItemId"] = song.AlbumId,
|
["ParentLogoItemId"] = song.AlbumId,
|
||||||
["ParentBackdropItemId"] = song.AlbumId,
|
["ParentBackdropItemId"] = song.AlbumId,
|
||||||
["ParentBackdropImageTags"] = new string[0],
|
["ParentBackdropImageTags"] = new string[0],
|
||||||
@@ -299,13 +305,11 @@ public class JellyfinResponseBuilder
|
|||||||
["ItemId"] = song.Id
|
["ItemId"] = song.Id
|
||||||
},
|
},
|
||||||
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
|
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
|
||||||
["ArtistItems"] = artistNames.Count > 0
|
["ArtistItems"] = artistNames.Count > 0 && song.ArtistIds.Count == artistNames.Count
|
||||||
? artistNames.Select((name, index) => new Dictionary<string, object?>
|
? artistNames.Select((name, index) => new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Name"] = name,
|
["Name"] = name,
|
||||||
["Id"] = index == 0 && song.ArtistId != null
|
["Id"] = song.ArtistIds[index]
|
||||||
? song.ArtistId
|
|
||||||
: $"{song.Id}-artist-{index}"
|
|
||||||
}).ToArray()
|
}).ToArray()
|
||||||
: new[]
|
: new[]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
// Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity)
|
// Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity)
|
||||||
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
|
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
_logger.LogDebug("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
|
_logger.LogInformation("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -44,7 +44,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(deviceId))
|
if (string.IsNullOrEmpty(deviceId))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Cannot create session - no device ID");
|
_logger.LogError("Cannot create session - no device ID");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
||||||
{
|
{
|
||||||
existingSession.LastActivity = DateTime.UtcNow;
|
existingSession.LastActivity = DateTime.UtcNow;
|
||||||
_logger.LogTrace("Session already exists for device {DeviceId}", deviceId);
|
_logger.LogInformation("Session already exists for device {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Refresh capabilities to keep session alive
|
// Refresh capabilities to keep session alive
|
||||||
// If this returns false (401), the token expired and client needs to re-auth
|
// If this returns false (401), the token expired and client needs to re-auth
|
||||||
@@ -60,7 +60,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
if (!success)
|
if (!success)
|
||||||
{
|
{
|
||||||
// Token expired - remove the stale session
|
// Token expired - remove the stale session
|
||||||
_logger.LogInformation("Token expired for device {DeviceId} - removing session", deviceId);
|
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
|
||||||
await RemoveSessionAsync(deviceId);
|
await RemoveSessionAsync(deviceId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -78,13 +78,17 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
if (!success)
|
if (!success)
|
||||||
{
|
{
|
||||||
// Token expired or invalid - client needs to re-authenticate
|
// Token expired or invalid - client needs to re-authenticate
|
||||||
_logger.LogInformation("Failed to create session for {DeviceId} - token may be expired", deviceId);
|
_logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
_logger.LogInformation("Session created for {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Track this session
|
// Track this session
|
||||||
|
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
||||||
|
?? headers["X-Real-IP"].FirstOrDefault()
|
||||||
|
?? "Unknown";
|
||||||
|
|
||||||
_sessions[deviceId] = new SessionInfo
|
_sessions[deviceId] = new SessionInfo
|
||||||
{
|
{
|
||||||
DeviceId = deviceId,
|
DeviceId = deviceId,
|
||||||
@@ -92,7 +96,8 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
Device = device,
|
Device = device,
|
||||||
Version = version,
|
Version = version,
|
||||||
LastActivity = DateTime.UtcNow,
|
LastActivity = DateTime.UtcNow,
|
||||||
Headers = CloneHeaders(headers)
|
Headers = CloneHeaders(headers),
|
||||||
|
ClientIp = clientIp
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
// Start a WebSocket connection to Jellyfin on behalf of this client
|
||||||
@@ -138,7 +143,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
else if (statusCode == 401)
|
else if (statusCode == 401)
|
||||||
{
|
{
|
||||||
// Token expired - this is expected, client needs to re-authenticate
|
// Token expired - this is expected, client needs to re-authenticate
|
||||||
_logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate");
|
_logger.LogWarning("Capabilities returned 401 (token expired) - client should re-authenticate");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -160,7 +165,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogDebug("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
|
_logger.LogError("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +227,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
Client = s.Client,
|
Client = s.Client,
|
||||||
Device = s.Device,
|
Device = s.Device,
|
||||||
Version = s.Version,
|
Version = s.Version,
|
||||||
|
ClientIp = s.ClientIp,
|
||||||
LastActivity = s.LastActivity,
|
LastActivity = s.LastActivity,
|
||||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||||
HasWebSocket = s.WebSocket != null,
|
HasWebSocket = s.WebSocket != null,
|
||||||
@@ -256,7 +262,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
_logger.LogError(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -276,7 +282,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
};
|
};
|
||||||
var stopJson = JsonSerializer.Serialize(stopPayload);
|
var stopJson = JsonSerializer.Serialize(stopPayload);
|
||||||
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
||||||
_logger.LogDebug("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||||
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +291,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
|
_logger.LogError("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,7 +304,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
{
|
{
|
||||||
if (!_sessions.TryGetValue(deviceId, out var session))
|
if (!_sessions.TryGetValue(deviceId, out var session))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
|
_logger.LogError("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +366,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
if (!string.IsNullOrEmpty(_settings.ApiKey))
|
if (!string.IsNullOrEmpty(_settings.ApiKey))
|
||||||
{
|
{
|
||||||
jellyfinWsUrl += $"?api_key={_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);
|
_logger.LogWarning("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -375,7 +381,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
|
|
||||||
// Connect to Jellyfin
|
// Connect to Jellyfin
|
||||||
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
|
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
|
||||||
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
|
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
|
||||||
|
|
||||||
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
|
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
|
||||||
// This tells Jellyfin to create/show the session in the dashboard
|
// This tells Jellyfin to create/show the session in the dashboard
|
||||||
@@ -383,7 +389,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}";
|
var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}";
|
||||||
var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
|
var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
|
||||||
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||||
_logger.LogDebug("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
|
_logger.LogInformation("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Also send SessionsStart to subscribe to session updates
|
// Also send SessionsStart to subscribe to session updates
|
||||||
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
|
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
|
||||||
@@ -516,20 +522,20 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
|
|
||||||
if (!success)
|
if (!success)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
|
_logger.LogWarning("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
|
||||||
expiredSessions.Add(session.DeviceId);
|
expiredSessions.Add(session.DeviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
|
_logger.LogError(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove sessions with expired tokens
|
// Remove sessions with expired tokens
|
||||||
foreach (var deviceId in expiredSessions)
|
foreach (var deviceId in expiredSessions)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Removing session with expired token: {DeviceId}", deviceId);
|
_logger.LogWarning("Removing session with expired token: {DeviceId}", deviceId);
|
||||||
await RemoveSessionAsync(deviceId);
|
await RemoveSessionAsync(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +571,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
public ClientWebSocket? WebSocket { get; set; }
|
public ClientWebSocket? WebSocket { get; set; }
|
||||||
public string? LastPlayingItemId { get; set; }
|
public string? LastPlayingItemId { get; set; }
|
||||||
public long? LastPlayingPositionTicks { get; set; }
|
public long? LastPlayingPositionTicks { get; set; }
|
||||||
|
public string? ClientIp { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -14,262 +14,262 @@ namespace allstarr.Services.Local;
|
|||||||
/// Uses a simple JSON file to store mappings (can be replaced with a database)
|
/// Uses a simple JSON file to store mappings (can be replaced with a database)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LocalLibraryService : ILocalLibraryService
|
public class LocalLibraryService : ILocalLibraryService
|
||||||
{
|
{
|
||||||
private readonly string _mappingFilePath;
|
private readonly string _mappingFilePath;
|
||||||
private readonly string _downloadDirectory;
|
private readonly string _downloadDirectory;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SubsonicSettings _subsonicSettings;
|
private readonly SubsonicSettings _subsonicSettings;
|
||||||
private readonly ILogger<LocalLibraryService> _logger;
|
private readonly ILogger<LocalLibraryService> _logger;
|
||||||
private Dictionary<string, LocalSongMapping>? _mappings;
|
private Dictionary<string, LocalSongMapping>? _mappings;
|
||||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
// Debounce to avoid triggering too many scans
|
// Debounce to avoid triggering too many scans
|
||||||
private DateTime _lastScanTrigger = DateTime.MinValue;
|
private DateTime _lastScanTrigger = DateTime.MinValue;
|
||||||
private readonly TimeSpan _scanDebounceInterval = TimeSpan.FromSeconds(30);
|
private readonly TimeSpan _scanDebounceInterval = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
public LocalLibraryService(
|
public LocalLibraryService(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IOptions<SubsonicSettings> subsonicSettings,
|
IOptions<SubsonicSettings> subsonicSettings,
|
||||||
ILogger<LocalLibraryService> logger)
|
ILogger<LocalLibraryService> logger)
|
||||||
{
|
{
|
||||||
_downloadDirectory = configuration["Library:DownloadPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "downloads");
|
_downloadDirectory = configuration["Library:DownloadPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "downloads");
|
||||||
_mappingFilePath = Path.Combine(_downloadDirectory, ".mappings.json");
|
_mappingFilePath = Path.Combine(_downloadDirectory, ".mappings.json");
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_subsonicSettings = subsonicSettings.Value;
|
_subsonicSettings = subsonicSettings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
if (!Directory.Exists(_downloadDirectory))
|
if (!Directory.Exists(_downloadDirectory))
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(_downloadDirectory);
|
Directory.CreateDirectory(_downloadDirectory);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId)
|
public async Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId)
|
||||||
{
|
{
|
||||||
var mappings = await LoadMappingsAsync();
|
var mappings = await LoadMappingsAsync();
|
||||||
var key = $"{externalProvider}:{externalId}";
|
var key = $"{externalProvider}:{externalId}";
|
||||||
|
|
||||||
if (mappings.TryGetValue(key, out var mapping) && File.Exists(mapping.LocalPath))
|
if (mappings.TryGetValue(key, out var mapping) && File.Exists(mapping.LocalPath))
|
||||||
{
|
{
|
||||||
return mapping.LocalPath;
|
return mapping.LocalPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RegisterDownloadedSongAsync(Song song, string localPath)
|
public async Task RegisterDownloadedSongAsync(Song song, string localPath)
|
||||||
{
|
{
|
||||||
if (song.ExternalProvider == null || song.ExternalId == null) return;
|
if (song.ExternalProvider == null || song.ExternalId == null) return;
|
||||||
|
|
||||||
// Load mappings first (this acquires the lock internally if needed)
|
// Load mappings first (this acquires the lock internally if needed)
|
||||||
var mappings = await LoadMappingsAsync();
|
var mappings = await LoadMappingsAsync();
|
||||||
|
|
||||||
await _lock.WaitAsync();
|
await _lock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var key = $"{song.ExternalProvider}:{song.ExternalId}";
|
var key = $"{song.ExternalProvider}:{song.ExternalId}";
|
||||||
|
|
||||||
mappings[key] = new LocalSongMapping
|
mappings[key] = new LocalSongMapping
|
||||||
{
|
{
|
||||||
ExternalProvider = song.ExternalProvider,
|
ExternalProvider = song.ExternalProvider,
|
||||||
ExternalId = song.ExternalId,
|
ExternalId = song.ExternalId,
|
||||||
LocalPath = localPath,
|
LocalPath = localPath,
|
||||||
Title = song.Title,
|
Title = song.Title,
|
||||||
Artist = song.Artist,
|
Artist = song.Artist,
|
||||||
Album = song.Album,
|
Album = song.Album,
|
||||||
DownloadedAt = DateTime.UtcNow
|
DownloadedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
await SaveMappingsAsync(mappings);
|
await SaveMappingsAsync(mappings);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_lock.Release();
|
_lock.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId)
|
public async Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId)
|
||||||
{
|
{
|
||||||
// For now, return null as we don't yet have integration
|
// For now, return null as we don't yet have integration
|
||||||
// with the Subsonic server to retrieve local ID after scan
|
// with the Subsonic server to retrieve local ID after scan
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public (bool isExternal, string? provider, string? externalId) ParseSongId(string songId)
|
public (bool isExternal, string? provider, string? externalId) ParseSongId(string songId)
|
||||||
{
|
{
|
||||||
var (isExternal, provider, _, externalId) = ParseExternalId(songId);
|
var (isExternal, provider, _, externalId) = ParseExternalId(songId);
|
||||||
return (isExternal, provider, externalId);
|
return (isExternal, provider, externalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id)
|
public (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id)
|
||||||
{
|
{
|
||||||
if (!id.StartsWith("ext-"))
|
if (!id.StartsWith("ext-"))
|
||||||
{
|
{
|
||||||
return (false, null, null, null);
|
return (false, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
var parts = id.Split('-');
|
var parts = id.Split('-');
|
||||||
|
|
||||||
// Known types for the new format
|
// Known types for the new format
|
||||||
var knownTypes = new HashSet<string> { "song", "album", "artist" };
|
var knownTypes = new HashSet<string> { "song", "album", "artist" };
|
||||||
|
|
||||||
// New format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259)
|
// New format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259)
|
||||||
// Only use new format if parts[2] is a known type
|
// Only use new format if parts[2] is a known type
|
||||||
if (parts.Length >= 4 && knownTypes.Contains(parts[2]))
|
if (parts.Length >= 4 && knownTypes.Contains(parts[2]))
|
||||||
{
|
{
|
||||||
var provider = parts[1];
|
var provider = parts[1];
|
||||||
var type = parts[2];
|
var type = parts[2];
|
||||||
var externalId = string.Join("-", parts.Skip(3)); // Handle IDs with dashes
|
var externalId = string.Join("-", parts.Skip(3)); // Handle IDs with dashes
|
||||||
return (true, provider, type, externalId);
|
return (true, provider, type, externalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy format: ext-{provider}-{id} (assumes "song" type for backward compatibility)
|
// Legacy format: ext-{provider}-{id} (assumes "song" type for backward compatibility)
|
||||||
// This handles both 3-part IDs and 4+ part IDs where parts[2] is NOT a known type
|
// This handles both 3-part IDs and 4+ part IDs where parts[2] is NOT a known type
|
||||||
if (parts.Length >= 3)
|
if (parts.Length >= 3)
|
||||||
{
|
{
|
||||||
var provider = parts[1];
|
var provider = parts[1];
|
||||||
var externalId = string.Join("-", parts.Skip(2)); // Everything after provider is the ID
|
var externalId = string.Join("-", parts.Skip(2)); // Everything after provider is the ID
|
||||||
return (true, provider, "song", externalId);
|
return (true, provider, "song", externalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (false, null, null, null);
|
return (false, null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Dictionary<string, LocalSongMapping>> LoadMappingsAsync()
|
private async Task<Dictionary<string, LocalSongMapping>> LoadMappingsAsync()
|
||||||
{
|
{
|
||||||
// Fast path: return cached mappings if available
|
// Fast path: return cached mappings if available
|
||||||
if (_mappings != null) return _mappings;
|
if (_mappings != null) return _mappings;
|
||||||
|
|
||||||
// Slow path: acquire lock to load from file (prevents race condition)
|
// Slow path: acquire lock to load from file (prevents race condition)
|
||||||
await _lock.WaitAsync();
|
await _lock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Double-check after acquiring lock
|
// Double-check after acquiring lock
|
||||||
if (_mappings != null) return _mappings;
|
if (_mappings != null) return _mappings;
|
||||||
|
|
||||||
if (File.Exists(_mappingFilePath))
|
if (File.Exists(_mappingFilePath))
|
||||||
{
|
{
|
||||||
var json = await File.ReadAllTextAsync(_mappingFilePath);
|
var json = await File.ReadAllTextAsync(_mappingFilePath);
|
||||||
_mappings = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, LocalSongMapping>>(json)
|
_mappings = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, LocalSongMapping>>(json)
|
||||||
?? new Dictionary<string, LocalSongMapping>();
|
?? new Dictionary<string, LocalSongMapping>();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_mappings = new Dictionary<string, LocalSongMapping>();
|
_mappings = new Dictionary<string, LocalSongMapping>();
|
||||||
}
|
}
|
||||||
|
|
||||||
return _mappings;
|
return _mappings;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_lock.Release();
|
_lock.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveMappingsAsync(Dictionary<string, LocalSongMapping> mappings)
|
private async Task SaveMappingsAsync(Dictionary<string, LocalSongMapping> mappings)
|
||||||
{
|
{
|
||||||
_mappings = mappings;
|
_mappings = mappings;
|
||||||
var json = System.Text.Json.JsonSerializer.Serialize(mappings, new System.Text.Json.JsonSerializerOptions
|
var json = System.Text.Json.JsonSerializer.Serialize(mappings, new System.Text.Json.JsonSerializerOptions
|
||||||
{
|
{
|
||||||
WriteIndented = true
|
WriteIndented = true
|
||||||
});
|
});
|
||||||
await File.WriteAllTextAsync(_mappingFilePath, json);
|
await File.WriteAllTextAsync(_mappingFilePath, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetDownloadDirectory() => _downloadDirectory;
|
public string GetDownloadDirectory() => _downloadDirectory;
|
||||||
|
|
||||||
public async Task<bool> TriggerLibraryScanAsync()
|
public async Task<bool> TriggerLibraryScanAsync()
|
||||||
{
|
{
|
||||||
// Debounce: avoid triggering too many successive scans
|
// Debounce: avoid triggering too many successive scans
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
if (now - _lastScanTrigger < _scanDebounceInterval)
|
if (now - _lastScanTrigger < _scanDebounceInterval)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Scan debounced - last scan was {Elapsed}s ago",
|
_logger.LogDebug("Scan debounced - last scan was {Elapsed}s ago",
|
||||||
(now - _lastScanTrigger).TotalSeconds);
|
(now - _lastScanTrigger).TotalSeconds);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastScanTrigger = now;
|
_lastScanTrigger = now;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Call Subsonic API to trigger a scan
|
// Call Subsonic API to trigger a scan
|
||||||
// Note: This endpoint works without authentication on most Subsonic/Navidrome servers
|
// Note: This endpoint works without authentication on most Subsonic/Navidrome servers
|
||||||
// when called from localhost. For remote servers requiring auth, this would need
|
// when called from localhost. For remote servers requiring auth, this would need
|
||||||
// to be refactored to accept credentials from the controller layer.
|
// to be refactored to accept credentials from the controller layer.
|
||||||
var url = $"{_subsonicSettings.Url}/rest/startScan?f=json";
|
var url = $"{_subsonicSettings.Url}/rest/startScan?f=json";
|
||||||
|
|
||||||
_logger.LogInformation("Triggering Subsonic library scan...");
|
_logger.LogInformation("Triggering Subsonic library scan...");
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
_logger.LogInformation("Subsonic scan triggered successfully: {Response}", content);
|
_logger.LogInformation("Subsonic scan triggered successfully: {Response}", content);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Failed to trigger Subsonic scan: {StatusCode} - Server may require authentication", response.StatusCode);
|
_logger.LogError("Failed to trigger Subsonic scan: {StatusCode} - Server may require authentication", response.StatusCode);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error triggering Subsonic library scan");
|
_logger.LogError(ex, "Error triggering Subsonic library scan");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ScanStatus?> GetScanStatusAsync()
|
public async Task<ScanStatus?> GetScanStatusAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Note: This endpoint works without authentication on most Subsonic/Navidrome servers
|
// Note: This endpoint works without authentication on most Subsonic/Navidrome servers
|
||||||
// when called from localhost.
|
// when called from localhost.
|
||||||
var url = $"{_subsonicSettings.Url}/rest/getScanStatus?f=json";
|
var url = $"{_subsonicSettings.Url}/rest/getScanStatus?f=json";
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
var doc = JsonDocument.Parse(content);
|
var doc = JsonDocument.Parse(content);
|
||||||
|
|
||||||
if (doc.RootElement.TryGetProperty("subsonic-response", out var subsonicResponse) &&
|
if (doc.RootElement.TryGetProperty("subsonic-response", out var subsonicResponse) &&
|
||||||
subsonicResponse.TryGetProperty("scanStatus", out var scanStatus))
|
subsonicResponse.TryGetProperty("scanStatus", out var scanStatus))
|
||||||
{
|
{
|
||||||
return new ScanStatus
|
return new ScanStatus
|
||||||
{
|
{
|
||||||
Scanning = scanStatus.TryGetProperty("scanning", out var scanning) && scanning.GetBoolean(),
|
Scanning = scanStatus.TryGetProperty("scanning", out var scanning) && scanning.GetBoolean(),
|
||||||
Count = scanStatus.TryGetProperty("count", out var count) ? count.GetInt32() : null
|
Count = scanStatus.TryGetProperty("count", out var count) ? count.GetInt32() : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error getting Subsonic scan status");
|
_logger.LogError(ex, "Error getting Subsonic scan status");
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents the mapping between an external song and its local file
|
/// Represents the mapping between an external song and its local file
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LocalSongMapping
|
public class LocalSongMapping
|
||||||
{
|
{
|
||||||
public string ExternalProvider { get; set; } = string.Empty;
|
public string ExternalProvider { get; set; } = string.Empty;
|
||||||
public string ExternalId { get; set; } = string.Empty;
|
public string ExternalId { get; set; } = string.Empty;
|
||||||
public string LocalPath { get; set; } = string.Empty;
|
public string LocalPath { get; set; } = string.Empty;
|
||||||
public string? LocalSubsonicId { get; set; }
|
public string? LocalSubsonicId { get; set; }
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
public string Artist { get; set; } = string.Empty;
|
public string Artist { get; set; } = string.Empty;
|
||||||
public string Album { get; set; } = string.Empty;
|
public string Album { get; set; } = string.Empty;
|
||||||
public DateTime DownloadedAt { get; set; }
|
public DateTime DownloadedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public class LrclibService
|
|||||||
ILogger<LrclibService> logger)
|
ILogger<LrclibService> logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
|
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.1 (https://github.com/SoPat712/allstarr)");
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ public class LrclibService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to deserialize cached lyrics");
|
_logger.LogError(ex, "Failed to deserialize cached lyrics");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ public class LrclibService
|
|||||||
$"track_name={Uri.EscapeDataString(trackName)}&" +
|
$"track_name={Uri.EscapeDataString(trackName)}&" +
|
||||||
$"artist_name={Uri.EscapeDataString(searchArtistName)}";
|
$"artist_name={Uri.EscapeDataString(searchArtistName)}";
|
||||||
|
|
||||||
_logger.LogInformation("Searching LRCLIB: {Url} (expecting {ArtistCount} artists)", searchUrl, artistNames.Length);
|
_logger.LogDebug("Searching LRCLIB: {Url} (expecting {ArtistCount} artists)", searchUrl, artistNames.Length);
|
||||||
|
|
||||||
var searchResponse = await _httpClient.GetAsync(searchUrl);
|
var searchResponse = await _httpClient.GetAsync(searchUrl);
|
||||||
|
|
||||||
@@ -157,12 +157,12 @@ public class LrclibService
|
|||||||
SyncedLyrics = bestMatch.SyncedLyrics
|
SyncedLyrics = bestMatch.SyncedLyrics
|
||||||
};
|
};
|
||||||
|
|
||||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
|
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Best match score too low ({Score:F1}), trying exact match", bestScore);
|
_logger.LogDebug("Best match score too low ({Score:F1}), trying exact match", bestScore);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ public class LrclibService
|
|||||||
SyncedLyrics = lyrics.SyncedLyrics
|
SyncedLyrics = lyrics.SyncedLyrics
|
||||||
};
|
};
|
||||||
|
|
||||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(exactResult, JsonOptions), TimeSpan.FromDays(30));
|
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(exactResult, JsonOptions), CacheExtensions.LyricsTTL);
|
||||||
|
|
||||||
_logger.LogInformation("Retrieved lyrics via exact match for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id);
|
_logger.LogInformation("Retrieved lyrics via exact match for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id);
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ public class LrclibService
|
|||||||
}
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch lyrics from LRCLIB for {Artist} - {Track}", artistName, trackName);
|
_logger.LogError(ex, "Failed to fetch lyrics from LRCLIB for {Artist} - {Track}", artistName, trackName);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -350,7 +350,7 @@ public class LrclibService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to fetch cached lyrics for {Artist} - {Track}", artistName, trackName);
|
_logger.LogError(ex, "Failed to fetch cached lyrics for {Artist} - {Track}", artistName, trackName);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,7 +368,7 @@ public class LrclibService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to deserialize cached lyrics");
|
_logger.LogError(ex, "Failed to deserialize cached lyrics");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +404,7 @@ public class LrclibService
|
|||||||
SyncedLyrics = lyrics.SyncedLyrics
|
SyncedLyrics = lyrics.SyncedLyrics
|
||||||
};
|
};
|
||||||
|
|
||||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
|
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
using allstarr.Models.Lyrics;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Lyrics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates lyrics fetching from multiple sources with priority-based fallback.
|
||||||
|
/// Priority order: Spotify → LyricsPlus → LRCLib
|
||||||
|
/// Note: Jellyfin local lyrics are handled by the controller before calling this orchestrator.
|
||||||
|
/// </summary>
|
||||||
|
public class LyricsOrchestrator
|
||||||
|
{
|
||||||
|
private readonly SpotifyLyricsService _spotifyLyrics;
|
||||||
|
private readonly LyricsPlusService _lyricsPlus;
|
||||||
|
private readonly LrclibService _lrclib;
|
||||||
|
private readonly SpotifyApiSettings _spotifySettings;
|
||||||
|
private readonly ILogger<LyricsOrchestrator> _logger;
|
||||||
|
|
||||||
|
public LyricsOrchestrator(
|
||||||
|
SpotifyLyricsService spotifyLyrics,
|
||||||
|
LyricsPlusService lyricsPlus,
|
||||||
|
LrclibService lrclib,
|
||||||
|
IOptions<SpotifyApiSettings> spotifySettings,
|
||||||
|
ILogger<LyricsOrchestrator> logger)
|
||||||
|
{
|
||||||
|
_spotifyLyrics = spotifyLyrics;
|
||||||
|
_lyricsPlus = lyricsPlus;
|
||||||
|
_lrclib = lrclib;
|
||||||
|
_spotifySettings = spotifySettings.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches lyrics with automatic fallback through all available sources.
|
||||||
|
/// Note: Jellyfin local lyrics are handled by the controller before calling this.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="trackName">Track title</param>
|
||||||
|
/// <param name="artistNames">Artist names (can be multiple)</param>
|
||||||
|
/// <param name="albumName">Album name</param>
|
||||||
|
/// <param name="durationSeconds">Track duration in seconds</param>
|
||||||
|
/// <param name="spotifyTrackId">Spotify track ID (if available)</param>
|
||||||
|
/// <returns>Lyrics info or null if not found</returns>
|
||||||
|
public async Task<LyricsInfo?> GetLyricsAsync(
|
||||||
|
string trackName,
|
||||||
|
string[] artistNames,
|
||||||
|
string? albumName,
|
||||||
|
int durationSeconds,
|
||||||
|
string? spotifyTrackId = null)
|
||||||
|
{
|
||||||
|
var artistName = string.Join(", ", artistNames);
|
||||||
|
|
||||||
|
_logger.LogInformation("🎵 Fetching lyrics for: {Artist} - {Track}", artistName, trackName);
|
||||||
|
|
||||||
|
// 1. Try Spotify lyrics (if Spotify ID provided)
|
||||||
|
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||||
|
{
|
||||||
|
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
|
||||||
|
if (spotifyLyrics != null)
|
||||||
|
{
|
||||||
|
return spotifyLyrics;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try LyricsPlus
|
||||||
|
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
||||||
|
if (lyricsPlusLyrics != null)
|
||||||
|
{
|
||||||
|
return lyricsPlusLyrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try LRCLib
|
||||||
|
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
||||||
|
if (lrclibLyrics != null)
|
||||||
|
{
|
||||||
|
return lrclibLyrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("❌ No lyrics found for: {Artist} - {Track}", artistName, trackName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prefetches lyrics in the background (for cache warming).
|
||||||
|
/// Skips Jellyfin local since we don't have an itemId.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> PrefetchLyricsAsync(
|
||||||
|
string trackName,
|
||||||
|
string[] artistNames,
|
||||||
|
string? albumName,
|
||||||
|
int durationSeconds,
|
||||||
|
string? spotifyTrackId = null)
|
||||||
|
{
|
||||||
|
var artistName = string.Join(", ", artistNames);
|
||||||
|
|
||||||
|
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Track}", artistName, trackName);
|
||||||
|
|
||||||
|
// 1. Try Spotify lyrics (if Spotify ID provided)
|
||||||
|
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||||
|
{
|
||||||
|
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
|
||||||
|
if (spotifyLyrics != null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try LyricsPlus
|
||||||
|
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
||||||
|
if (lyricsPlusLyrics != null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try LRCLib
|
||||||
|
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
||||||
|
if (lrclibLyrics != null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("No lyrics found for prefetch: {Artist} - {Track}", artistName, trackName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Private Helper Methods
|
||||||
|
|
||||||
|
private async Task<LyricsInfo?> TrySpotifyLyrics(string spotifyTrackId, string artistName, string trackName)
|
||||||
|
{
|
||||||
|
if (!_spotifySettings.Enabled)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Spotify API not enabled, skipping Spotify lyrics");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Validate Spotify ID format
|
||||||
|
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
||||||
|
|
||||||
|
if (cleanSpotifyId.Length != 22 || cleanSpotifyId.Contains(":") || cleanSpotifyId.Contains("local"))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Invalid Spotify ID format: {SpotifyId}, skipping", spotifyTrackId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("→ Trying Spotify lyrics for track ID: {SpotifyId}", cleanSpotifyId);
|
||||||
|
|
||||||
|
var spotifyLyrics = await _spotifyLyrics.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
||||||
|
|
||||||
|
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines, type: {SyncType})",
|
||||||
|
artistName, trackName, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
||||||
|
|
||||||
|
return _spotifyLyrics.ToLyricsInfo(spotifyLyrics);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching Spotify lyrics for track ID {SpotifyId}", spotifyTrackId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<LyricsInfo?> TryLyricsPlusLyrics(
|
||||||
|
string trackName,
|
||||||
|
string[] artistNames,
|
||||||
|
string? albumName,
|
||||||
|
int durationSeconds,
|
||||||
|
string artistName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("→ Trying LyricsPlus for: {Artist} - {Track}", artistName, trackName);
|
||||||
|
|
||||||
|
var lyrics = await _lyricsPlus.GetLyricsAsync(trackName, artistNames, albumName, durationSeconds);
|
||||||
|
|
||||||
|
if (lyrics != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✓ Found LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
|
||||||
|
return lyrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("No LyricsPlus lyrics found for {Artist} - {Track}", artistName, trackName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<LyricsInfo?> TryLrclibLyrics(
|
||||||
|
string trackName,
|
||||||
|
string[] artistNames,
|
||||||
|
string? albumName,
|
||||||
|
int durationSeconds,
|
||||||
|
string artistName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("→ Trying LRCLib for: {Artist} - {Track}", artistName, trackName);
|
||||||
|
|
||||||
|
var lyrics = await _lrclib.GetLyricsAsync(trackName, artistNames, albumName ?? string.Empty, durationSeconds);
|
||||||
|
|
||||||
|
if (lyrics != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✓ Found LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
|
||||||
|
return lyrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("No LRCLib lyrics found for {Artist} - {Track}", artistName, trackName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using allstarr.Models.Lyrics;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Lyrics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for fetching lyrics from LyricsPlus API (https://lyricsplus.prjktla.workers.dev)
|
||||||
|
/// Supports multiple sources: Apple Music, Spotify, Musixmatch, and more
|
||||||
|
/// </summary>
|
||||||
|
public class LyricsPlusService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly RedisCacheService _cache;
|
||||||
|
private readonly ILogger<LyricsPlusService> _logger;
|
||||||
|
private const string BaseUrl = "https://lyricsplus.prjktla.workers.dev/v2/lyrics/get";
|
||||||
|
|
||||||
|
public LyricsPlusService(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
RedisCacheService cache,
|
||||||
|
ILogger<LyricsPlusService> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.1 (https://github.com/SoPat712/allstarr)");
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string artistName, string? albumName, int durationSeconds)
|
||||||
|
{
|
||||||
|
return await GetLyricsAsync(trackName, new[] { artistName }, albumName, durationSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string[] artistNames, string? albumName, int durationSeconds)
|
||||||
|
{
|
||||||
|
// Validate input parameters
|
||||||
|
if (string.IsNullOrWhiteSpace(trackName) || artistNames == null || artistNames.Length == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Invalid parameters for LyricsPlus search: trackName={TrackName}, artistCount={ArtistCount}",
|
||||||
|
trackName, artistNames?.Length ?? 0);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var artistName = string.Join(", ", artistNames);
|
||||||
|
var cacheKey = $"lyricsplus:{artistName}:{trackName}:{albumName}:{durationSeconds}";
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
var cached = await _cache.GetStringAsync(cacheKey);
|
||||||
|
if (!string.IsNullOrEmpty(cached))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<LyricsInfo>(cached, JsonOptions);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to deserialize cached LyricsPlus lyrics");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Build URL with query parameters
|
||||||
|
var url = $"{BaseUrl}?title={Uri.EscapeDataString(trackName)}&artist={Uri.EscapeDataString(artistName)}";
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(albumName))
|
||||||
|
{
|
||||||
|
url += $"&album={Uri.EscapeDataString(albumName)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationSeconds > 0)
|
||||||
|
{
|
||||||
|
url += $"&duration={durationSeconds}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sources: apple, lyricsplus, musixmatch, spotify, musixmatch-word
|
||||||
|
url += "&source=apple,lyricsplus,musixmatch,spotify,musixmatch-word";
|
||||||
|
|
||||||
|
_logger.LogDebug("Fetching lyrics from LyricsPlus: {Url}", url);
|
||||||
|
|
||||||
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Lyrics not found on LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var lyricsResponse = JsonSerializer.Deserialize<LyricsPlusResponse>(json, JsonOptions);
|
||||||
|
|
||||||
|
if (lyricsResponse == null || lyricsResponse.Lyrics == null || lyricsResponse.Lyrics.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Empty lyrics response from LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to LyricsInfo format
|
||||||
|
var result = ConvertToLyricsInfo(lyricsResponse, trackName, artistName, albumName, durationSeconds);
|
||||||
|
|
||||||
|
if (result != null)
|
||||||
|
{
|
||||||
|
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL);
|
||||||
|
_logger.LogInformation("✓ Retrieved lyrics from LyricsPlus for {Artist} - {Track} (type: {Type}, source: {Source})",
|
||||||
|
artistName, trackName, lyricsResponse.Type, lyricsResponse.Metadata?.Source);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to fetch lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LyricsInfo? ConvertToLyricsInfo(LyricsPlusResponse response, string trackName, string artistName, string? albumName, int durationSeconds)
|
||||||
|
{
|
||||||
|
if (response.Lyrics == null || response.Lyrics.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? syncedLyrics = null;
|
||||||
|
string? plainLyrics = null;
|
||||||
|
|
||||||
|
// Convert based on type
|
||||||
|
if (response.Type == "Word")
|
||||||
|
{
|
||||||
|
// Word-level timing - convert to line-level LRC
|
||||||
|
syncedLyrics = ConvertWordTimingToLrc(response.Lyrics);
|
||||||
|
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
|
||||||
|
}
|
||||||
|
else if (response.Type == "Line")
|
||||||
|
{
|
||||||
|
// Line-level timing - convert to LRC
|
||||||
|
syncedLyrics = ConvertLineTimingToLrc(response.Lyrics);
|
||||||
|
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Static or unknown type - just plain text
|
||||||
|
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LyricsInfo
|
||||||
|
{
|
||||||
|
TrackName = trackName,
|
||||||
|
ArtistName = artistName,
|
||||||
|
AlbumName = albumName ?? string.Empty,
|
||||||
|
Duration = durationSeconds,
|
||||||
|
Instrumental = false,
|
||||||
|
PlainLyrics = plainLyrics,
|
||||||
|
SyncedLyrics = syncedLyrics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ConvertLineTimingToLrc(List<LyricsPlusLine> lines)
|
||||||
|
{
|
||||||
|
var lrcLines = new List<string>();
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
if (line.Time.HasValue)
|
||||||
|
{
|
||||||
|
var timestamp = TimeSpan.FromMilliseconds(line.Time.Value);
|
||||||
|
var mm = (int)timestamp.TotalMinutes;
|
||||||
|
var ss = timestamp.Seconds;
|
||||||
|
var cs = timestamp.Milliseconds / 10; // Convert to centiseconds
|
||||||
|
|
||||||
|
lrcLines.Add($"[{mm:D2}:{ss:D2}.{cs:D2}]{line.Text}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No timing, just add the text
|
||||||
|
lrcLines.Add(line.Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("\n", lrcLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ConvertWordTimingToLrc(List<LyricsPlusLine> lines)
|
||||||
|
{
|
||||||
|
// For word-level timing, we use the line start time
|
||||||
|
// (word-level detail is in syllabus array but we simplify to line-level for LRC)
|
||||||
|
return ConvertLineTimingToLrc(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
|
||||||
|
private class LyricsPlusResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string Type { get; set; } = string.Empty; // "Word", "Line", or "Static"
|
||||||
|
|
||||||
|
[JsonPropertyName("metadata")]
|
||||||
|
public LyricsPlusMetadata? Metadata { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("lyrics")]
|
||||||
|
public List<LyricsPlusLine> Lyrics { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LyricsPlusMetadata
|
||||||
|
{
|
||||||
|
[JsonPropertyName("source")]
|
||||||
|
public string? Source { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("title")]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("language")]
|
||||||
|
public string? Language { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LyricsPlusLine
|
||||||
|
{
|
||||||
|
[JsonPropertyName("time")]
|
||||||
|
public long? Time { get; set; } // Milliseconds
|
||||||
|
|
||||||
|
[JsonPropertyName("duration")]
|
||||||
|
public long? Duration { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("text")]
|
||||||
|
public string Text { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("syllabus")]
|
||||||
|
public List<LyricsPlusSyllable>? Syllabus { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LyricsPlusSyllable
|
||||||
|
{
|
||||||
|
[JsonPropertyName("time")]
|
||||||
|
public long Time { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("duration")]
|
||||||
|
public long Duration { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("text")]
|
||||||
|
public string Text { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
// Run initial prefetch
|
// Run initial prefetch
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Running initial lyrics prefetch on startup");
|
_logger.LogDebug("Running initial lyrics prefetch on startup");
|
||||||
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -115,7 +115,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
string playlistName,
|
string playlistName,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Prefetching lyrics for playlist: {Playlist}", playlistName);
|
_logger.LogDebug("Prefetching lyrics for playlist: {Playlist}", playlistName);
|
||||||
|
|
||||||
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||||
if (tracks.Count == 0)
|
if (tracks.Count == 0)
|
||||||
@@ -156,7 +156,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}",
|
_logger.LogInformation("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}",
|
||||||
spotifyToJellyfinId.Count, playlistName);
|
spotifyToJellyfinId.Count, playlistName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
if (!string.IsNullOrEmpty(existingLyrics))
|
if (!string.IsNullOrEmpty(existingLyrics))
|
||||||
{
|
{
|
||||||
cached++;
|
cached++;
|
||||||
_logger.LogDebug("✓ Lyrics already cached for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
_logger.LogInformation("✓ Lyrics already cached for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
if (hasLocalLyrics)
|
if (hasLocalLyrics)
|
||||||
{
|
{
|
||||||
cached++;
|
cached++;
|
||||||
_logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch",
|
_logger.LogWarning("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch",
|
||||||
track.PrimaryArtist, track.Title);
|
track.PrimaryArtist, track.Title);
|
||||||
|
|
||||||
// Remove any previously cached LRCLib lyrics for this track
|
// Remove any previously cached LRCLib lyrics for this track
|
||||||
@@ -239,12 +239,12 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to prefetch lyrics for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
_logger.LogError(ex, "Failed to prefetch lyrics for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||||
missing++;
|
missing++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
|
_logger.LogDebug("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
|
||||||
playlistName, fetched, cached, missing);
|
playlistName, fetched, cached, missing);
|
||||||
|
|
||||||
return (fetched, cached, missing);
|
return (fetched, cached, missing);
|
||||||
@@ -264,7 +264,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to save lyrics to file for {Artist} - {Track}", artist, title);
|
_logger.LogError(ex, "Failed to save lyrics to file for {Artist} - {Track}", artist, title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +277,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
{
|
{
|
||||||
if (!Directory.Exists(_lyricsCacheDir))
|
if (!Directory.Exists(_lyricsCacheDir))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Lyrics cache directory does not exist, skipping cache warming");
|
_logger.LogWarning("Lyrics cache directory does not exist, skipping cache warming");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("🔥 Warming lyrics cache from {Count} files...", files.Length);
|
_logger.LogDebug("🔥 Warming lyrics cache from {Count} files...", files.Length);
|
||||||
|
|
||||||
var loaded = 0;
|
var loaded = 0;
|
||||||
foreach (var file in files)
|
foreach (var file in files)
|
||||||
@@ -301,17 +301,17 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
if (lyrics != null)
|
if (lyrics != null)
|
||||||
{
|
{
|
||||||
var cacheKey = $"lyrics:{lyrics.ArtistName}:{lyrics.TrackName}:{lyrics.AlbumName}:{lyrics.Duration}";
|
var cacheKey = $"lyrics:{lyrics.ArtistName}:{lyrics.TrackName}:{lyrics.AlbumName}:{lyrics.Duration}";
|
||||||
await _cache.SetStringAsync(cacheKey, json, TimeSpan.FromDays(30));
|
await _cache.SetStringAsync(cacheKey, json, CacheExtensions.LyricsTTL);
|
||||||
loaded++;
|
loaded++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to load lyrics from file {File}", Path.GetFileName(file));
|
_logger.LogError(ex, "Failed to load lyrics from file {File}", Path.GetFileName(file));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("✅ Warmed {Count} lyrics from file cache", loaded);
|
_logger.LogDebug("✅ Warmed {Count} lyrics from file cache", loaded);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -351,7 +351,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to remove cached lyrics for {Artist} - {Track}", artist, title);
|
_logger.LogError(ex, "Failed to remove cached lyrics for {Artist} - {Track}", artist, title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +375,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
|
|
||||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)",
|
_logger.LogDebug("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)",
|
||||||
artistName, trackTitle, spotifyLyrics.Lines.Count);
|
artistName, trackTitle, spotifyLyrics.Lines.Count);
|
||||||
return spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
return spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||||
}
|
}
|
||||||
@@ -384,7 +384,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track {SpotifyId}", spotifyTrackId);
|
_logger.LogError(ex, "Error fetching Spotify lyrics for track {SpotifyId}", spotifyTrackId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -423,7 +423,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Error checking Jellyfin lyrics for item {ItemId}", jellyfinItemId);
|
_logger.LogError(ex, "Error checking Jellyfin lyrics for item {ItemId}", jellyfinItemId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,7 +528,7 @@ public class LyricsPrefetchService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Error checking for local Jellyfin lyrics for Spotify track {SpotifyId}", spotifyTrackId);
|
_logger.LogError(ex, "Error checking for local Jellyfin lyrics for Spotify track {SpotifyId}", spotifyTrackId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,16 +167,14 @@ public class LyricsStartupValidator : BaseStartupValidator
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_spotifySettings.ClientId))
|
if (!_spotifySettings.Enabled)
|
||||||
{
|
{
|
||||||
WriteStatus("Spotify API", "NOT CONFIGURED", ConsoleColor.Yellow);
|
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Gray);
|
||||||
WriteDetail("Set SpotifyApi__ClientId to enable");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
|
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
|
||||||
WriteDetail($"Client ID: {_spotifySettings.ClientId.Substring(0, Math.Min(8, _spotifySettings.ClientId.Length))}...");
|
WriteDetail("Note: Spotify API is used for track matching and lyrics");
|
||||||
WriteDetail("Note: Spotify API is used for track matching, not lyrics");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -50,13 +50,13 @@ public class SpotifyLyricsService
|
|||||||
{
|
{
|
||||||
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Spotify API not enabled or no session cookie configured");
|
_logger.LogInformation("Spotify API not enabled or no session cookie configured");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(_settings.LyricsApiUrl))
|
if (string.IsNullOrEmpty(_settings.LyricsApiUrl))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Spotify lyrics API URL not configured");
|
_logger.LogInformation("Spotify lyrics API URL not configured");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ public class SpotifyLyricsService
|
|||||||
|
|
||||||
if (result != null)
|
if (result != null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
|
_logger.LogDebug("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
|
||||||
spotifyTrackId, result.Lines.Count);
|
spotifyTrackId, result.Lines.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ public class SpotifyLyricsService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Error fetching lyrics from sidecar API for track {TrackId}", spotifyTrackId);
|
_logger.LogError(ex, "Error fetching lyrics from sidecar API for track {TrackId}", spotifyTrackId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,14 +110,14 @@ public class SpotifyLyricsService
|
|||||||
{
|
{
|
||||||
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Spotify lyrics search skipped: API not enabled or no session cookie");
|
_logger.LogInformation("Spotify lyrics search skipped: API not enabled or no session cookie");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The sidecar API only supports track ID, not search
|
// The sidecar API only supports track ID, not search
|
||||||
// So we skip Spotify lyrics for search-based requests
|
// So we skip Spotify lyrics for search-based requests
|
||||||
// LRCLib will be used as fallback
|
// LRCLib will be used as fallback
|
||||||
_logger.LogDebug("Spotify lyrics search by metadata not supported with sidecar API, skipping");
|
_logger.LogWarning("Spotify lyrics search by metadata not supported with sidecar API, skipping");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +169,7 @@ public class SpotifyLyricsService
|
|||||||
// Check for error
|
// Check for error
|
||||||
if (root.TryGetProperty("error", out var error) && error.GetBoolean())
|
if (root.TryGetProperty("error", out var error) && error.GetBoolean())
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Sidecar API returned error for track {TrackId}", trackId);
|
_logger.LogError("Sidecar API returned error for track {TrackId}", trackId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public class MusicBrainzService
|
|||||||
ILogger<MusicBrainzService> logger)
|
ILogger<MusicBrainzService> logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
|
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.1 (https://github.com/SoPat712/allstarr)");
|
||||||
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
@@ -92,6 +92,7 @@ public class MusicBrainzService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Searches for recordings by title and artist.
|
/// Searches for recordings by title and artist.
|
||||||
|
/// Note: Search API doesn't return genres, only MBIDs. Use LookupByMbidAsync to get genres.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
|
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
|
||||||
{
|
{
|
||||||
@@ -107,7 +108,8 @@ public class MusicBrainzService
|
|||||||
// Build Lucene query
|
// Build Lucene query
|
||||||
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
|
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
|
||||||
var encodedQuery = Uri.EscapeDataString(query);
|
var encodedQuery = Uri.EscapeDataString(query);
|
||||||
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}&inc=genres+tags";
|
// Note: Search API doesn't support inc=genres, only returns basic info + MBIDs
|
||||||
|
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}";
|
||||||
|
|
||||||
_logger.LogDebug("MusicBrainz search: {Url}", url);
|
_logger.LogDebug("MusicBrainz search: {Url}", url);
|
||||||
|
|
||||||
@@ -128,7 +130,7 @@ public class MusicBrainzService
|
|||||||
return new List<MusicBrainzRecording>();
|
return new List<MusicBrainzRecording>();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Found {Count} MusicBrainz recordings for: {Title} - {Artist}",
|
_logger.LogDebug("Found {Count} MusicBrainz recordings for: {Title} - {Artist}",
|
||||||
result.Recordings.Count, title, artist);
|
result.Recordings.Count, title, artist);
|
||||||
|
|
||||||
return result.Recordings;
|
return result.Recordings;
|
||||||
@@ -140,9 +142,56 @@ public class MusicBrainzService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a recording by MBID to get full details including genres.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<MusicBrainzRecording?> LookupByMbidAsync(string mbid)
|
||||||
|
{
|
||||||
|
if (!_settings.Enabled)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RateLimitAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"{_settings.BaseUrl}/recording/{mbid}?fmt=json&inc=artists+releases+release-groups+genres+tags";
|
||||||
|
_logger.LogDebug("MusicBrainz MBID lookup: {Url}", url);
|
||||||
|
|
||||||
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("MusicBrainz MBID lookup failed: {StatusCode}", response.StatusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var recording = JsonSerializer.Deserialize<MusicBrainzRecording>(json, JsonOptions);
|
||||||
|
|
||||||
|
if (recording == null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No MusicBrainz recording found for MBID: {Mbid}", mbid);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string?>();
|
||||||
|
_logger.LogInformation("✓ Found MusicBrainz recording for MBID {Mbid}: {Title} by {Artist} (Genres: {Genres})",
|
||||||
|
mbid, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
|
||||||
|
|
||||||
|
return recording;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error looking up MBID {Mbid} in MusicBrainz", mbid);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enriches a song with genre information from MusicBrainz.
|
/// Enriches a song with genre information from MusicBrainz.
|
||||||
/// First tries ISRC lookup, then falls back to title/artist search.
|
/// First tries ISRC lookup, then falls back to title/artist search + MBID lookup.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
|
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
|
||||||
{
|
{
|
||||||
@@ -153,17 +202,23 @@ public class MusicBrainzService
|
|||||||
|
|
||||||
MusicBrainzRecording? recording = null;
|
MusicBrainzRecording? recording = null;
|
||||||
|
|
||||||
// Try ISRC lookup first (most accurate)
|
// Try ISRC lookup first (most accurate and includes genres)
|
||||||
if (!string.IsNullOrEmpty(isrc))
|
if (!string.IsNullOrEmpty(isrc))
|
||||||
{
|
{
|
||||||
recording = await LookupByIsrcAsync(isrc);
|
recording = await LookupByIsrcAsync(isrc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to search if ISRC lookup failed or no ISRC provided
|
// Fall back to search + MBID lookup if ISRC lookup failed or no ISRC provided
|
||||||
if (recording == null)
|
if (recording == null)
|
||||||
{
|
{
|
||||||
var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
|
var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
|
||||||
recording = recordings.FirstOrDefault();
|
var searchResult = recordings.FirstOrDefault();
|
||||||
|
|
||||||
|
// If we found a recording from search, do a full lookup by MBID to get genres
|
||||||
|
if (searchResult != null && !string.IsNullOrEmpty(searchResult.Id))
|
||||||
|
{
|
||||||
|
recording = await LookupByMbidAsync(searchResult.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recording == null)
|
if (recording == null)
|
||||||
@@ -186,7 +241,7 @@ public class MusicBrainzService
|
|||||||
.ToList());
|
.ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Found {Count} genres for {Title} - {Artist}: {Genres}",
|
_logger.LogDebug("Found {Count} genres for {Title} - {Artist}: {Genres}",
|
||||||
genres.Count, title, artist, string.Join(", ", genres));
|
genres.Count, title, artist, string.Join(", ", genres));
|
||||||
|
|
||||||
return genres;
|
return genres;
|
||||||
|
|||||||
@@ -92,18 +92,18 @@ public class QobuzBundleService
|
|||||||
|
|
||||||
// Step 1: Get the bundle URL from login page
|
// Step 1: Get the bundle URL from login page
|
||||||
var bundleUrl = await GetBundleUrlAsync();
|
var bundleUrl = await GetBundleUrlAsync();
|
||||||
_logger.LogInformation("Found bundle URL: {BundleUrl}", bundleUrl);
|
_logger.LogDebug("Found bundle URL: {BundleUrl}", bundleUrl);
|
||||||
|
|
||||||
// Step 2: Download the bundle JavaScript
|
// Step 2: Download the bundle JavaScript
|
||||||
var bundleJs = await DownloadBundleAsync(bundleUrl);
|
var bundleJs = await DownloadBundleAsync(bundleUrl);
|
||||||
|
|
||||||
// Step 3: Extract App ID
|
// Step 3: Extract App ID
|
||||||
_cachedAppId = ExtractAppId(bundleJs);
|
_cachedAppId = ExtractAppId(bundleJs);
|
||||||
_logger.LogInformation("Extracted App ID: {AppId}", _cachedAppId);
|
_logger.LogDebug("Extracted App ID: {AppId}", _cachedAppId);
|
||||||
|
|
||||||
// Step 4: Extract secrets (they are base64 encoded in the bundle)
|
// Step 4: Extract secrets (they are base64 encoded in the bundle)
|
||||||
_cachedSecrets = ExtractSecrets(bundleJs);
|
_cachedSecrets = ExtractSecrets(bundleJs);
|
||||||
_logger.LogInformation("Extracted {Count} secrets", _cachedSecrets.Count);
|
_logger.LogDebug("Extracted {Count} secrets", _cachedSecrets.Count);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -253,7 +253,7 @@ public class QobuzBundleService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to decode secret for timezone {Timezone}", kvp.Key);
|
_logger.LogError(ex, "Failed to decode secret for timezone {Timezone}", kvp.Key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ public class QobuzDownloadService : BaseDownloadService
|
|||||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||||
? Path.Combine(DownloadPath, "cache")
|
? Path.Combine(DownloadPath, "cache")
|
||||||
: Path.Combine(DownloadPath, "permanent");
|
: Path.Combine(DownloadPath, "permanent");
|
||||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "qobuz", trackId);
|
||||||
|
|
||||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||||
EnsureDirectoryExists(albumFolder);
|
EnsureDirectoryExists(albumFolder);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
|||||||
using allstarr.Models.Download;
|
using allstarr.Models.Download;
|
||||||
using allstarr.Models.Search;
|
using allstarr.Models.Search;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
|
using allstarr.Services.Common;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
private readonly SubsonicSettings _settings;
|
private readonly SubsonicSettings _settings;
|
||||||
private readonly QobuzBundleService _bundleService;
|
private readonly QobuzBundleService _bundleService;
|
||||||
private readonly ILogger<QobuzMetadataService> _logger;
|
private readonly ILogger<QobuzMetadataService> _logger;
|
||||||
|
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||||
private readonly string? _userAuthToken;
|
private readonly string? _userAuthToken;
|
||||||
private readonly string? _userId;
|
private readonly string? _userId;
|
||||||
|
|
||||||
@@ -28,12 +30,14 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
IOptions<SubsonicSettings> settings,
|
IOptions<SubsonicSettings> settings,
|
||||||
IOptions<QobuzSettings> qobuzSettings,
|
IOptions<QobuzSettings> qobuzSettings,
|
||||||
QobuzBundleService bundleService,
|
QobuzBundleService bundleService,
|
||||||
ILogger<QobuzMetadataService> logger)
|
ILogger<QobuzMetadataService> logger,
|
||||||
|
GenreEnrichmentService? genreEnrichment = null)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_bundleService = bundleService;
|
_bundleService = bundleService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_genreEnrichment = genreEnrichment;
|
||||||
|
|
||||||
var qobuzConfig = qobuzSettings.Value;
|
var qobuzConfig = qobuzSettings.Value;
|
||||||
_userAuthToken = qobuzConfig.UserAuthToken;
|
_userAuthToken = qobuzConfig.UserAuthToken;
|
||||||
@@ -177,7 +181,26 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
if (track.TryGetProperty("error", out _)) return null;
|
if (track.TryGetProperty("error", out _)) return null;
|
||||||
|
|
||||||
return ParseQobuzTrackFull(track);
|
var song = ParseQobuzTrackFull(track);
|
||||||
|
|
||||||
|
// Enrich with MusicBrainz genres if missing
|
||||||
|
if (_genreEnrichment != null && song != null && string.IsNullOrEmpty(song.Genre))
|
||||||
|
{
|
||||||
|
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _genreEnrichment.EnrichSongGenreAsync(song);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to enrich genre for {Title}", song.Title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return song;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ public class SpotifyApiClient : IDisposable
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_settings.SessionCookie))
|
if (string.IsNullOrEmpty(_settings.SessionCookie))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No Spotify session cookie configured");
|
_logger.LogInformation("No Spotify session cookie configured");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +349,17 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
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)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
||||||
@@ -519,7 +530,7 @@ public class SpotifyApiClient : IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to parse GraphQL track");
|
_logger.LogError(ex, "Failed to parse GraphQL track");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -735,6 +746,18 @@ public class SpotifyApiClient : IDisposable
|
|||||||
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
||||||
string searchName,
|
string searchName,
|
||||||
CancellationToken cancellationToken = default)
|
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);
|
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||||
if (string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
@@ -744,61 +767,204 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
try
|
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 playlists = new List<SpotifyPlaylist>();
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
const int limit = 50;
|
const int limit = 50;
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}";
|
// 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);
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode) break;
|
|
||||||
|
// 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.LogError("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
|
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;
|
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())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
itemCount++;
|
||||||
|
|
||||||
// Check if name matches (case-insensitive)
|
if (!item.TryGetProperty("item", out var playlistItem) ||
|
||||||
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
!playlistItem.TryGetProperty("data", out var playlist))
|
||||||
{
|
{
|
||||||
playlists.Add(new SpotifyPlaylist
|
continue;
|
||||||
{
|
|
||||||
SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "",
|
|
||||||
Name = itemName,
|
|
||||||
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
|
||||||
TotalTracks = item.TryGetProperty("tracks", out var tracks) &&
|
|
||||||
tracks.TryGetProperty("total", out var total)
|
|
||||||
? total.GetInt32() : 0,
|
|
||||||
SnapshotId = item.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (items.GetArrayLength() < limit) break;
|
if (itemCount < limit) break;
|
||||||
offset += limit;
|
offset += limit;
|
||||||
|
|
||||||
if (_settings.RateLimitDelayMs > 0)
|
// Add delay between pages to avoid rate limiting
|
||||||
{
|
// Library fetching can be aggressive, so use a longer delay
|
||||||
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
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.LogDebug("Found {Count} playlists{Filter} via GraphQL",
|
||||||
|
playlists.Count,
|
||||||
|
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
||||||
return playlists;
|
return playlists;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error searching user playlists for '{SearchName}'", searchName);
|
_logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL",
|
||||||
|
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
||||||
return new List<SpotifyPlaylist>();
|
return new List<SpotifyPlaylist>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
if (_spotifyApiSettings.Value.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.Value.SessionCookie))
|
if (_spotifyApiSettings.Value.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.Value.SessionCookie))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("SpotifyApi is enabled with session cookie - using direct Spotify API instead of Jellyfin scraping");
|
_logger.LogInformation("SpotifyApi is enabled with session cookie - using direct Spotify API instead of Jellyfin scraping");
|
||||||
_logger.LogInformation("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists.");
|
_logger.LogDebug("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists.");
|
||||||
_logger.LogInformation("========================================");
|
_logger.LogInformation("========================================");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
|
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Jellyfin URL or API key not configured, Spotify playlist injection disabled");
|
_logger.LogInformation("Jellyfin URL or API key not configured, Spotify playlist injection disabled");
|
||||||
_logger.LogInformation("========================================");
|
_logger.LogInformation("========================================");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Skipping startup fetch - already have cached files");
|
_logger.LogWarning("Skipping startup fetch - already have cached files");
|
||||||
_hasRunOnce = true;
|
_hasRunOnce = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,7 +194,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
if (File.Exists(filePath))
|
if (File.Exists(filePath))
|
||||||
{
|
{
|
||||||
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
|
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
|
||||||
_logger.LogInformation(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
|
_logger.LogDebug(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
|
||||||
|
|
||||||
// Load into Redis if not already there
|
// Load into Redis if not already there
|
||||||
if (!await _cache.ExistsAsync(cacheKey))
|
if (!await _cache.ExistsAsync(cacheKey))
|
||||||
@@ -207,7 +207,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
// Check Redis cache
|
// Check Redis cache
|
||||||
if (await _cache.ExistsAsync(cacheKey))
|
if (await _cache.ExistsAsync(cacheKey))
|
||||||
{
|
{
|
||||||
_logger.LogInformation(" {Playlist}: Found in Redis cache", playlistName);
|
_logger.LogDebug(" {Playlist}: Found in Redis cache", playlistName);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
|
|
||||||
if (allPlaylistsHaveCache)
|
if (allPlaylistsHaveCache)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
|
_logger.LogWarning("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,13 +250,13 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
|
|
||||||
// No expiration - cache persists until next Jellyfin job generates new file
|
// No expiration - cache persists until next Jellyfin job generates new file
|
||||||
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365));
|
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365));
|
||||||
_logger.LogInformation("Loaded {Count} tracks from file cache for {Playlist} (age: {Age:F1}h, no expiration)",
|
_logger.LogDebug("Loaded {Count} tracks from file cache for {Playlist} (age: {Age:F1}h, no expiration)",
|
||||||
tracks.Count, playlistName, fileAge.TotalHours);
|
tracks.Count, playlistName, fileAge.TotalHours);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to load file cache for {Playlist}", playlistName);
|
_logger.LogError(ex, "Failed to load file cache for {Playlist}", playlistName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
var filePath = GetCacheFilePath(playlistName);
|
var filePath = GetCacheFilePath(playlistName);
|
||||||
var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true });
|
||||||
await File.WriteAllTextAsync(filePath, json);
|
await File.WriteAllTextAsync(filePath, json);
|
||||||
_logger.LogInformation("Saved {Count} tracks to file cache for {Playlist}",
|
_logger.LogDebug("Saved {Count} tracks to file cache for {Playlist}",
|
||||||
tracks.Count, playlistName);
|
tracks.Count, playlistName);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -279,7 +279,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken)
|
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("=== FETCHING MISSING TRACKS ===");
|
_logger.LogInformation("=== FETCHING MISSING TRACKS ===");
|
||||||
_logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count);
|
_logger.LogDebug("Processing {Count} playlists", _playlistIdToName.Count);
|
||||||
|
|
||||||
// Track when we find files to optimize search for other playlists
|
// Track when we find files to optimize search for other playlists
|
||||||
DateTime? firstFoundTime = null;
|
DateTime? firstFoundTime = null;
|
||||||
@@ -324,11 +324,11 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
|
|
||||||
if (existingTracks != null && existingTracks.Count > 0)
|
if (existingTracks != null && existingTracks.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(" Current cache has {Count} tracks, will search for newer file", existingTracks.Count);
|
_logger.LogDebug(" Current cache has {Count} tracks, will search for newer file", existingTracks.Count);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation(" No existing cache, will search for missing tracks file");
|
_logger.LogDebug(" No existing cache, will search for missing tracks file");
|
||||||
}
|
}
|
||||||
|
|
||||||
var settings = _spotifySettings.Value;
|
var settings = _spotifySettings.Value;
|
||||||
@@ -428,7 +428,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
// Keep the existing cache - don't let it expire
|
// Keep the existing cache - don't let it expire
|
||||||
if (existingTracks != null && existingTracks.Count > 0)
|
if (existingTracks != null && existingTracks.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(" ✓ Keeping existing cache with {Count} tracks (no expiration)", existingTracks.Count);
|
_logger.LogDebug(" ✓ Keeping existing cache with {Count} tracks (no expiration)", existingTracks.Count);
|
||||||
// Re-save with no expiration to ensure it persists
|
// Re-save with no expiration to ensure it persists
|
||||||
await _cache.SetAsync(cacheKey, existingTracks, TimeSpan.FromDays(365)); // Effectively no expiration
|
await _cache.SetAsync(cacheKey, existingTracks, TimeSpan.FromDays(365)); // Effectively no expiration
|
||||||
}
|
}
|
||||||
@@ -444,7 +444,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
if (tracks != null && tracks.Count > 0)
|
if (tracks != null && tracks.Count > 0)
|
||||||
{
|
{
|
||||||
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365)); // No expiration
|
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365)); // No expiration
|
||||||
_logger.LogInformation(" ✓ Loaded {Count} tracks from file cache (no expiration)", tracks.Count);
|
_logger.LogDebug(" ✓ Loaded {Count} tracks from file cache (no expiration)", tracks.Count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -476,7 +476,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Log every request with the actual filename
|
// Log every request with the actual filename
|
||||||
_logger.LogInformation("Checking: {Playlist} at {DateTime}", playlistName, time.ToString("yyyy-MM-dd HH:mm"));
|
_logger.LogDebug("Checking: {Playlist} at {DateTime}", playlistName, time.ToString("yyyy-MM-dd HH:mm"));
|
||||||
|
|
||||||
var response = await httpClient.GetAsync(url, cancellationToken);
|
var response = await httpClient.GetAsync(url, cancellationToken);
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
@@ -502,7 +502,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
|
_logger.LogError(ex, "Failed to fetch {Filename}", filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (false, null);
|
return (false, null);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using allstarr.Models.Spotify;
|
|||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Cronos;
|
||||||
|
|
||||||
namespace allstarr.Services.Spotify;
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ namespace allstarr.Services.Spotify;
|
|||||||
/// - ISRC codes available for exact matching
|
/// - ISRC codes available for exact matching
|
||||||
/// - Real-time data without waiting for plugin sync schedules
|
/// - Real-time data without waiting for plugin sync schedules
|
||||||
/// - Full track metadata (duration, release date, etc.)
|
/// - 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>
|
/// </summary>
|
||||||
public class SpotifyPlaylistFetcher : BackgroundService
|
public class SpotifyPlaylistFetcher : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -23,7 +27,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
private readonly SpotifyApiClient _spotifyClient;
|
private readonly SpotifyApiClient _spotifyClient;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
|
|
||||||
private const string CacheDirectory = "/app/cache/spotify";
|
|
||||||
private const string CacheKeyPrefix = "spotify:playlist:";
|
private const string CacheKeyPrefix = "spotify:playlist:";
|
||||||
|
|
||||||
// Track Spotify playlist IDs after discovery
|
// Track Spotify playlist IDs after discovery
|
||||||
@@ -45,6 +48,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the Spotify playlist tracks in order, using cache if available.
|
/// Gets the Spotify playlist tracks in order, using cache if available.
|
||||||
|
/// Cache persists until next cron run to prevent excess API calls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
/// <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>
|
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
||||||
@@ -57,7 +61,38 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
if (cached != null && cached.Tracks.Count > 0)
|
if (cached != null && cached.Tracks.Count > 0)
|
||||||
{
|
{
|
||||||
var age = DateTime.UtcNow - cached.FetchedAt;
|
var age = DateTime.UtcNow - cached.FetchedAt;
|
||||||
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
|
||||||
|
// 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.LogWarning("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)",
|
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
|
||||||
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
||||||
@@ -65,47 +100,23 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try file cache
|
// Cache miss or expired - need to fetch fresh from Spotify
|
||||||
var filePath = GetCacheFilePath(playlistName);
|
// Try to use cached or configured Spotify playlist ID
|
||||||
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))
|
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
||||||
{
|
{
|
||||||
// Check if we have a configured Spotify ID for this playlist
|
// Check if we have a configured Spotify ID for this playlist
|
||||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||||
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
|
if (config != null && !string.IsNullOrEmpty(config.Id))
|
||||||
{
|
{
|
||||||
// Use the configured Spotify playlist ID directly
|
// Use the configured Spotify playlist ID directly
|
||||||
spotifyId = playlistConfig.Id;
|
spotifyId = config.Id;
|
||||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||||
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No configured ID, try searching by name (works for public/followed playlists)
|
// No configured ID, try searching by name (works for public/followed playlists)
|
||||||
_logger.LogDebug("No configured Spotify ID for '{Name}', searching...", playlistName);
|
_logger.LogInformation("No configured Spotify ID for '{Name}', searching...", playlistName);
|
||||||
var playlists = await _spotifyClient.SearchUserPlaylistsAsync(playlistName);
|
var playlists = await _spotifyClient.SearchUserPlaylistsAsync(playlistName);
|
||||||
|
|
||||||
var exactMatch = playlists.FirstOrDefault(p =>
|
var exactMatch = playlists.FirstOrDefault(p =>
|
||||||
@@ -113,21 +124,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
if (exactMatch == null)
|
if (exactMatch == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
|
_logger.LogInformation("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
|
||||||
|
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
||||||
// 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;
|
spotifyId = exactMatch.SpotifyId;
|
||||||
@@ -140,16 +138,42 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId);
|
var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId);
|
||||||
if (playlist == null || playlist.Tracks.Count == 0)
|
if (playlist == null || playlist.Tracks.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Failed to fetch playlist '{Name}' from Spotify", playlistName);
|
_logger.LogError("Failed to fetch playlist '{Name}' from Spotify", playlistName);
|
||||||
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cache
|
// Calculate cache expiration based on cron schedule
|
||||||
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
|
var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||||
await SaveToFileCacheAsync(playlistName, playlist);
|
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
|
||||||
|
|
||||||
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
|
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
|
||||||
playlistName, playlist.Tracks.Count);
|
{
|
||||||
|
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.LogError(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Redis cache with cron-based expiration
|
||||||
|
await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
|
||||||
|
|
||||||
|
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
|
||||||
|
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
|
||||||
|
|
||||||
return playlist.Tracks;
|
return playlist.Tracks;
|
||||||
}
|
}
|
||||||
@@ -206,9 +230,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
_logger.LogInformation("========================================");
|
_logger.LogInformation("========================================");
|
||||||
_logger.LogInformation("SpotifyPlaylistFetcher: Starting up...");
|
_logger.LogInformation("SpotifyPlaylistFetcher: Starting up...");
|
||||||
|
|
||||||
// Ensure cache directory exists
|
|
||||||
Directory.CreateDirectory(CacheDirectory);
|
|
||||||
|
|
||||||
if (!_spotifyApiSettings.Enabled)
|
if (!_spotifyApiSettings.Enabled)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Spotify API integration is DISABLED");
|
_logger.LogInformation("Spotify API integration is DISABLED");
|
||||||
@@ -218,13 +239,13 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Spotify session cookie not configured - cannot access editorial playlists");
|
_logger.LogError("Spotify session cookie not configured - cannot access editorial playlists");
|
||||||
_logger.LogInformation("========================================");
|
_logger.LogInformation("========================================");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify we can get an access token (the most reliable auth check)
|
// Verify we can get an access token (the most reliable auth check)
|
||||||
_logger.LogInformation("Attempting Spotify authentication...");
|
_logger.LogDebug("Attempting Spotify authentication...");
|
||||||
var token = await _spotifyClient.GetWebAccessTokenAsync(stoppingToken);
|
var token = await _spotifyClient.GetWebAccessTokenAsync(stoppingToken);
|
||||||
if (string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
{
|
{
|
||||||
@@ -235,32 +256,99 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
_logger.LogInformation("Spotify API ENABLED");
|
_logger.LogInformation("Spotify API ENABLED");
|
||||||
_logger.LogInformation("Authenticated via sp_dc session cookie");
|
_logger.LogInformation("Authenticated via sp_dc session cookie");
|
||||||
_logger.LogInformation("Cache duration: {Minutes} minutes", _spotifyApiSettings.CacheDurationMinutes);
|
|
||||||
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
||||||
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
||||||
|
|
||||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(" - {Name}", playlist.Name);
|
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||||
|
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("========================================");
|
_logger.LogInformation("========================================");
|
||||||
|
|
||||||
// Initial fetch of all playlists
|
// Cron-based refresh loop - only fetch when cron schedule triggers
|
||||||
await FetchAllPlaylistsAsync(stoppingToken);
|
// This prevents excess Spotify API calls
|
||||||
|
|
||||||
// Periodic refresh loop
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await FetchAllPlaylistsAsync(stoppingToken);
|
// 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.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", playlistName);
|
||||||
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error during periodic playlist refresh");
|
_logger.LogError(ex, "Error in playlist fetcher loop");
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,7 +364,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var tracks = await GetPlaylistTracksAsync(config.Name);
|
var tracks = await GetPlaylistTracksAsync(config.Name);
|
||||||
_logger.LogInformation(" {Name}: {Count} tracks", config.Name, tracks.Count);
|
_logger.LogDebug(" {Name}: {Count} tracks", config.Name, tracks.Count);
|
||||||
|
|
||||||
// Log sample of track order for debugging
|
// Log sample of track order for debugging
|
||||||
if (tracks.Count > 0)
|
if (tracks.Count > 0)
|
||||||
@@ -301,36 +389,11 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
// Wait 3 seconds between each playlist to avoid 429 TooManyRequests errors
|
// Wait 3 seconds between each playlist to avoid 429 TooManyRequests errors
|
||||||
if (config != _spotifyImportSettings.Playlists.Last())
|
if (config != _spotifyImportSettings.Playlists.Last())
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
|
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", config.Name);
|
||||||
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
|
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("=== FINISHED FETCHING SPOTIFY PLAYLISTS ===");
|
_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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using allstarr.Services.Jellyfin;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Cronos;
|
||||||
|
|
||||||
namespace allstarr.Services.Spotify;
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
@@ -17,6 +18,9 @@ namespace allstarr.Services.Spotify;
|
|||||||
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
||||||
///
|
///
|
||||||
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
/// 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>
|
/// </summary>
|
||||||
public class SpotifyTrackMatchingService : BackgroundService
|
public class SpotifyTrackMatchingService : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -27,8 +31,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
||||||
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
||||||
private DateTime _lastMatchingRun = DateTime.MinValue;
|
|
||||||
private readonly TimeSpan _minimumMatchingInterval = TimeSpan.FromMinutes(5); // Don't run more than once per 5 minutes
|
// 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(
|
public SpotifyTrackMatchingService(
|
||||||
IOptions<SpotifyImportSettings> spotifySettings,
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
@@ -57,17 +63,29 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||||
|
|
||||||
if (!_spotifySettings.Enabled)
|
if (!_spotifySettings.Enabled)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||||
? "ISRC-preferred" : "fuzzy";
|
? "ISRC-preferred" : "fuzzy";
|
||||||
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
_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
|
// Wait a bit for the fetcher to run first
|
||||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||||
@@ -75,7 +93,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Run once on startup to match any existing missing tracks
|
// Run once on startup to match any existing missing tracks
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Running initial track matching on startup");
|
_logger.LogInformation("Running initial track matching on startup (one-time)");
|
||||||
await MatchAllPlaylistsAsync(stoppingToken);
|
await MatchAllPlaylistsAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -83,52 +101,106 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
_logger.LogError(ex, "Error during startup track matching");
|
_logger.LogError(ex, "Error during startup track matching");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now start the periodic matching loop
|
// Now start the cron-based scheduling loop
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// Wait for configured interval before next run (default 24 hours)
|
|
||||||
var intervalHours = _spotifySettings.MatchingIntervalHours;
|
|
||||||
if (intervalHours <= 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Periodic matching disabled (MatchingIntervalHours = {Hours}), only startup run will execute", intervalHours);
|
|
||||||
break; // Exit loop - only run once on startup
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await MatchAllPlaylistsAsync(stoppingToken);
|
// 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.LogWarning("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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error in track matching service");
|
_logger.LogError(ex, "Error in cron scheduling loop");
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Public method to trigger matching manually for all playlists (called from controller).
|
|
||||||
/// </summary>
|
|
||||||
public async Task TriggerMatchingAsync()
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Manual track matching triggered for all playlists");
|
|
||||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Public method to trigger matching for a specific playlist (called from controller).
|
/// Matches tracks for a single playlist (called by cron scheduler or manual trigger).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
|
|
||||||
|
|
||||||
var playlist = _spotifySettings.Playlists
|
var playlist = _spotifySettings.Playlists
|
||||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (playlist == null)
|
if (playlist == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Playlist {Playlist} not found in configuration", playlistName);
|
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,13 +220,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
// Use new direct API mode with ISRC support
|
// Use new direct API mode with ISRC support
|
||||||
await MatchPlaylistTracksWithIsrcAsync(
|
await MatchPlaylistTracksWithIsrcAsync(
|
||||||
playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
|
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fall back to legacy mode
|
// Fall back to legacy mode
|
||||||
await MatchPlaylistTracksLegacyAsync(
|
await MatchPlaylistTracksLegacyAsync(
|
||||||
playlist.Name, metadataService, CancellationToken.None);
|
playlist.Name, metadataService, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -164,19 +236,43 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
/// <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()
|
||||||
{
|
{
|
||||||
// Check if we've run too recently (cooldown period)
|
_logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
|
||||||
var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
|
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||||
if (timeSinceLastRun < _minimumMatchingInterval)
|
}
|
||||||
|
|
||||||
|
/// <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))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)",
|
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||||
(int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
|
if (timeSinceLastRun < _minimumRunInterval)
|
||||||
return;
|
{
|
||||||
|
_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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
|
||||||
_lastMatchingRun = DateTime.UtcNow;
|
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||||
|
|
||||||
var playlists = _spotifySettings.Playlists;
|
var playlists = _spotifySettings.Playlists;
|
||||||
if (playlists.Count == 0)
|
if (playlists.Count == 0)
|
||||||
@@ -185,34 +281,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
return;
|
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>();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var playlist in playlists)
|
foreach (var playlist in playlists)
|
||||||
{
|
{
|
||||||
if (cancellationToken.IsCancellationRequested) break;
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (playlistFetcher != null)
|
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
|
||||||
{
|
|
||||||
// Use new direct API mode with ISRC support
|
|
||||||
await MatchPlaylistTracksWithIsrcAsync(
|
|
||||||
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Fall back to legacy mode
|
|
||||||
await MatchPlaylistTracksLegacyAsync(
|
|
||||||
playlist.Name, metadataService, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -220,7 +295,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
_logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -241,7 +316,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||||
if (spotifyTracks.Count == 0)
|
if (spotifyTracks.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No tracks found for {Playlist}, skipping matching", playlistName);
|
_logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +347,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
|
_logger.LogInformation("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
|
||||||
}
|
}
|
||||||
|
|
||||||
var (existingTracksResponse, _) = await proxyService.GetJsonAsyncInternal(
|
var (existingTracksResponse, _) = await proxyService.GetJsonAsyncInternal(
|
||||||
@@ -304,7 +379,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Could not fetch existing Jellyfin tracks for {Playlist}, will match all tracks", playlistName);
|
_logger.LogError(ex, "Could not fetch existing Jellyfin tracks for {Playlist}, will match all tracks", playlistName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -316,12 +391,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (tracksToMatch.Count == 0)
|
if (tracksToMatch.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching",
|
_logger.LogWarning("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching",
|
||||||
spotifyTracks.Count, playlistName);
|
spotifyTracks.Count, playlistName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)",
|
_logger.LogWarning("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)",
|
||||||
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
|
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
|
||||||
|
|
||||||
// Check cache - use snapshot/timestamp to detect changes
|
// Check cache - use snapshot/timestamp to detect changes
|
||||||
@@ -355,7 +430,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (!hasNewManualMappings)
|
if (!hasNewManualMappings)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
|
_logger.LogWarning("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
|
||||||
playlistName, existingMatched.Count, tracksToMatch.Count);
|
playlistName, existingMatched.Count, tracksToMatch.Count);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -413,7 +488,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
_logger.LogError(ex, "Failed to match track: {Title} - {Artist}",
|
||||||
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||||
return (spotifyTrack, new List<(Song, double, string)>());
|
return (spotifyTrack, new List<(Song, double, string)>());
|
||||||
}
|
}
|
||||||
@@ -497,8 +572,37 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (matchedTracks.Count > 0)
|
if (matchedTracks.Count > 0)
|
||||||
{
|
{
|
||||||
// Cache matched tracks with position data
|
// Calculate cache expiration: until next cron run (not just cache duration from settings)
|
||||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
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.LogError(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
|
// Save matched tracks to file for persistence across restarts
|
||||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||||
@@ -506,15 +610,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Also update legacy cache for backward compatibility
|
// Also update legacy cache for backward compatibility
|
||||||
var legacyKey = $"spotify:matched:{playlistName}";
|
var legacyKey = $"spotify:matched:{playlistName}";
|
||||||
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||||
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
"✓ 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);
|
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours);
|
||||||
|
|
||||||
// Pre-build playlist items cache for instant serving
|
// Pre-build playlist items cache for instant serving
|
||||||
// This is what makes the UI show all matched tracks at once
|
// This is what makes the UI show all matched tracks at once
|
||||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
|
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -706,7 +810,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
|
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
|
||||||
if (existingMatched != null && existingMatched.Count > 0)
|
if (existingMatched != null && existingMatched.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
_logger.LogWarning("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
||||||
playlistName, existingMatched.Count);
|
playlistName, existingMatched.Count);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -715,11 +819,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
|
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
|
||||||
if (missingTracks == null || missingTracks.Count == 0)
|
if (missingTracks == null || missingTracks.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No missing tracks found for {Playlist}, skipping matching", playlistName);
|
_logger.LogWarning("No missing tracks found for {Playlist}, skipping matching", playlistName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Matching {Count} tracks for {Playlist} (with rate limiting)",
|
_logger.LogWarning("Matching {Count} tracks for {Playlist} (with rate limiting)",
|
||||||
missingTracks.Count, playlistName);
|
missingTracks.Count, playlistName);
|
||||||
|
|
||||||
var matchedSongs = new List<Song>();
|
var matchedSongs = new List<Song>();
|
||||||
@@ -774,15 +878,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
_logger.LogError(ex, "Failed to match track: {Title} - {Artist}",
|
||||||
track.Title, track.PrimaryArtist);
|
track.Title, track.PrimaryArtist);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedSongs.Count > 0)
|
if (matchedSongs.Count > 0)
|
||||||
{
|
{
|
||||||
// Cache matched tracks for 1 hour
|
// Cache matched tracks for configurable duration
|
||||||
await _cache.SetAsync(matchedTracksKey, matchedSongs, TimeSpan.FromHours(1));
|
await _cache.SetAsync(matchedTracksKey, matchedSongs, CacheExtensions.SpotifyMatchedTracksTTL);
|
||||||
_logger.LogInformation("✓ Cached {Matched}/{Total} matched tracks for {Playlist}",
|
_logger.LogInformation("✓ Cached {Matched}/{Total} matched tracks for {Playlist}",
|
||||||
matchedSongs.Count, missingTracks.Count, playlistName);
|
matchedSongs.Count, missingTracks.Count, playlistName);
|
||||||
}
|
}
|
||||||
@@ -843,21 +947,23 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pre-builds the playlist items cache for instant serving.
|
/// Pre-builds the playlist items cache for instant serving.
|
||||||
/// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order.
|
/// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order.
|
||||||
|
/// PRIORITY: Local Jellyfin tracks FIRST, then external providers for unmatched tracks only.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PreBuildPlaylistItemsCacheAsync(
|
private async Task PreBuildPlaylistItemsCacheAsync(
|
||||||
string playlistName,
|
string playlistName,
|
||||||
string? jellyfinPlaylistId,
|
string? jellyfinPlaylistId,
|
||||||
List<SpotifyPlaylistTrack> spotifyTracks,
|
List<SpotifyPlaylistTrack> spotifyTracks,
|
||||||
List<MatchedTrack> matchedTracks,
|
List<MatchedTrack> externalMatchedTracks,
|
||||||
|
TimeSpan cacheExpiration,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
|
_logger.LogDebug("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(jellyfinPlaylistId))
|
if (string.IsNullOrEmpty(jellyfinPlaylistId))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
|
_logger.LogError("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,7 +982,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var userId = jellyfinSettings.UserId;
|
var userId = jellyfinSettings.UserId;
|
||||||
if (string.IsNullOrEmpty(userId))
|
if (string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
|
_logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -887,12 +993,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
|
// Request all fields that clients typically need (not just MediaSources)
|
||||||
|
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=Genres,DateCreated,MediaSources,ParentId,People,Tags,SortName,ProviderIds";
|
||||||
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
||||||
|
|
||||||
if (statusCode != 200 || existingTracksResponse == null)
|
if (statusCode != 200 || existingTracksResponse == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
|
_logger.LogError("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,8 +1030,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build the final track list in correct Spotify order
|
// Build the final track list in correct Spotify order
|
||||||
|
// PRIORITY: Local Jellyfin tracks FIRST, then external for unmatched only
|
||||||
var finalItems = new List<Dictionary<string, object?>>();
|
var finalItems = new List<Dictionary<string, object?>>();
|
||||||
var usedJellyfinItems = new HashSet<string>();
|
var usedJellyfinItems = new HashSet<string>();
|
||||||
|
var matchedSpotifyIds = new HashSet<string>(); // Track which Spotify tracks got local matches
|
||||||
var localUsedCount = 0;
|
var localUsedCount = 0;
|
||||||
var externalUsedCount = 0;
|
var externalUsedCount = 0;
|
||||||
var manualExternalCount = 0;
|
var manualExternalCount = 0;
|
||||||
@@ -962,19 +1071,42 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||||
if (itemDict != null)
|
if (itemDict != null)
|
||||||
{
|
{
|
||||||
// Add Spotify ID to ProviderIds so lyrics can work for local tracks too
|
// Add Jellyfin ID to ProviderIds for easy identification
|
||||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
if (itemDict.TryGetValue("Id", out var jellyfinIdObj) && jellyfinIdObj != null)
|
||||||
{
|
{
|
||||||
if (!itemDict.ContainsKey("ProviderIds"))
|
var jellyfinId = jellyfinIdObj.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(jellyfinId))
|
||||||
{
|
{
|
||||||
itemDict["ProviderIds"] = new Dictionary<string, string>();
|
if (!itemDict.ContainsKey("ProviderIds"))
|
||||||
}
|
{
|
||||||
|
itemDict["ProviderIds"] = new Dictionary<string, string>();
|
||||||
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
|
}
|
||||||
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
|
||||||
{
|
// Handle ProviderIds which might be a JsonElement or Dictionary
|
||||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
Dictionary<string, string>? providerIds = null;
|
||||||
_logger.LogDebug("Added Spotify ID {SpotifyId} to local track for lyrics support", spotifyTrack.SpotifyId);
|
|
||||||
|
if (itemDict["ProviderIds"] is Dictionary<string, string> dict)
|
||||||
|
{
|
||||||
|
providerIds = dict;
|
||||||
|
}
|
||||||
|
else if (itemDict["ProviderIds"] is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
// Convert JsonElement to Dictionary
|
||||||
|
providerIds = new Dictionary<string, string>();
|
||||||
|
foreach (var prop in jsonEl.EnumerateObject())
|
||||||
|
{
|
||||||
|
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||||
|
}
|
||||||
|
// Replace the JsonElement with the Dictionary
|
||||||
|
itemDict["ProviderIds"] = providerIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerIds != null && !providerIds.ContainsKey("Jellyfin"))
|
||||||
|
{
|
||||||
|
providerIds["Jellyfin"] = jellyfinId;
|
||||||
|
_logger.LogDebug("Added Jellyfin ID {JellyfinId} to manual mapped local track {Title}",
|
||||||
|
jellyfinId, spotifyTrack.Title);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,6 +1115,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
usedJellyfinItems.Add(matchedKey);
|
usedJellyfinItems.Add(matchedKey);
|
||||||
}
|
}
|
||||||
|
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as locally matched
|
||||||
localUsedCount++;
|
localUsedCount++;
|
||||||
}
|
}
|
||||||
continue; // Skip to next track
|
continue; // Skip to next track
|
||||||
@@ -1031,7 +1164,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
|
_logger.LogError("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
|
||||||
provider, externalId);
|
provider, externalId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1058,15 +1191,6 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// Convert external song to Jellyfin item format and add to finalItems
|
||||||
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
|
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
|
||||||
|
|
||||||
@@ -1088,6 +1212,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
finalItems.Add(externalItem);
|
finalItems.Add(externalItem);
|
||||||
externalUsedCount++;
|
externalUsedCount++;
|
||||||
manualExternalCount++;
|
manualExternalCount++;
|
||||||
|
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as matched (external)
|
||||||
|
|
||||||
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
|
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
|
||||||
spotifyTrack.Title, provider, externalId);
|
spotifyTrack.Title, provider, externalId);
|
||||||
@@ -1096,11 +1221,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
|
_logger.LogError(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no manual external mapping, try AGGRESSIVE fuzzy matching with local Jellyfin tracks
|
// THIRD: Try AGGRESSIVE fuzzy matching with local Jellyfin tracks (PRIORITY!)
|
||||||
double bestScore = 0;
|
double bestScore = 0;
|
||||||
|
|
||||||
foreach (var kvp in jellyfinItemsByName)
|
foreach (var kvp in jellyfinItemsByName)
|
||||||
@@ -1140,19 +1265,42 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||||
if (itemDict != null)
|
if (itemDict != null)
|
||||||
{
|
{
|
||||||
// Add Spotify ID to ProviderIds so lyrics can work for fuzzy-matched local tracks too
|
// Add Jellyfin ID to ProviderIds for easy identification
|
||||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
if (itemDict.TryGetValue("Id", out var jellyfinIdObj) && jellyfinIdObj != null)
|
||||||
{
|
{
|
||||||
if (!itemDict.ContainsKey("ProviderIds"))
|
var jellyfinId = jellyfinIdObj.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(jellyfinId))
|
||||||
{
|
{
|
||||||
itemDict["ProviderIds"] = new Dictionary<string, string>();
|
if (!itemDict.ContainsKey("ProviderIds"))
|
||||||
}
|
{
|
||||||
|
itemDict["ProviderIds"] = new Dictionary<string, string>();
|
||||||
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
|
}
|
||||||
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
|
||||||
{
|
// Handle ProviderIds which might be a JsonElement or Dictionary
|
||||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
Dictionary<string, string>? providerIds = null;
|
||||||
_logger.LogDebug("Added Spotify ID {SpotifyId} to fuzzy-matched local track for lyrics support", spotifyTrack.SpotifyId);
|
|
||||||
|
if (itemDict["ProviderIds"] is Dictionary<string, string> dict)
|
||||||
|
{
|
||||||
|
providerIds = dict;
|
||||||
|
}
|
||||||
|
else if (itemDict["ProviderIds"] is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
// Convert JsonElement to Dictionary
|
||||||
|
providerIds = new Dictionary<string, string>();
|
||||||
|
foreach (var prop in jsonEl.EnumerateObject())
|
||||||
|
{
|
||||||
|
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||||
|
}
|
||||||
|
// Replace the JsonElement with the Dictionary
|
||||||
|
itemDict["ProviderIds"] = providerIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerIds != null && !providerIds.ContainsKey("Jellyfin"))
|
||||||
|
{
|
||||||
|
providerIds["Jellyfin"] = jellyfinId;
|
||||||
|
_logger.LogDebug("Fuzzy matched local track {Title} with Jellyfin ID {Id} (score: {Score:F1})",
|
||||||
|
spotifyTrack.Title, jellyfinId, bestScore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1161,44 +1309,108 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
usedJellyfinItems.Add(matchedKey);
|
usedJellyfinItems.Add(matchedKey);
|
||||||
}
|
}
|
||||||
|
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as locally matched
|
||||||
localUsedCount++;
|
localUsedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No local match - try to find external track
|
// FOURTH: No local match - try to find external track (ONLY for unmatched tracks)
|
||||||
var matched = matchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
var matched = externalMatchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||||
if (matched != null && matched.MatchedSong != null)
|
if (matched != null && matched.MatchedSong != null)
|
||||||
{
|
{
|
||||||
// Convert external song to Jellyfin item format
|
// Convert external song to Jellyfin item format
|
||||||
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
|
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);
|
finalItems.Add(externalItem);
|
||||||
|
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as matched (external)
|
||||||
externalUsedCount++;
|
externalUsedCount++;
|
||||||
|
|
||||||
|
_logger.LogDebug("Using external match for {Title}: {Provider}",
|
||||||
|
spotifyTrack.Title, matched.MatchedSong.ExternalProvider);
|
||||||
}
|
}
|
||||||
|
// else: Track remains unmatched (not added to finalItems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (finalItems.Count > 0)
|
if (finalItems.Count > 0)
|
||||||
{
|
{
|
||||||
// Save to Redis cache
|
// Enrich external tracks with genres from MusicBrainz
|
||||||
|
if (externalUsedCount > 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var genreEnrichment = _serviceProvider.GetService<GenreEnrichmentService>();
|
||||||
|
if (genreEnrichment != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("🎨 Enriching {Count} external tracks with genres from MusicBrainz...", externalUsedCount);
|
||||||
|
|
||||||
|
// Extract external songs from externalMatchedTracks that were actually used
|
||||||
|
var usedExternalSpotifyIds = finalItems
|
||||||
|
.Where(item => item.TryGetValue("Id", out var idObj) &&
|
||||||
|
idObj is string id && id.StartsWith("ext-"))
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
// Try to get Spotify ID from ProviderIds
|
||||||
|
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj is Dictionary<string, string> providerIds)
|
||||||
|
{
|
||||||
|
providerIds.TryGetValue("Spotify", out var spotifyId);
|
||||||
|
return spotifyId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.Where(id => !string.IsNullOrEmpty(id))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var externalSongs = externalMatchedTracks
|
||||||
|
.Where(t => t.MatchedSong != null &&
|
||||||
|
!t.MatchedSong.IsLocal &&
|
||||||
|
usedExternalSpotifyIds.Contains(t.SpotifyId))
|
||||||
|
.Select(t => t.MatchedSong!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Enrich genres in parallel
|
||||||
|
await genreEnrichment.EnrichSongsGenresAsync(externalSongs);
|
||||||
|
|
||||||
|
// Update the genres in finalItems
|
||||||
|
foreach (var item in finalItems)
|
||||||
|
{
|
||||||
|
if (item.TryGetValue("Id", out var idObj) && idObj is string id && id.StartsWith("ext-"))
|
||||||
|
{
|
||||||
|
// Find the corresponding song
|
||||||
|
var song = externalSongs.FirstOrDefault(s => s.Id == id);
|
||||||
|
if (song != null && !string.IsNullOrEmpty(song.Genre))
|
||||||
|
{
|
||||||
|
// Update Genres array
|
||||||
|
item["Genres"] = new[] { song.Genre };
|
||||||
|
|
||||||
|
// Update GenreItems array
|
||||||
|
item["GenreItems"] = new[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Name"] = song.Genre,
|
||||||
|
["Id"] = $"genre-{song.Genre.ToLowerInvariant()}"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.LogDebug("✓ Enriched {Title} with genre: {Genre}", song.Title, song.Genre);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("✅ Genre enrichment complete for {Playlist}", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to enrich genres for {Playlist}, continuing without genres", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to Redis cache with same expiration as matched tracks (until next cron run)
|
||||||
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
||||||
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
||||||
|
|
||||||
// Save to file cache for persistence
|
// Save to file cache for persistence
|
||||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||||
@@ -1209,9 +1421,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
manualMappingInfo = $" [Manual external: {manualExternalCount}]";
|
manualMappingInfo = $" [Manual external: {manualExternalCount}]";
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogDebug("✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
|
||||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
|
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
|
||||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1264,7 +1475,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
|
||||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||||
|
|
||||||
_logger.LogDebug("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
|
_logger.LogInformation("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||||
? Path.Combine("downloads", "cache")
|
? Path.Combine("downloads", "cache")
|
||||||
: Path.Combine("downloads", "permanent");
|
: Path.Combine("downloads", "permanent");
|
||||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension, "squidwtf", trackId);
|
||||||
|
|
||||||
// Create directories if they don't exist
|
// Create directories if they don't exist
|
||||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
private readonly ILogger<SquidWTFMetadataService> _logger;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
|
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||||
|
|
||||||
public SquidWTFMetadataService(
|
public SquidWTFMetadataService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
@@ -63,13 +64,15 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
IOptions<SquidWTFSettings> squidwtfSettings,
|
IOptions<SquidWTFSettings> squidwtfSettings,
|
||||||
ILogger<SquidWTFMetadataService> logger,
|
ILogger<SquidWTFMetadataService> logger,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
List<string> apiUrls)
|
List<string> apiUrls,
|
||||||
|
GenreEnrichmentService? genreEnrichment = null)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||||
|
_genreEnrichment = genreEnrichment;
|
||||||
|
|
||||||
// Set up default headers
|
// Set up default headers
|
||||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||||
@@ -83,19 +86,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
// Race all endpoints for fastest search results
|
// Use round-robin to distribute load across endpoints (allows parallel processing of multiple tracks)
|
||||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Use 's' parameter for track search as per hifi-api spec
|
// Use 's' parameter for track search as per hifi-api spec
|
||||||
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url, ct);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(ct);
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
// Check for error in response body
|
// Check for error in response body
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
@@ -129,19 +132,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
// Race all endpoints for fastest search results
|
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
||||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
||||||
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url, ct);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(ct);
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var albums = new List<Album>();
|
var albums = new List<Album>();
|
||||||
@@ -166,14 +169,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
// Race all endpoints for fastest search results
|
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
||||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Per hifi-api spec: use 'a' parameter for artist search
|
// Per hifi-api spec: use 'a' parameter for artist search
|
||||||
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||||
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
_logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url, ct);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -181,7 +184,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(ct);
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var artists = new List<Artist>();
|
var artists = new List<Artist>();
|
||||||
@@ -237,7 +240,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to parse playlist, skipping");
|
_logger.LogWarning(ex, "Failed to parse playlist, skipping");
|
||||||
// Skip this playlist and continue with others
|
// Skip this playlist and continue with others
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,6 +289,23 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
var song = ParseTidalTrackFull(track);
|
var song = ParseTidalTrackFull(track);
|
||||||
|
|
||||||
|
// Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres)
|
||||||
|
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
|
||||||
|
{
|
||||||
|
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _genreEnrichment.EnrichSongGenreAsync(song);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to enrich genre for {Title}", song.Title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
||||||
// This avoids redundant conversions and ensures it's done in parallel with the download
|
// This avoids redundant conversions and ensures it's done in parallel with the download
|
||||||
|
|
||||||
@@ -336,8 +356,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for 24 hours
|
// Cache for configurable duration
|
||||||
await _cache.SetAsync(cacheKey, album, TimeSpan.FromHours(24));
|
await _cache.SetAsync(cacheKey, album, CacheExtensions.MetadataTTL);
|
||||||
|
|
||||||
return album;
|
return album;
|
||||||
}, (Album?)null);
|
}, (Album?)null);
|
||||||
@@ -347,14 +367,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return null;
|
if (externalProvider != "squidwtf") return null;
|
||||||
|
|
||||||
_logger.LogInformation("GetArtistAsync called for SquidWTF artist {ExternalId}", externalId);
|
_logger.LogDebug("GetArtistAsync called for SquidWTF artist {ExternalId}", externalId);
|
||||||
|
|
||||||
// Try cache first
|
// Try cache first
|
||||||
var cacheKey = $"squidwtf:artist:{externalId}";
|
var cacheKey = $"squidwtf:artist:{externalId}";
|
||||||
var cached = await _cache.GetAsync<Artist>(cacheKey);
|
var cached = await _cache.GetAsync<Artist>(cacheKey);
|
||||||
if (cached != null)
|
if (cached != null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Returning cached artist {ArtistName}", cached.Name);
|
_logger.LogDebug("Returning cached artist {ArtistName}", cached.Name);
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,12 +382,12 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
|
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
|
||||||
var url = $"{baseUrl}/artist/?f={externalId}";
|
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||||
_logger.LogInformation("Fetching artist from {Url}", url);
|
_logger.LogDebug("Fetching artist from {Url}", url);
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("SquidWTF artist request failed with status {StatusCode}", response.StatusCode);
|
_logger.LogError("SquidWTF artist request failed with status {StatusCode}", response.StatusCode);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +408,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
if (albumItems[0].TryGetProperty("artist", out var artistEl))
|
if (albumItems[0].TryGetProperty("artist", out var artistEl))
|
||||||
{
|
{
|
||||||
artistSource = artistEl;
|
artistSource = artistEl;
|
||||||
_logger.LogInformation("Found artist from albums, albumCount={AlbumCount}", albumCount);
|
_logger.LogDebug("Found artist from albums, albumCount={AlbumCount}", albumCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,10 +443,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
using var doc = JsonDocument.Parse(normalizedArtist.ToJsonString());
|
using var doc = JsonDocument.Parse(normalizedArtist.ToJsonString());
|
||||||
var artist = ParseTidalArtist(doc.RootElement);
|
var artist = ParseTidalArtist(doc.RootElement);
|
||||||
|
|
||||||
_logger.LogInformation("Successfully parsed artist {ArtistName} with {AlbumCount} albums", artist.Name, albumCount);
|
_logger.LogDebug("Successfully parsed artist {ArtistName} with {AlbumCount} albums", artist.Name, albumCount);
|
||||||
|
|
||||||
// Cache for 24 hours
|
// Cache for configurable duration
|
||||||
await _cache.SetAsync(cacheKey, artist, TimeSpan.FromHours(24));
|
await _cache.SetAsync(cacheKey, artist, CacheExtensions.MetadataTTL);
|
||||||
|
|
||||||
return artist;
|
return artist;
|
||||||
}, (Artist?)null);
|
}, (Artist?)null);
|
||||||
@@ -438,16 +458,16 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
|
_logger.LogDebug("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
|
||||||
|
|
||||||
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
|
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
|
||||||
var url = $"{baseUrl}/artist/?f={externalId}";
|
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||||
_logger.LogInformation("Fetching artist albums from URL: {Url}", url);
|
_logger.LogDebug("Fetching artist albums from URL: {Url}", url);
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("SquidWTF artist albums request failed with status {StatusCode}", response.StatusCode);
|
_logger.LogError("SquidWTF artist albums request failed with status {StatusCode}", response.StatusCode);
|
||||||
return new List<Album>();
|
return new List<Album>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +488,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
parsedAlbum.Title, parsedAlbum.Artist, parsedAlbum.ArtistId);
|
parsedAlbum.Title, parsedAlbum.Artist, parsedAlbum.ArtistId);
|
||||||
albums.Add(parsedAlbum);
|
albums.Add(parsedAlbum);
|
||||||
}
|
}
|
||||||
_logger.LogInformation("Found {AlbumCount} albums for artist {ExternalId}", albums.Count, externalId);
|
_logger.LogDebug("Found {AlbumCount} albums for artist {ExternalId}", albums.Count, externalId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -595,6 +615,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
|
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
|
||||||
var allArtists = new List<string>();
|
var allArtists = new List<string>();
|
||||||
|
var allArtistIds = new List<string>();
|
||||||
string artistName = "";
|
string artistName = "";
|
||||||
string? artistId = null;
|
string? artistId = null;
|
||||||
|
|
||||||
@@ -604,9 +625,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
foreach (var artistEl in artists.EnumerateArray())
|
foreach (var artistEl in artists.EnumerateArray())
|
||||||
{
|
{
|
||||||
var name = artistEl.GetProperty("name").GetString();
|
var name = artistEl.GetProperty("name").GetString();
|
||||||
|
var id = artistEl.GetProperty("id").GetInt64();
|
||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
{
|
{
|
||||||
allArtists.Add(name);
|
allArtists.Add(name);
|
||||||
|
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,7 +637,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
if (allArtists.Count > 0)
|
if (allArtists.Count > 0)
|
||||||
{
|
{
|
||||||
artistName = allArtists[0];
|
artistName = allArtists[0];
|
||||||
artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}";
|
artistId = allArtistIds[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback to singular "artist" field
|
// Fallback to singular "artist" field
|
||||||
@@ -623,6 +646,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||||
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
||||||
allArtists.Add(artistName);
|
allArtists.Add(artistName);
|
||||||
|
allArtistIds.Add(artistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get album info
|
// Get album info
|
||||||
@@ -649,6 +673,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
Artist = artistName,
|
Artist = artistName,
|
||||||
ArtistId = artistId,
|
ArtistId = artistId,
|
||||||
Artists = allArtists,
|
Artists = allArtists,
|
||||||
|
ArtistIds = allArtistIds,
|
||||||
Album = albumTitle,
|
Album = albumTitle,
|
||||||
AlbumId = albumId,
|
AlbumId = albumId,
|
||||||
Duration = track.TryGetProperty("duration", out var duration)
|
Duration = track.TryGetProperty("duration", out var duration)
|
||||||
@@ -711,6 +736,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
// Get all artists - prefer "artists" array for collaborations
|
// Get all artists - prefer "artists" array for collaborations
|
||||||
var allArtists = new List<string>();
|
var allArtists = new List<string>();
|
||||||
|
var allArtistIds = new List<string>();
|
||||||
string artistName = "";
|
string artistName = "";
|
||||||
long artistIdNum = 0;
|
long artistIdNum = 0;
|
||||||
|
|
||||||
@@ -719,9 +745,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
foreach (var artistEl in artists.EnumerateArray())
|
foreach (var artistEl in artists.EnumerateArray())
|
||||||
{
|
{
|
||||||
var name = artistEl.GetProperty("name").GetString();
|
var name = artistEl.GetProperty("name").GetString();
|
||||||
|
var id = artistEl.GetProperty("id").GetInt64();
|
||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
{
|
{
|
||||||
allArtists.Add(name);
|
allArtists.Add(name);
|
||||||
|
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,6 +764,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||||
artistIdNum = artist.GetProperty("id").GetInt64();
|
artistIdNum = artist.GetProperty("id").GetInt64();
|
||||||
allArtists.Add(artistName);
|
allArtists.Add(artistName);
|
||||||
|
allArtistIds.Add($"ext-squidwtf-artist-{artistIdNum}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album artist - same as main artist for Tidal tracks
|
// Album artist - same as main artist for Tidal tracks
|
||||||
@@ -771,6 +800,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
Artist = artistName,
|
Artist = artistName,
|
||||||
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
|
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
|
||||||
Artists = allArtists,
|
Artists = allArtists,
|
||||||
|
ArtistIds = allArtistIds,
|
||||||
Album = albumTitle,
|
Album = albumTitle,
|
||||||
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
|
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
|
||||||
AlbumArtist = albumArtist,
|
AlbumArtist = albumArtist,
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await _httpClient.GetAsync(endpoint, ct);
|
// 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;
|
return response.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ public class PlaylistSyncService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to download track '{Artist} - {Title}'", track.Artist, track.Title);
|
_logger.LogError(ex, "Failed to download track '{Artist} - {Title}'", track.Artist, track.Title);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ public class PlaylistSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString());
|
await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString());
|
||||||
_logger.LogInformation("Created M3U playlist: {Path}", playlistPath);
|
_logger.LogDebug("Created M3U playlist: {Path}", playlistPath);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -259,7 +259,7 @@ public class PlaylistSyncService
|
|||||||
// Skip real-time updates during full playlist download (M3U will be created once at the end)
|
// Skip real-time updates during full playlist download (M3U will be created once at the end)
|
||||||
if (isFullPlaylistDownload)
|
if (isFullPlaylistDownload)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Skipping M3U update for track {TrackId} (full playlist download in progress)", track.Id);
|
_logger.LogWarning("Skipping M3U update for track {TrackId} (full playlist download in progress)", track.Id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ public class PlaylistSyncService
|
|||||||
|
|
||||||
// Write the M3U file (overwrites existing)
|
// Write the M3U file (overwrites existing)
|
||||||
await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString());
|
await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString());
|
||||||
_logger.LogInformation("Updated M3U playlist '{PlaylistName}' with {Count} tracks (in correct order)",
|
_logger.LogDebug("Updated M3U playlist '{PlaylistName}' with {Count} tracks (in correct order)",
|
||||||
playlist.Name, addedCount);
|
playlist.Name, addedCount);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -382,7 +382,7 @@ public class PlaylistSyncService
|
|||||||
|
|
||||||
if (expiredKeys.Count > 0)
|
if (expiredKeys.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Cleaned up {Count} expired playlist cache entries", expiredKeys.Count);
|
_logger.LogWarning("Cleaned up {Count} expired playlist cache entries", expiredKeys.Count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
@@ -392,7 +392,7 @@ public class PlaylistSyncService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Error during playlist cache cleanup");
|
_logger.LogError(ex, "Error during playlist cache cleanup");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ public class SubsonicModelMapper
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Error parsing Subsonic search response");
|
_logger.LogError(ex, "Error parsing Subsonic search response");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (songs, albums, artists);
|
return (songs, albums, artists);
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>allstarr</RootNamespace>
|
<RootNamespace>allstarr</RootNamespace>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.1</Version>
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
<AssemblyVersion>1.0.1.0</AssemblyVersion>
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
<FileVersion>1.0.1.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
<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="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
||||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||||
|
|||||||
@@ -32,8 +32,7 @@
|
|||||||
"EnableExternalPlaylists": true
|
"EnableExternalPlaylists": true
|
||||||
},
|
},
|
||||||
"Library": {
|
"Library": {
|
||||||
"DownloadPath": "./downloads",
|
"DownloadPath": "./downloads"
|
||||||
"KeptPath": "/app/kept"
|
|
||||||
},
|
},
|
||||||
"Qobuz": {
|
"Qobuz": {
|
||||||
"UserAuthToken": "your-qobuz-token",
|
"UserAuthToken": "your-qobuz-token",
|
||||||
@@ -52,6 +51,17 @@
|
|||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"ConnectionString": "localhost:6379"
|
"ConnectionString": "localhost:6379"
|
||||||
},
|
},
|
||||||
|
"Cache": {
|
||||||
|
"SearchResultsMinutes": 120,
|
||||||
|
"PlaylistImagesHours": 168,
|
||||||
|
"SpotifyPlaylistItemsHours": 168,
|
||||||
|
"SpotifyMatchedTracksDays": 30,
|
||||||
|
"LyricsDays": 14,
|
||||||
|
"GenreDays": 30,
|
||||||
|
"MetadataDays": 7,
|
||||||
|
"OdesliLookupDays": 60,
|
||||||
|
"ProxyImagesDays": 14
|
||||||
|
},
|
||||||
"SpotifyImport": {
|
"SpotifyImport": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"SyncStartHour": 16,
|
"SyncStartHour": 16,
|
||||||
@@ -62,8 +72,6 @@
|
|||||||
},
|
},
|
||||||
"SpotifyApi": {
|
"SpotifyApi": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"ClientId": "",
|
|
||||||
"ClientSecret": "",
|
|
||||||
"SessionCookie": "",
|
"SessionCookie": "",
|
||||||
"CacheDurationMinutes": 60,
|
"CacheDurationMinutes": 60,
|
||||||
"RateLimitDelayMs": 100,
|
"RateLimitDelayMs": 100,
|
||||||
|
|||||||
+593
-54
@@ -75,6 +75,7 @@
|
|||||||
.status-badge.success { background: rgba(63, 185, 80, 0.2); color: var(--success); }
|
.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.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-badge.error { background: rgba(248, 81, 73, 0.2); color: var(--error); }
|
||||||
|
.status-badge.info { background: rgba(59, 130, 246, 0.2); color: #3b82f6; }
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -537,7 +538,7 @@
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||||
<div class="tab" data-tab="playlists">Active Playlists</div>
|
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||||
<div class="tab" data-tab="config">Configuration</div>
|
<div class="tab" data-tab="config">Configuration</div>
|
||||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -652,21 +653,22 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>
|
<h2>
|
||||||
Active Spotify Playlists
|
Injected Spotify Playlists
|
||||||
<div class="actions">
|
<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="matchAllPlaylists()" title="Re-match tracks when local library changed (uses cached Spotify data)">Re-match All Local</button>
|
||||||
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</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>
|
<button onclick="refreshAndMatchAll()" title="Rebuild all playlists when Spotify playlists changed (fetches fresh data and re-matches)" style="background:var(--accent);border-color:var(--accent);">Rebuild All Remote</button>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||||
These are the Spotify playlists currently being monitored and filled with tracks from your music service.
|
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music service.
|
||||||
</p>
|
</p>
|
||||||
<table class="playlist-table">
|
<table class="playlist-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Spotify ID</th>
|
<th>Spotify ID</th>
|
||||||
|
<th>Sync Schedule</th>
|
||||||
<th>Tracks</th>
|
<th>Tracks</th>
|
||||||
<th>Completion</th>
|
<th>Completion</th>
|
||||||
<th>Cache Age</th>
|
<th>Cache Age</th>
|
||||||
@@ -675,7 +677,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="playlist-table-body">
|
<tbody id="playlist-table-body">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="loading">
|
<td colspan="7" class="loading">
|
||||||
<span class="spinner"></span> Loading playlists...
|
<span class="spinner"></span> Loading playlists...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -806,8 +808,62 @@
|
|||||||
|
|
||||||
<!-- Configuration Tab -->
|
<!-- Configuration Tab -->
|
||||||
<div class="tab-content" id="tab-config">
|
<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">
|
<div class="card">
|
||||||
<h2>Spotify API Settings</h2>
|
<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-section">
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">API Enabled</span>
|
<span class="label">API Enabled</span>
|
||||||
@@ -815,7 +871,7 @@
|
|||||||
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">Session Cookie (sp_dc)</span>
|
<span class="label">Session Cookie (sp_dc) <span style="color: var(--error);">*</span></span>
|
||||||
<span class="value" id="config-spotify-cookie">-</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>
|
<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>
|
||||||
@@ -858,7 +914,7 @@
|
|||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">Quality</span>
|
<span class="label">Quality</span>
|
||||||
<span class="value" id="config-squid-quality">-</span>
|
<span class="value" id="config-squid-quality">-</span>
|
||||||
<button onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', '', ['LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
|
<button onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', 'HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest)\\nLOSSLESS: 16-bit/44.1kHz FLAC (default)\\nHIGH: 320kbps AAC\\nLOW: 96kbps AAC', ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -904,17 +960,17 @@
|
|||||||
<h2>Jellyfin Settings</h2>
|
<h2>Jellyfin Settings</h2>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">URL</span>
|
<span class="label">URL <span style="color: var(--error);">*</span></span>
|
||||||
<span class="value" id="config-jellyfin-url">-</span>
|
<span class="value" id="config-jellyfin-url">-</span>
|
||||||
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
|
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">API Key</span>
|
<span class="label">API Key <span style="color: var(--error);">*</span></span>
|
||||||
<span class="value" id="config-jellyfin-api-key">-</span>
|
<span class="value" id="config-jellyfin-api-key">-</span>
|
||||||
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
|
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">User ID</span>
|
<span class="label">User ID <span style="color: var(--error);">*</span></span>
|
||||||
<span class="value" id="config-jellyfin-user-id">-</span>
|
<span class="value" id="config-jellyfin-user-id">-</span>
|
||||||
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
|
<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>
|
||||||
@@ -943,17 +999,71 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Sync Schedule</h2>
|
<h2>Spotify Import Settings</h2>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">Sync Start Time</span>
|
<span class="label">Spotify Import Enabled</span>
|
||||||
<span class="value" id="config-sync-time">-</span>
|
<span class="value" id="config-spotify-import-enabled">-</span>
|
||||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_START_HOUR', 'Sync Start Hour (0-23)', 'number')">Edit</button>
|
<button onclick="openEditSetting('SPOTIFY_IMPORT_ENABLED', 'Spotify Import Enabled', 'toggle')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">Sync Window</span>
|
<span class="label">Matching Interval (hours)</span>
|
||||||
<span class="value" id="config-sync-window">-</span>
|
<span class="value" id="config-matching-interval">-</span>
|
||||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_WINDOW_HOURS', 'Sync Window (hours)', 'number')">Edit</button>
|
<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>Cache Settings</h2>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
Configure how long different types of data are cached. Longer durations reduce API calls but may show stale data.
|
||||||
|
</p>
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Search Results (minutes)</span>
|
||||||
|
<span class="value" id="config-cache-search">-</span>
|
||||||
|
<button onclick="openEditCacheSetting('SearchResultsMinutes', 'Search Results Cache (minutes)', 'How long to cache search results')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Playlist Images (hours)</span>
|
||||||
|
<span class="value" id="config-cache-playlist-images">-</span>
|
||||||
|
<button onclick="openEditCacheSetting('PlaylistImagesHours', 'Playlist Images Cache (hours)', 'How long to cache playlist cover images')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Spotify Playlist Items (hours)</span>
|
||||||
|
<span class="value" id="config-cache-spotify-items">-</span>
|
||||||
|
<button onclick="openEditCacheSetting('SpotifyPlaylistItemsHours', 'Spotify Playlist Items Cache (hours)', 'How long to cache Spotify playlist data')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Spotify Matched Tracks (days)</span>
|
||||||
|
<span class="value" id="config-cache-matched-tracks">-</span>
|
||||||
|
<button onclick="openEditCacheSetting('SpotifyMatchedTracksDays', 'Matched Tracks Cache (days)', 'How long to cache Spotify ID to track mappings')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Lyrics (days)</span>
|
||||||
|
<span class="value" id="config-cache-lyrics">-</span>
|
||||||
|
<button onclick="openEditCacheSetting('LyricsDays', 'Lyrics Cache (days)', 'How long to cache fetched lyrics')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Genre Data (days)</span>
|
||||||
|
<span class="value" id="config-cache-genres">-</span>
|
||||||
|
<button onclick="openEditCacheSetting('GenreDays', 'Genre Cache (days)', 'How long to cache genre information')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">External Metadata (days)</span>
|
||||||
|
<span class="value" id="config-cache-metadata">-</span>
|
||||||
|
<button onclick="openEditCacheSetting('MetadataDays', 'Metadata Cache (days)', 'How long to cache SquidWTF/Deezer/Qobuz metadata')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Odesli Lookups (days)</span>
|
||||||
|
<span class="value" id="config-cache-odesli">-</span>
|
||||||
|
<button onclick="openEditCacheSetting('OdesliLookupDays', 'Odesli Lookup Cache (days)', 'How long to cache Odesli URL conversions')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Proxy Images (days)</span>
|
||||||
|
<span class="value" id="config-cache-proxy-images">-</span>
|
||||||
|
<button onclick="openEditCacheSetting('ProxyImagesDays', 'Proxy Images Cache (days)', 'How long to cache proxied Jellyfin images')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1119,7 +1229,7 @@
|
|||||||
<div class="modal-content" style="max-width: 600px;">
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
<h3>Map Track to External Provider</h3>
|
<h3>Map Track to External Provider</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<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.
|
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Jellyfin mapping modal instead.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Track Info -->
|
<!-- Track Info -->
|
||||||
@@ -1161,25 +1271,94 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Local Jellyfin Track Mapping Modal -->
|
||||||
|
<div class="modal" id="local-map-modal">
|
||||||
|
<div class="modal-content" style="max-width: 700px;">
|
||||||
|
<h3>Map Track to Local Jellyfin Track</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
Search your Jellyfin library and select a local track to map to this Spotify track.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Track Info -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Spotify Track (Position <span id="local-map-position"></span>)</label>
|
||||||
|
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||||
|
<strong id="local-map-spotify-title"></strong><br>
|
||||||
|
<span style="color: var(--text-secondary);" id="local-map-spotify-artist"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Section -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Search Jellyfin Library</label>
|
||||||
|
<input type="text" id="local-map-search" placeholder="Search for track name or artist...">
|
||||||
|
<button onclick="searchJellyfinTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Results -->
|
||||||
|
<div id="local-map-results" style="max-height: 300px; overflow-y: auto; margin-top: 16px;"></div>
|
||||||
|
|
||||||
|
<input type="hidden" id="local-map-playlist-name">
|
||||||
|
<input type="hidden" id="local-map-spotify-id">
|
||||||
|
<input type="hidden" id="local-map-jellyfin-id">
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button onclick="closeModal('local-map-modal')">Cancel</button>
|
||||||
|
<button class="primary" onclick="saveLocalMapping()" id="local-map-save-btn" disabled>Save Mapping</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Link Playlist Modal -->
|
<!-- Link Playlist Modal -->
|
||||||
<div class="modal" id="link-playlist-modal">
|
<div class="modal" id="link-playlist-modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h3>Link to Spotify Playlist</h3>
|
<h3>Link to Spotify Playlist</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
Enter the Spotify playlist ID or URL. Allstarr will automatically download missing tracks from your configured music service.
|
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>
|
</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Jellyfin Playlist</label>
|
<label>Jellyfin Playlist</label>
|
||||||
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
|
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
|
||||||
<input type="hidden" id="link-jellyfin-id">
|
<input type="hidden" id="link-jellyfin-id">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
|
<!-- 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>
|
<label>Spotify Playlist ID or URL</label>
|
||||||
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
|
<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;">
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||||
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
|
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</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">
|
<div class="modal-actions">
|
||||||
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
||||||
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
|
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
|
||||||
@@ -1460,7 +1639,7 @@
|
|||||||
|
|
||||||
if (data.playlists.length === 0) {
|
if (data.playlists.length === 0) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1491,13 +1670,13 @@
|
|||||||
// Show breakdown with color coding
|
// Show breakdown with color coding
|
||||||
let breakdownParts = [];
|
let breakdownParts = [];
|
||||||
if (localCount > 0) {
|
if (localCount > 0) {
|
||||||
breakdownParts.push(`<span style="color:var(--success)">${localCount} local</span>`);
|
breakdownParts.push(`<span style="color:var(--success)">${localCount} Local</span>`);
|
||||||
}
|
}
|
||||||
if (externalMatched > 0) {
|
if (externalMatched > 0) {
|
||||||
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} matched</span>`);
|
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} External</span>`);
|
||||||
}
|
}
|
||||||
if (externalMissing > 0) {
|
if (externalMissing > 0) {
|
||||||
breakdownParts.push(`<span style="color:var(--warning)">${externalMissing} missing</span>`);
|
breakdownParts.push(`<span style="color:var(--warning)">${externalMissing} Missing</span>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const breakdown = breakdownParts.length > 0
|
const breakdown = breakdownParts.length > 0
|
||||||
@@ -1514,25 +1693,31 @@
|
|||||||
// Debug logging
|
// Debug logging
|
||||||
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
|
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
|
||||||
|
|
||||||
|
const syncSchedule = p.syncSchedule || '0 8 * * 1';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${escapeHtml(p.name)}</strong></td>
|
<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;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>${statsHtml}${breakdown}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
<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="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:${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:${externalPct}%;height:100%;background:#3b82f6;transition:width 0.3s;" title="${externalMatched} external tracks"></div>
|
||||||
<div style="width:${missingPct}%;height:100%;background:#6b7280;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
|
<div style="width:${missingPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
|
||||||
</div>
|
</div>
|
||||||
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')" title="Re-match when local library changed (uses cached Spotify data)">Re-match Local</button>
|
||||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')" title="Rebuild when Spotify playlist changed (fetches fresh data)" style="background:var(--accent);border-color:var(--accent);">Rebuild Remote</button>
|
||||||
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
||||||
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -1776,6 +1961,23 @@
|
|||||||
const res = await fetch('/api/admin/config');
|
const res = await fetch('/api/admin/config');
|
||||||
const data = await res.json();
|
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
|
// Spotify API settings
|
||||||
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
||||||
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
||||||
@@ -1817,10 +2019,21 @@
|
|||||||
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
|
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
|
||||||
|
|
||||||
// Sync settings
|
// Sync settings
|
||||||
const syncHour = data.spotifyImport.syncStartHour;
|
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
|
||||||
const syncMin = data.spotifyImport.syncStartMinute;
|
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' hours';
|
||||||
document.getElementById('config-sync-time').textContent = `${String(syncHour).padStart(2, '0')}:${String(syncMin).padStart(2, '0')}`;
|
|
||||||
document.getElementById('config-sync-window').textContent = data.spotifyImport.syncWindowHours + ' hours';
|
// Cache settings
|
||||||
|
if (data.cache) {
|
||||||
|
document.getElementById('config-cache-search').textContent = data.cache.searchResultsMinutes || '120';
|
||||||
|
document.getElementById('config-cache-playlist-images').textContent = data.cache.playlistImagesHours || '168';
|
||||||
|
document.getElementById('config-cache-spotify-items').textContent = data.cache.spotifyPlaylistItemsHours || '168';
|
||||||
|
document.getElementById('config-cache-matched-tracks').textContent = data.cache.spotifyMatchedTracksDays || '30';
|
||||||
|
document.getElementById('config-cache-lyrics').textContent = data.cache.lyricsDays || '14';
|
||||||
|
document.getElementById('config-cache-genres').textContent = data.cache.genreDays || '30';
|
||||||
|
document.getElementById('config-cache-metadata').textContent = data.cache.metadataDays || '7';
|
||||||
|
document.getElementById('config-cache-odesli').textContent = data.cache.odesliLookupDays || '60';
|
||||||
|
document.getElementById('config-cache-proxy-images').textContent = data.cache.proxyImagesDays || '14';
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch config:', error);
|
console.error('Failed to fetch config:', error);
|
||||||
}
|
}
|
||||||
@@ -1896,23 +2109,138 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLinkPlaylist(jellyfinId, name) {
|
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-id').value = jellyfinId;
|
||||||
document.getElementById('link-jellyfin-name').value = name;
|
document.getElementById('link-jellyfin-name').value = name;
|
||||||
document.getElementById('link-spotify-id').value = '';
|
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');
|
openModal('link-playlist-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function linkPlaylist() {
|
async function linkPlaylist() {
|
||||||
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
||||||
const name = document.getElementById('link-jellyfin-name').value;
|
const name = document.getElementById('link-jellyfin-name').value;
|
||||||
const spotifyId = document.getElementById('link-spotify-id').value.trim();
|
const syncSchedule = document.getElementById('link-sync-schedule').value.trim();
|
||||||
|
|
||||||
if (!spotifyId) {
|
// Validate sync schedule (basic cron format check)
|
||||||
showToast('Spotify Playlist ID is required', 'error');
|
if (!syncSchedule) {
|
||||||
|
showToast('Sync schedule is required', 'error');
|
||||||
return;
|
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:
|
// Extract ID from various Spotify formats:
|
||||||
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
||||||
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
|
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
|
||||||
@@ -1935,7 +2263,11 @@
|
|||||||
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
spotifyPlaylistId: cleanSpotifyId,
|
||||||
|
syncSchedule: syncSchedule
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -1945,6 +2277,9 @@
|
|||||||
showRestartBanner();
|
showRestartBanner();
|
||||||
closeModal('link-playlist-modal');
|
closeModal('link-playlist-modal');
|
||||||
|
|
||||||
|
// Clear the Spotify playlists cache so it refreshes next time
|
||||||
|
spotifyUserPlaylists = [];
|
||||||
|
|
||||||
// Update UI state without refetching all playlists
|
// Update UI state without refetching all playlists
|
||||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||||
if (playlistsTable) {
|
if (playlistsTable) {
|
||||||
@@ -1982,6 +2317,9 @@
|
|||||||
showToast('Playlist unlinked.', 'success');
|
showToast('Playlist unlinked.', 'success');
|
||||||
showRestartBanner();
|
showRestartBanner();
|
||||||
|
|
||||||
|
// Clear the Spotify playlists cache so it refreshes next time
|
||||||
|
spotifyUserPlaylists = [];
|
||||||
|
|
||||||
// Update UI state without refetching all playlists
|
// Update UI state without refetching all playlists
|
||||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||||
if (playlistsTable) {
|
if (playlistsTable) {
|
||||||
@@ -2020,18 +2358,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearPlaylistCache(name) {
|
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;
|
if (!confirm(`Rebuild "${name}" from scratch?\n\nThis will:\n• Fetch fresh Spotify playlist data\n• Clear all caches\n• Re-match all tracks\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Show warning banner
|
// Show warning banner
|
||||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||||
|
|
||||||
showToast(`Clearing cache for ${name}...`, 'info');
|
showToast(`Rebuilding ${name} from scratch...`, 'info');
|
||||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
|
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast(`✓ ${data.message} (Cleared ${data.clearedKeys} cache keys, ${data.clearedFiles} files)`, 'success', 5000);
|
showToast(`✓ ${data.message}`, 'success', 5000);
|
||||||
// Refresh the playlists table after a delay to show updated counts
|
// Refresh the playlists table after a delay to show updated counts
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fetchPlaylists();
|
fetchPlaylists();
|
||||||
@@ -2039,7 +2377,7 @@
|
|||||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to clear cache', 'error');
|
showToast(data.error || 'Failed to rebuild playlist', 'error');
|
||||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2053,7 +2391,7 @@
|
|||||||
// Show warning banner
|
// Show warning banner
|
||||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||||
|
|
||||||
showToast(`Matching tracks for ${name}...`, 'success');
|
showToast(`Re-matching local tracks for ${name}...`, 'info');
|
||||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
@@ -2066,17 +2404,17 @@
|
|||||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to match tracks', 'error');
|
showToast(data.error || 'Failed to re-match tracks', 'error');
|
||||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('Failed to match tracks', 'error');
|
showToast('Failed to re-match tracks', 'error');
|
||||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function matchAllPlaylists() {
|
async function matchAllPlaylists() {
|
||||||
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
if (!confirm('Re-match local tracks for ALL playlists?\n\nUse this when your local library has changed.\n\nThis may take a few minutes.')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Show warning banner
|
// Show warning banner
|
||||||
@@ -2345,6 +2683,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async function removePlaylist(name) {
|
||||||
if (!confirm(`Remove playlist "${name}"?`)) return;
|
if (!confirm(`Remove playlist "${name}"?`)) return;
|
||||||
|
|
||||||
@@ -2374,8 +2745,23 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
|
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();
|
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) {
|
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>';
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
|
||||||
return;
|
return;
|
||||||
@@ -2399,7 +2785,7 @@
|
|||||||
}
|
}
|
||||||
} else if (t.isLocal === false) {
|
} else if (t.isLocal === false) {
|
||||||
const provider = capitalizeProvider(t.externalProvider) || 'External';
|
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>`;
|
statusBadge = `<span class="status-badge info" 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
|
// Add manual mapping indicator for external tracks
|
||||||
if (t.isManualMapping && t.manualMappingType === 'external') {
|
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>';
|
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>';
|
||||||
@@ -2422,7 +2808,7 @@
|
|||||||
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
|
||||||
} else {
|
} else {
|
||||||
// isLocal is null/undefined - track is missing (not found locally or externally)
|
// 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>';
|
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:rgba(245, 158, 11, 0.2);color:#f59e0b;"><span class="status-dot" style="background:#f59e0b;"></span>Missing</span>';
|
||||||
// Add both mapping buttons for missing tracks
|
// Add both mapping buttons for missing tracks
|
||||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||||
mapButton = `<button class="small map-track-btn"
|
mapButton = `<button class="small map-track-btn"
|
||||||
@@ -2490,7 +2876,8 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
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>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2568,6 +2955,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache setting editor (uses appsettings.json instead of .env)
|
||||||
|
function openEditCacheSetting(settingKey, label, helpText) {
|
||||||
|
currentEditKey = settingKey;
|
||||||
|
currentEditType = 'number';
|
||||||
|
|
||||||
|
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 + ' (Requires restart to apply)';
|
||||||
|
helpEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
helpEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById('edit-setting-input-container');
|
||||||
|
container.innerHTML = `<input type="number" id="edit-setting-value" placeholder="Enter value" min="1">`;
|
||||||
|
|
||||||
|
openModal('edit-setting-modal');
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
@@ -2715,8 +3124,27 @@
|
|||||||
saveBtn.disabled = !externalId;
|
saveBtn.disabled = !externalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open manual mapping modal (external only)
|
// Open local Jellyfin mapping modal
|
||||||
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||||
|
document.getElementById('local-map-playlist-name').value = playlistName;
|
||||||
|
document.getElementById('local-map-position').textContent = position + 1;
|
||||||
|
document.getElementById('local-map-spotify-title').textContent = title;
|
||||||
|
document.getElementById('local-map-spotify-artist').textContent = artist;
|
||||||
|
document.getElementById('local-map-spotify-id').value = spotifyId;
|
||||||
|
|
||||||
|
// Pre-fill search with track info
|
||||||
|
document.getElementById('local-map-search').value = `${title} ${artist}`;
|
||||||
|
|
||||||
|
// Reset fields
|
||||||
|
document.getElementById('local-map-results').innerHTML = '';
|
||||||
|
document.getElementById('local-map-jellyfin-id').value = '';
|
||||||
|
document.getElementById('local-map-save-btn').disabled = true;
|
||||||
|
|
||||||
|
openModal('local-map-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open external mapping modal
|
||||||
|
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||||
document.getElementById('map-playlist-name').value = playlistName;
|
document.getElementById('map-playlist-name').value = playlistName;
|
||||||
document.getElementById('map-position').textContent = position + 1;
|
document.getElementById('map-position').textContent = position + 1;
|
||||||
document.getElementById('map-spotify-title').textContent = title;
|
document.getElementById('map-spotify-title').textContent = title;
|
||||||
@@ -2731,12 +3159,123 @@
|
|||||||
openModal('manual-map-modal');
|
openModal('manual-map-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alias for backward compatibility
|
// Search Jellyfin tracks for local mapping
|
||||||
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
async function searchJellyfinTracks() {
|
||||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
const query = document.getElementById('local-map-search').value.trim();
|
||||||
|
if (!query) {
|
||||||
|
showToast('Please enter a search query', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultsDiv = document.getElementById('local-map-results');
|
||||||
|
resultsDiv.innerHTML = '<p style="text-align:center;padding:20px;">Searching...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.tracks || data.tracks.length === 0) {
|
||||||
|
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">No tracks found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = data.tracks.map(track => `
|
||||||
|
<div style="padding: 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: background 0.2s;"
|
||||||
|
onclick="selectJellyfinTrack('${escapeJs(track.id)}', '${escapeJs(track.name)}', '${escapeJs(track.artist)}')"
|
||||||
|
onmouseover="this.style.background='var(--bg-primary)'"
|
||||||
|
onmouseout="this.style.background='transparent'">
|
||||||
|
<strong>${escapeHtml(track.name)}</strong><br>
|
||||||
|
<span style="color: var(--text-secondary); font-size: 0.9em;">${escapeHtml(track.artist)}</span>
|
||||||
|
${track.album ? '<br><span style="color: var(--text-secondary); font-size: 0.85em;">' + escapeHtml(track.album) + '</span>' : ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save manual mapping (external only)
|
// Select a Jellyfin track for mapping
|
||||||
|
function selectJellyfinTrack(jellyfinId, name, artist) {
|
||||||
|
document.getElementById('local-map-jellyfin-id').value = jellyfinId;
|
||||||
|
document.getElementById('local-map-save-btn').disabled = false;
|
||||||
|
|
||||||
|
// Highlight selected track
|
||||||
|
document.querySelectorAll('#local-map-results > div').forEach(div => {
|
||||||
|
div.style.background = 'transparent';
|
||||||
|
div.style.border = '1px solid var(--border)';
|
||||||
|
});
|
||||||
|
event.target.closest('div').style.background = 'var(--primary)';
|
||||||
|
event.target.closest('div').style.border = '1px solid var(--primary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save local Jellyfin mapping
|
||||||
|
async function saveLocalMapping() {
|
||||||
|
const playlistName = document.getElementById('local-map-playlist-name').value;
|
||||||
|
const spotifyId = document.getElementById('local-map-spotify-id').value;
|
||||||
|
const jellyfinId = document.getElementById('local-map-jellyfin-id').value;
|
||||||
|
|
||||||
|
if (!jellyfinId) {
|
||||||
|
showToast('Please select a Jellyfin track', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
spotifyId,
|
||||||
|
jellyfinId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const saveBtn = document.getElementById('local-map-save-btn');
|
||||||
|
const originalText = saveBtn.textContent;
|
||||||
|
saveBtn.textContent = 'Saving...';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Track mapped successfully!', 'success');
|
||||||
|
closeModal('local-map-modal');
|
||||||
|
|
||||||
|
// Refresh the tracks view if it's open
|
||||||
|
const tracksModal = document.getElementById('tracks-modal');
|
||||||
|
if (tracksModal.style.display === 'flex') {
|
||||||
|
await viewTracks(playlistName);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(data.error || 'Failed to save mapping', 'error');
|
||||||
|
saveBtn.textContent = originalText;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showToast('Request timed out. The mapping may still be processing.', 'warning');
|
||||||
|
} else {
|
||||||
|
showToast('Failed to save mapping', 'error');
|
||||||
|
}
|
||||||
|
saveBtn.textContent = originalText;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save manual mapping (external only) - kept for backward compatibility
|
||||||
async function saveManualMapping() {
|
async function saveManualMapping() {
|
||||||
const playlistName = document.getElementById('map-playlist-name').value;
|
const playlistName = document.getElementById('map-playlist-name').value;
|
||||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||||
|
|||||||
+14
-2
@@ -17,8 +17,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- allstarr-network
|
- allstarr-network
|
||||||
|
|
||||||
|
# Spotify Lyrics API sidecar service
|
||||||
|
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
|
||||||
spotify-lyrics:
|
spotify-lyrics:
|
||||||
image: akashrchandran/spotify-lyrics-api:latest
|
image: akashrchandran/spotify-lyrics-api:latest
|
||||||
|
platform: linux/amd64
|
||||||
container_name: allstarr-spotify-lyrics
|
container_name: allstarr-spotify-lyrics
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -70,6 +73,17 @@ services:
|
|||||||
- Redis__ConnectionString=redis:6379
|
- Redis__ConnectionString=redis:6379
|
||||||
- Redis__Enabled=${REDIS_ENABLED:-true}
|
- Redis__Enabled=${REDIS_ENABLED:-true}
|
||||||
|
|
||||||
|
# ===== CACHE TTL SETTINGS =====
|
||||||
|
- Cache__SearchResultsMinutes=${CACHE_SEARCH_RESULTS_MINUTES:-120}
|
||||||
|
- Cache__PlaylistImagesHours=${CACHE_PLAYLIST_IMAGES_HOURS:-168}
|
||||||
|
- Cache__SpotifyPlaylistItemsHours=${CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS:-168}
|
||||||
|
- Cache__SpotifyMatchedTracksDays=${CACHE_SPOTIFY_MATCHED_TRACKS_DAYS:-30}
|
||||||
|
- Cache__LyricsDays=${CACHE_LYRICS_DAYS:-14}
|
||||||
|
- Cache__GenreDays=${CACHE_GENRE_DAYS:-30}
|
||||||
|
- Cache__MetadataDays=${CACHE_METADATA_DAYS:-7}
|
||||||
|
- Cache__OdesliLookupDays=${CACHE_ODESLI_LOOKUP_DAYS:-60}
|
||||||
|
- Cache__ProxyImagesDays=${CACHE_PROXY_IMAGES_DAYS:-14}
|
||||||
|
|
||||||
# ===== SUBSONIC BACKEND =====
|
# ===== SUBSONIC BACKEND =====
|
||||||
- Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533}
|
- Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533}
|
||||||
- Subsonic__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly}
|
- Subsonic__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly}
|
||||||
@@ -104,8 +118,6 @@ services:
|
|||||||
|
|
||||||
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
|
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
|
||||||
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
|
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
|
||||||
- SpotifyApi__ClientId=${SPOTIFY_API_CLIENT_ID:-}
|
|
||||||
- SpotifyApi__ClientSecret=${SPOTIFY_API_CLIENT_SECRET:-}
|
|
||||||
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
|
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
|
||||||
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
|
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
|
||||||
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
||||||
|
|||||||
Reference in New Issue
Block a user