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
|
||||
BACKEND_TYPE=Subsonic
|
||||
|
||||
# ===== REDIS CACHE =====
|
||||
# Enable Redis caching for metadata and images (default: true)
|
||||
REDIS_ENABLED=true
|
||||
# ===== REDIS CACHE (REQUIRED) =====
|
||||
# Redis is the primary cache for all runtime data (search results, playlists, lyrics, etc.)
|
||||
# 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 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
|
||||
|
||||
# ===== 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 =====
|
||||
# Server URL (required if using Subsonic backend)
|
||||
SUBSONIC_URL=http://localhost:4533
|
||||
@@ -40,11 +76,16 @@ MUSIC_SERVICE=SquidWTF
|
||||
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
||||
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
||||
# - downloads/kept/ - Favorited external tracks (always permanent)
|
||||
DOWNLOAD_PATH=./downloads
|
||||
Library__DownloadPath=./downloads
|
||||
|
||||
# ===== SQUIDWTF CONFIGURATION =====
|
||||
# Different quality options for SquidWTF. Only FLAC supported right now
|
||||
SQUIDWTF_QUALITY=FLAC
|
||||
# Preferred audio quality (optional, default: LOSSLESS)
|
||||
# - 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 ARL token (required if using Deezer)
|
||||
@@ -95,12 +136,12 @@ EXPLICIT_FILTER=All
|
||||
# The played track is downloaded first, remaining tracks are queued
|
||||
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
|
||||
# - 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
|
||||
# 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)
|
||||
# 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)
|
||||
SPOTIFY_API_ENABLED=false
|
||||
|
||||
# Spotify Client ID from https://developer.spotify.com/dashboard
|
||||
# Create an app in the Spotify Developer Dashboard to get this
|
||||
SPOTIFY_API_CLIENT_ID=
|
||||
|
||||
# Spotify Client Secret (optional - only needed for certain OAuth flows)
|
||||
SPOTIFY_API_CLIENT_SECRET=
|
||||
|
||||
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
|
||||
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
|
||||
# via session cookie because they're not accessible through the official API.
|
||||
|
||||
+3
-15
@@ -83,21 +83,9 @@ cache/
|
||||
# Docker volumes
|
||||
redis-data/
|
||||
|
||||
# API keys and specs (ignore markdown docs, keep OpenAPI spec)
|
||||
apis/steering/
|
||||
apis/api-calls/*.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/
|
||||
# Ignore everything in apis folder except jellyfin-openapi-stable.json
|
||||
apis/*
|
||||
!apis/jellyfin-openapi-stable.json
|
||||
|
||||
# Original source code for reference
|
||||
originals/
|
||||
|
||||
@@ -37,6 +37,8 @@ The proxy will be available at `http://localhost:5274`.
|
||||
## Web Dashboard
|
||||
|
||||
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
||||
<img width="1664" height="1101" alt="image" src="https://github.com/user-attachments/assets/9159100b-7e11-449e-8530-517d336d6bd2" />
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
|
||||
### Nginx Proxy Setup (Required)
|
||||
|
||||
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
|
||||
@@ -139,8 +139,14 @@ This project brings together all the music streaming providers into one unified
|
||||
**Compatible Jellyfin clients:**
|
||||
|
||||
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
|
||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
|
||||
- [Finamp](https://github.com/jmshrv/finamp) ()
|
||||
<img width="1691" height="1128" alt="image" src="https://github.com/user-attachments/assets/c602f71c-c4dd-49a9-b533-1558e24a9f45" />
|
||||
|
||||
|
||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android)
|
||||
<img width="523" height="1025" alt="image" src="https://github.com/user-attachments/assets/135e2721-5fd7-482f-bb06-b0736003cfe7" />
|
||||
|
||||
|
||||
- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS)
|
||||
|
||||
_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.
|
||||
|
||||
<img width="1649" height="3764" alt="image" src="https://github.com/user-attachments/assets/a4d3d79c-7741-427f-8c01-ffc90f3a579b" />
|
||||
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
1. **Install the Jellyfin Spotify Import Plugin**
|
||||
|
||||
@@ -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.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
using allstarr.Models.Settings;
|
||||
@@ -40,13 +41,19 @@ public class JellyfinProxyServiceTests
|
||||
ClientName = "TestClient",
|
||||
DeviceName = "TestDevice",
|
||||
DeviceId = "test-device-id",
|
||||
ClientVersion = "1.0.0"
|
||||
ClientVersion = "1.0.1"
|
||||
};
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||
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(
|
||||
_mockHttpClientFactory.Object,
|
||||
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
|
||||
{
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
{
|
||||
version = "1.0.0",
|
||||
version = "1.0.1",
|
||||
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
||||
jellyfinUrl = _jellyfinSettings.Url,
|
||||
spotify = new
|
||||
@@ -207,6 +207,10 @@ public class AdminController : ControllerBase
|
||||
return Ok(new { baseUrl });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current configuration including cache settings
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Get list of configured playlists with their current data
|
||||
/// </summary>
|
||||
@@ -232,17 +236,17 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
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)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read cached playlist summary");
|
||||
_logger.LogError(ex, "Failed to read cached playlist summary");
|
||||
}
|
||||
}
|
||||
else if (refresh)
|
||||
{
|
||||
_logger.LogInformation("🔄 Force refresh requested for playlist summary");
|
||||
_logger.LogDebug("🔄 Force refresh requested for playlist summary");
|
||||
}
|
||||
|
||||
var playlists = new List<object>();
|
||||
@@ -259,6 +263,7 @@ public class AdminController : ControllerBase
|
||||
["id"] = config.Id,
|
||||
["jellyfinId"] = config.JellyfinId,
|
||||
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
||||
["syncSchedule"] = config.SyncSchedule ?? "0 8 * * 1",
|
||||
["trackCount"] = 0,
|
||||
["localTracks"] = 0,
|
||||
["externalTracks"] = 0,
|
||||
@@ -296,7 +301,7 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
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.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);
|
||||
|
||||
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
|
||||
// External providers: SquidWTF, Deezer, Qobuz, Tidal
|
||||
// Local tracks: Have Jellyfin ID OR no external provider keys
|
||||
var isExternal = false;
|
||||
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
@@ -398,7 +404,7 @@ public class AdminController : ControllerBase
|
||||
|
||||
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 =>
|
||||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("Deezer", StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -413,6 +419,7 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
else
|
||||
{
|
||||
// Local track (has Jellyfin ID or no external provider)
|
||||
localCount++;
|
||||
}
|
||||
}
|
||||
@@ -427,7 +434,7 @@ public class AdminController : ControllerBase
|
||||
playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count;
|
||||
playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served
|
||||
|
||||
_logger.LogInformation("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||
_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);
|
||||
}
|
||||
else
|
||||
@@ -544,7 +551,7 @@ public class AdminController : ControllerBase
|
||||
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
|
||||
playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served
|
||||
|
||||
_logger.LogDebug("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
|
||||
_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);
|
||||
}
|
||||
}
|
||||
@@ -555,19 +562,19 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed to get Jellyfin playlist {Name}: {StatusCode}",
|
||||
_logger.LogError("Failed to get Jellyfin playlist {Name}: {StatusCode}",
|
||||
config.Name, response.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name);
|
||||
_logger.LogError(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Playlist {Name} has no JellyfinId configured", config.Name);
|
||||
_logger.LogInformation("Playlist {Name} has no JellyfinId configured", config.Name);
|
||||
}
|
||||
|
||||
playlists.Add(playlistInfo);
|
||||
@@ -588,7 +595,7 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
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 });
|
||||
@@ -621,7 +628,7 @@ public class AdminController : ControllerBase
|
||||
_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);
|
||||
|
||||
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
|
||||
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)
|
||||
{
|
||||
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))
|
||||
{
|
||||
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
|
||||
@@ -664,7 +685,20 @@ public class AdminController : ControllerBase
|
||||
string? manualMappingType = 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
|
||||
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));
|
||||
|
||||
// Check for external provider keys (case-insensitive)
|
||||
// External providers: squidwtf, deezer, qobuz, tidal (lowercase)
|
||||
var providerKey = providerIds.Keys.FirstOrDefault(k =>
|
||||
// Check for external provider keys (SquidWTF, Deezer, Qobuz, Tidal)
|
||||
// If found, it's an external track
|
||||
if (providerIds.Keys.Any(k =>
|
||||
k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase) ||
|
||||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (providerKey != null)
|
||||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "SquidWTF";
|
||||
_logger.LogDebug("✓ Track {Title} identified as SquidWTF", track.Title);
|
||||
}
|
||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase))) != null)
|
||||
else if (providerIds.Keys.Any(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "Deezer";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Deezer", track.Title);
|
||||
}
|
||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase))) != null)
|
||||
else if (providerIds.Keys.Any(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "Qobuz";
|
||||
_logger.LogDebug("✓ Track {Title} identified as Qobuz", track.Title);
|
||||
}
|
||||
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase))) != null)
|
||||
else if (providerIds.Keys.Any(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
isLocal = false;
|
||||
externalProvider = "Tidal";
|
||||
@@ -720,22 +752,25 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
else
|
||||
{
|
||||
// No external provider key found - it's a local track
|
||||
// Local tracks have MusicBrainz, ISRC, Spotify IDs but no external provider
|
||||
// No external provider key found - it's a local Jellyfin track
|
||||
// Local tracks may have: Jellyfin ID, MusicBrainz IDs, ISRC, etc.
|
||||
isLocal = true;
|
||||
_logger.LogDebug("✓ Track {Title} identified as LOCAL (has ProviderIds but no external provider)", track.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Track {Title} has ProviderIds object but it's null after parsing", track.Title);
|
||||
// ProviderIds exists but is null after parsing - treat as local
|
||||
isLocal = true;
|
||||
_logger.LogDebug("✓ Track {Title} identified as LOCAL (ProviderIds null)", track.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Track {Title} in cache but has NO ProviderIds - treating as missing", track.Title);
|
||||
isLocal = null;
|
||||
externalProvider = null;
|
||||
// Track is in cache but has NO ProviderIds property at all
|
||||
// This is typical for local Jellyfin tracks - treat as local
|
||||
isLocal = true;
|
||||
_logger.LogDebug("✓ Track {Title} identified as LOCAL (in cache, no ProviderIds)", track.Title);
|
||||
}
|
||||
|
||||
// Check if this is a manual mapping
|
||||
@@ -861,7 +896,7 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
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))
|
||||
@@ -916,13 +951,14 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
|
||||
/// <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>
|
||||
[HttpPost("playlists/{name}/match")]
|
||||
public async Task<IActionResult> MatchPlaylistTracks(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Name}", decodedName);
|
||||
_logger.LogInformation("Re-match tracks triggered for playlist: {Name} (checking for local changes)", decodedName);
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
@@ -931,12 +967,31 @@ public class AdminController : ControllerBase
|
||||
|
||||
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);
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
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)
|
||||
{
|
||||
@@ -946,13 +1001,14 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
|
||||
/// <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>
|
||||
[HttpPost("playlists/{name}/clear-cache")]
|
||||
public async Task<IActionResult> ClearPlaylistCache(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Clear cache & rebuild triggered for playlist: {Name}", decodedName);
|
||||
_logger.LogInformation("Rebuild from scratch triggered for playlist: {Name} (clearing Spotify cache)", decodedName);
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
@@ -961,13 +1017,15 @@ public class AdminController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
// Clear all cache keys for this playlist
|
||||
// Clear ALL cache keys for this playlist (including Spotify data)
|
||||
var cacheKeys = new[]
|
||||
{
|
||||
$"spotify:playlist:items:{decodedName}", // Pre-built items cache
|
||||
$"spotify:matched:ordered:{decodedName}", // Ordered matched tracks
|
||||
$"spotify:matched:{decodedName}", // Legacy matched tracks
|
||||
$"spotify:missing:{decodedName}" // Missing tracks
|
||||
$"spotify:missing:{decodedName}", // Missing tracks
|
||||
$"spotify:playlist:jellyfin-signature:{decodedName}", // Jellyfin signature
|
||||
$"spotify:playlist:{decodedName}" // Spotify playlist data
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
@@ -1003,10 +1061,10 @@ public class AdminController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = $"Cache cleared and rebuild triggered for {decodedName}",
|
||||
message = $"Rebuilding {decodedName} from scratch (fetching fresh Spotify data)",
|
||||
timestamp = DateTime.UtcNow,
|
||||
clearedKeys = cacheKeys.Length,
|
||||
clearedFiles = filesToDelete.Count(System.IO.File.Exists)
|
||||
clearedFiles = filesToDelete.Count(f => System.IO.File.Exists(f))
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1047,7 +1105,7 @@ public class AdminController : ControllerBase
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -1063,7 +1121,7 @@ public class AdminController : ControllerBase
|
||||
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
|
||||
if (type != "Audio")
|
||||
{
|
||||
_logger.LogDebug("Skipping non-audio item: {Type}", type);
|
||||
_logger.LogWarning("Skipping non-audio item: {Type}", type);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1124,7 +1182,7 @@ public class AdminController : ControllerBase
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
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);
|
||||
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))
|
||||
{
|
||||
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))
|
||||
@@ -1256,7 +1314,7 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
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);
|
||||
@@ -1282,13 +1340,13 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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
|
||||
{
|
||||
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
||||
musicService = _configuration.GetValue<string>("MusicService") ?? "SquidWTF",
|
||||
explicitFilter = _configuration.GetValue<string>("ExplicitFilter") ?? "All",
|
||||
enableExternalPlaylists = _configuration.GetValue<bool>("EnableExternalPlaylists", false),
|
||||
playlistsDirectory = _configuration.GetValue<string>("PlaylistsDirectory") ?? "(not set)",
|
||||
redisEnabled = _configuration.GetValue<bool>("Redis:Enabled", false),
|
||||
spotifyApi = new
|
||||
{
|
||||
enabled = _spotifyApiSettings.Enabled,
|
||||
@@ -1454,7 +1518,7 @@ public class AdminController : ControllerBase
|
||||
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
|
||||
{
|
||||
@@ -1483,7 +1547,7 @@ public class AdminController : ControllerBase
|
||||
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
|
||||
@@ -1519,7 +1583,13 @@ public class AdminController : ControllerBase
|
||||
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
|
||||
await System.IO.File.WriteAllTextAsync(_envFilePath, newContent + "\n");
|
||||
|
||||
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
||||
_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
|
||||
{
|
||||
@@ -1641,7 +1711,7 @@ public class AdminController : ControllerBase
|
||||
[HttpPost("cache/clear")]
|
||||
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 clearedRedisKeys = 0;
|
||||
@@ -1658,7 +1728,7 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
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")]
|
||||
public async Task<IActionResult> RestartContainer()
|
||||
{
|
||||
_logger.LogInformation("Container restart requested from admin UI");
|
||||
_logger.LogDebug("Container restart requested from admin UI");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -1731,7 +1801,7 @@ public class AdminController : ControllerBase
|
||||
var containerId = Environment.MachineName;
|
||||
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
|
||||
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>
|
||||
/// Get all playlists from Jellyfin
|
||||
/// </summary>
|
||||
@@ -1990,11 +2107,16 @@ public class AdminController : ControllerBase
|
||||
trackStats = await GetPlaylistTrackStats(id!);
|
||||
}
|
||||
|
||||
// Use actual track stats for configured playlists, otherwise use Jellyfin's count
|
||||
var actualTrackCount = isConfigured
|
||||
? trackStats.LocalTracks + trackStats.ExternalTracks
|
||||
: childCount;
|
||||
|
||||
playlists.Add(new
|
||||
{
|
||||
id,
|
||||
name,
|
||||
trackCount = childCount,
|
||||
trackCount = actualTrackCount,
|
||||
linkedSpotifyId,
|
||||
isConfigured,
|
||||
localTracks = trackStats.LocalTracks,
|
||||
@@ -2056,7 +2178,7 @@ public class AdminController : ControllerBase
|
||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2163,12 +2285,19 @@ public class AdminController : ControllerBase
|
||||
Name = request.Name,
|
||||
Id = request.SpotifyPlaylistId,
|
||||
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(
|
||||
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
|
||||
@@ -2193,9 +2322,63 @@ public class AdminController : ControllerBase
|
||||
return await RemovePlaylist(decodedName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update playlist sync schedule
|
||||
/// </summary>
|
||||
[HttpPut("playlists/{name}/schedule")]
|
||||
public async Task<IActionResult> UpdatePlaylistSchedule(string name, [FromBody] UpdateScheduleRequest request)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.SyncSchedule))
|
||||
{
|
||||
return BadRequest(new { error = "SyncSchedule is required" });
|
||||
}
|
||||
|
||||
// Basic cron validation
|
||||
var cronParts = request.SyncSchedule.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (cronParts.Length != 5)
|
||||
{
|
||||
return BadRequest(new { error = "Invalid cron format. Expected: minute hour day month dayofweek" });
|
||||
}
|
||||
|
||||
// Read current playlists
|
||||
var currentPlaylists = await ReadPlaylistsFromEnvFile();
|
||||
var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
return NotFound(new { error = $"Playlist '{decodedName}' not found" });
|
||||
}
|
||||
|
||||
// Update the schedule
|
||||
playlist.SyncSchedule = request.SyncSchedule.Trim();
|
||||
|
||||
// Save back to .env
|
||||
var playlistsJson = JsonSerializer.Serialize(
|
||||
currentPlaylists.Select(p => new[] {
|
||||
p.Name,
|
||||
p.Id,
|
||||
p.JellyfinId,
|
||||
p.LocalTracksPosition.ToString().ToLower(),
|
||||
p.SyncSchedule ?? "0 8 * * 1"
|
||||
}).ToArray()
|
||||
);
|
||||
|
||||
var updateRequest = new ConfigUpdateRequest
|
||||
{
|
||||
Updates = new Dictionary<string, string>
|
||||
{
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||
}
|
||||
};
|
||||
|
||||
return await UpdateConfig(updateRequest);
|
||||
}
|
||||
|
||||
private string GetJellyfinAuthHeader()
|
||||
{
|
||||
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
||||
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.1\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2224,7 +2407,7 @@ public class AdminController : ControllerBase
|
||||
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);
|
||||
if (playlistArrays != null)
|
||||
{
|
||||
@@ -2240,7 +2423,8 @@ public class AdminController : ControllerBase
|
||||
LocalTracksPosition = arr.Length >= 4 &&
|
||||
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
||||
? 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}";
|
||||
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
|
||||
@@ -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 {
|
||||
message = "Spotify cache cleared successfully",
|
||||
@@ -2748,7 +2932,7 @@ public class AdminController : ControllerBase
|
||||
if (System.IO.File.Exists(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 {
|
||||
message = "Endpoint usage log cleared successfully",
|
||||
@@ -2917,7 +3101,7 @@ public class AdminController : ControllerBase
|
||||
// Cache the lyrics using the standard cache key
|
||||
var lyricsCacheKey = $"lyrics:{request.Artist}:{request.Title}:{request.Album ?? ""}:{request.DurationSeconds}";
|
||||
await _cache.SetAsync(lyricsCacheKey, lyricsInfo.PlainLyrics);
|
||||
_logger.LogInformation("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
|
||||
_logger.LogDebug("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -2939,7 +3123,7 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
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
|
||||
@@ -3030,7 +3214,7 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
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)
|
||||
{
|
||||
_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 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>
|
||||
@@ -3308,7 +3498,7 @@ public class LinkPlaylistRequest
|
||||
{
|
||||
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));
|
||||
|
||||
if (!Directory.Exists(keptPath))
|
||||
@@ -3327,7 +3517,7 @@ public class LinkPlaylistRequest
|
||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||
.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)
|
||||
{
|
||||
@@ -3358,7 +3548,7 @@ public class LinkPlaylistRequest
|
||||
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
|
||||
{
|
||||
@@ -3392,7 +3582,7 @@ public class LinkPlaylistRequest
|
||||
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
|
||||
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
|
||||
var normalizedFullPath = Path.GetFullPath(fullPath);
|
||||
@@ -3411,7 +3601,7 @@ public class LinkPlaylistRequest
|
||||
}
|
||||
|
||||
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)
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
@@ -3425,7 +3615,7 @@ public class LinkPlaylistRequest
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -3495,3 +3685,11 @@ public class LinkPlaylistRequest
|
||||
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 SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||
private readonly LyricsPlusService? _lyricsPlusService;
|
||||
private readonly LrclibService? _lrclibService;
|
||||
private readonly LyricsOrchestrator? _lyricsOrchestrator;
|
||||
private readonly OdesliService _odesliService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly IConfiguration _configuration;
|
||||
@@ -64,7 +66,9 @@ public class JellyfinController : ControllerBase
|
||||
PlaylistSyncService? playlistSyncService = null,
|
||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||
SpotifyLyricsService? spotifyLyricsService = null,
|
||||
LrclibService? lrclibService = null)
|
||||
LyricsPlusService? lyricsPlusService = null,
|
||||
LrclibService? lrclibService = null,
|
||||
LyricsOrchestrator? lyricsOrchestrator = null)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
@@ -80,7 +84,9 @@ public class JellyfinController : ControllerBase
|
||||
_playlistSyncService = playlistSyncService;
|
||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||
_spotifyLyricsService = spotifyLyricsService;
|
||||
_lyricsPlusService = lyricsPlusService;
|
||||
_lrclibService = lrclibService;
|
||||
_lyricsOrchestrator = lyricsOrchestrator;
|
||||
_odesliService = odesliService;
|
||||
_cache = cache;
|
||||
_configuration = configuration;
|
||||
@@ -111,7 +117,7 @@ public class JellyfinController : ControllerBase
|
||||
[FromQuery] bool recursive = true,
|
||||
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);
|
||||
|
||||
// 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" });
|
||||
}
|
||||
|
||||
_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 });
|
||||
}
|
||||
|
||||
// Update Spotify playlist counts if enabled and response contains playlists
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -248,7 +254,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
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
|
||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||
@@ -269,7 +275,7 @@ public class JellyfinController : ControllerBase
|
||||
var externalResult = await externalTask;
|
||||
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",
|
||||
externalResult.Songs.Count,
|
||||
externalResult.Albums.Count,
|
||||
@@ -279,53 +285,50 @@ public class JellyfinController : ControllerBase
|
||||
// Parse Jellyfin results into domain models
|
||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||
|
||||
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
|
||||
// Just interleave local and external results based on which source has better overall match
|
||||
// Sort all results by match score (local tracks get +10 boost)
|
||||
// This ensures best matches appear first regardless of source
|
||||
var allSongs = localSongs.Concat(externalResult.Songs)
|
||||
.Select(s => new { Song = s, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + (s.IsLocal ? 10.0 : 0.0) })
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Song)
|
||||
.ToList();
|
||||
|
||||
// Calculate average match score for each source to determine which should come first
|
||||
var localSongsAvgScore = localSongs.Any()
|
||||
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||
: 0.0;
|
||||
var externalSongsAvgScore = externalResult.Songs.Any()
|
||||
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||
: 0.0;
|
||||
var allAlbums = localAlbums.Concat(externalResult.Albums)
|
||||
.Select(a => new { Album = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + (a.IsLocal ? 10.0 : 0.0) })
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Album)
|
||||
.ToList();
|
||||
|
||||
var localAlbumsAvgScore = localAlbums.Any()
|
||||
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||
: 0.0;
|
||||
var externalAlbumsAvgScore = externalResult.Albums.Any()
|
||||
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||
: 0.0;
|
||||
var allArtists = localArtists.Concat(externalResult.Artists)
|
||||
.Select(a => new { Artist = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + (a.IsLocal ? 10.0 : 0.0) })
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Select(x => x.Artist)
|
||||
.ToList();
|
||||
|
||||
var localArtistsAvgScore = localArtists.Any()
|
||||
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||
: 0.0;
|
||||
var externalArtistsAvgScore = externalResult.Artists.Any()
|
||||
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||
: 0.0;
|
||||
|
||||
// Interleave results: put better-matching source first, preserve original ordering within each source
|
||||
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
|
||||
// Log top results for debugging
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
|
||||
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
|
||||
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
|
||||
if (allSongs.Any())
|
||||
{
|
||||
var topSong = allSongs.First();
|
||||
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topSong.Title) + (topSong.IsLocal ? 10.0 : 0.0);
|
||||
_logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal}, score={Score:F2})",
|
||||
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
|
||||
@@ -343,7 +346,7 @@ public class JellyfinController : ControllerBase
|
||||
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);
|
||||
|
||||
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
||||
@@ -374,7 +377,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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
|
||||
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"))
|
||||
{
|
||||
_logger.LogInformation("Adding {Count} artists to results", mergedArtists.Count);
|
||||
_logger.LogDebug("Adding {Count} artists to results", mergedArtists.Count);
|
||||
items.AddRange(mergedArtists);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
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
|
||||
{
|
||||
@@ -419,11 +422,11 @@ public class JellyfinController : ControllerBase
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
|
||||
{
|
||||
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
|
||||
await _cache.SetAsync(cacheKey, response, TimeSpan.FromMinutes(15));
|
||||
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' (15 min TTL)", searchTerm);
|
||||
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
|
||||
_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
|
||||
{
|
||||
@@ -612,7 +615,7 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
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>()));
|
||||
|
||||
// Check if asking for audio (album tracks)
|
||||
@@ -633,7 +636,7 @@ public class JellyfinController : ControllerBase
|
||||
var albums = await _metadataService.GetArtistAlbumsAsync(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
|
||||
if (artist != null)
|
||||
@@ -664,13 +667,13 @@ public class JellyfinController : ControllerBase
|
||||
[FromQuery] int limit = 50,
|
||||
[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 (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
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
|
||||
var jellyfinTask = _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers);
|
||||
@@ -681,7 +684,7 @@ public class JellyfinController : ControllerBase
|
||||
var (jellyfinResult, _) = await jellyfinTask;
|
||||
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);
|
||||
|
||||
// Parse Jellyfin artists
|
||||
@@ -698,7 +701,7 @@ public class JellyfinController : ControllerBase
|
||||
// Show ALL matches (local + external) sorted by best match first
|
||||
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
|
||||
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))
|
||||
{
|
||||
// Update last access time for cache cleanup
|
||||
// Update last write time for cache cleanup (extends cache lifetime)
|
||||
try
|
||||
{
|
||||
System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow);
|
||||
System.IO.File.SetLastWriteTimeUtc(localPath, DateTime.UtcNow);
|
||||
}
|
||||
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);
|
||||
@@ -1103,7 +1106,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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 await GetPlaceholderImageAsync();
|
||||
}
|
||||
@@ -1144,7 +1147,7 @@ public class JellyfinController : ControllerBase
|
||||
[HttpGet("Items/{itemId}/Lyrics")]
|
||||
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))
|
||||
{
|
||||
@@ -1153,18 +1156,18 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
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);
|
||||
|
||||
// For local tracks, check if Jellyfin already has embedded lyrics
|
||||
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)
|
||||
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);
|
||||
|
||||
if (jellyfinLyrics != null && statusCode == 200)
|
||||
@@ -1173,7 +1176,7 @@ public class JellyfinController : ControllerBase
|
||||
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
|
||||
@@ -1197,7 +1200,7 @@ public class JellyfinController : ControllerBase
|
||||
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||
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);
|
||||
}
|
||||
else
|
||||
@@ -1225,7 +1228,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1274,50 +1277,53 @@ public class JellyfinController : ControllerBase
|
||||
searchArtists.Add(searchArtist);
|
||||
}
|
||||
|
||||
// Use orchestrator for clean, modular lyrics fetching
|
||||
LyricsInfo? lyrics = null;
|
||||
|
||||
if (_lyricsOrchestrator != null)
|
||||
{
|
||||
lyrics = await _lyricsOrchestrator.GetLyricsAsync(
|
||||
trackName: searchTitle,
|
||||
artistNames: searchArtists.ToArray(),
|
||||
albumName: searchAlbum,
|
||||
durationSeconds: song.Duration ?? 0,
|
||||
spotifyTrackId: spotifyTrackId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to manual fetching if orchestrator not available
|
||||
_logger.LogWarning("LyricsOrchestrator not available, using fallback method");
|
||||
|
||||
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
|
||||
// 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)
|
||||
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
||||
|
||||
// Spotify track IDs are 22 characters, base62 encoded
|
||||
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
|
||||
{
|
||||
_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);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to LRCLIB if no Spotify lyrics
|
||||
if (lyrics == null)
|
||||
// Fall back to LyricsPlus
|
||||
if (lyrics == null && _lyricsPlusService != null)
|
||||
{
|
||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
||||
string.Join(", ", searchArtists),
|
||||
searchTitle);
|
||||
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||
if (lrclibService != 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(
|
||||
lyrics = await _lrclibService.GetLyricsAsync(
|
||||
searchTitle,
|
||||
searchArtists.ToArray(),
|
||||
searchAlbum,
|
||||
@@ -1342,7 +1348,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
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
|
||||
// Skip ID tags like [ar:Artist], [ti:Title], etc.
|
||||
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.
|
||||
}
|
||||
_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))
|
||||
{
|
||||
@@ -1386,7 +1392,7 @@ public class JellyfinController : ControllerBase
|
||||
["Text"] = line.Trim()
|
||||
});
|
||||
}
|
||||
_logger.LogInformation("Split into {Count} plain lyric lines", lyricLines.Count);
|
||||
_logger.LogDebug("Split into {Count} plain lyric lines", lyricLines.Count);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1411,14 +1417,14 @@ public class JellyfinController : ControllerBase
|
||||
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
|
||||
if (lyricLines.Count > 0)
|
||||
{
|
||||
var sampleLine = lyricLines[0];
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1498,6 +1504,21 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
_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
|
||||
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
|
||||
if (_lrclibService != null)
|
||||
{
|
||||
@@ -1537,7 +1574,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
_logger.LogInformation("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
||||
_logger.LogDebug("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
|
||||
userId, itemId, Request.Path);
|
||||
|
||||
// Check if this is an external playlist - trigger download
|
||||
@@ -1628,7 +1665,7 @@ public class JellyfinController : ControllerBase
|
||||
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);
|
||||
|
||||
@@ -1649,7 +1686,7 @@ public class JellyfinController : ControllerBase
|
||||
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);
|
||||
|
||||
// External items - remove from kept folder if it exists
|
||||
@@ -1686,7 +1723,7 @@ public class JellyfinController : ControllerBase
|
||||
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);
|
||||
|
||||
@@ -1748,7 +1785,7 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
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
|
||||
if (PlaylistIdHelper.IsExternalPlaylist(playlistId))
|
||||
@@ -1788,7 +1825,7 @@ public class JellyfinController : ControllerBase
|
||||
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);
|
||||
|
||||
return HandleProxyResponse(result, statusCode);
|
||||
@@ -1834,15 +1871,15 @@ public class JellyfinController : ControllerBase
|
||||
var imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
||||
|
||||
// Cache for 1 hour (playlists can change, so don't cache too long)
|
||||
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
|
||||
// Cache for configurable duration (playlists can change)
|
||||
await _cache.SetAsync(cacheKey, imageBytes, CacheExtensions.PlaylistImagesTTL);
|
||||
_logger.LogDebug("Cached playlist image for {PlaylistId}", playlistId);
|
||||
|
||||
return File(imageBytes, contentType);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1870,7 +1907,7 @@ public class JellyfinController : ControllerBase
|
||||
// Reset stream position
|
||||
Request.Body.Position = 0;
|
||||
|
||||
_logger.LogInformation("Authentication request received");
|
||||
_logger.LogDebug("Authentication request received");
|
||||
// DO NOT log request body or detailed headers - contains password
|
||||
|
||||
// Forward to Jellyfin server with client headers - completely transparent proxy
|
||||
@@ -1933,14 +1970,14 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to post session capabilities after auth");
|
||||
_logger.LogError(ex, "Failed to post session capabilities after auth");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
|
||||
_logger.LogError("Authentication failed - status {StatusCode}", statusCode);
|
||||
}
|
||||
|
||||
// Return Jellyfin's exact response
|
||||
@@ -2023,7 +2060,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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
|
||||
{
|
||||
Items = Array.Empty<object>(),
|
||||
@@ -2132,7 +2169,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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
|
||||
{
|
||||
Items = Array.Empty<object>(),
|
||||
@@ -2212,7 +2249,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
else if (statusCode == 401)
|
||||
{
|
||||
_logger.LogDebug("⚠ Jellyfin returned 401 for capabilities (token expired)");
|
||||
_logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2294,7 +2331,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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)
|
||||
{
|
||||
_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
|
||||
{
|
||||
_logger.LogWarning("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId);
|
||||
_logger.LogError("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -2414,7 +2451,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
@@ -2427,7 +2464,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -2540,7 +2577,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to report playback progress");
|
||||
_logger.LogError(ex, "Failed to report playback progress");
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -2561,7 +2598,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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
|
||||
var doc = JsonDocument.Parse(body);
|
||||
@@ -2657,7 +2694,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
else if (statusCode == 401)
|
||||
{
|
||||
_logger.LogDebug("Playback stop returned 401 (token expired)");
|
||||
_logger.LogWarning("Playback stop returned 401 (token expired)");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2668,7 +2705,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to report playback stopped");
|
||||
_logger.LogError(ex, "Failed to report playback stopped");
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -2690,7 +2727,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to ping playback session");
|
||||
_logger.LogError(ex, "Failed to ping playback session");
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -2778,7 +2815,7 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
LocalAddress = Request.Host.ToString(),
|
||||
ServerName = serverName ?? "Allstarr",
|
||||
Version = version ?? "1.0.0",
|
||||
Version = version ?? "1.0.1",
|
||||
ProductName = "Allstarr (Jellyfin Proxy)",
|
||||
OperatingSystem = Environment.OSVersion.Platform.ToString(),
|
||||
Id = _settings.DeviceId,
|
||||
@@ -2862,7 +2899,7 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
var playlistId = parts[1];
|
||||
|
||||
_logger.LogInformation("=== PLAYLIST REQUEST ===");
|
||||
_logger.LogDebug("=== PLAYLIST REQUEST ===");
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
||||
_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)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to proxy binary request for {Path}", path);
|
||||
_logger.LogError(ex, "Failed to proxy binary request for {Path}", path);
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
@@ -2948,12 +2985,12 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
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
|
||||
if (path.EndsWith("/items", StringComparison.OrdinalIgnoreCase) || path.Equals("items", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Redirecting to SearchItems");
|
||||
_logger.LogDebug("Redirecting to SearchItems");
|
||||
return await SearchItems(
|
||||
searchTerm: searchTerm,
|
||||
includeItemTypes: Request.Query["IncludeItemTypes"],
|
||||
@@ -2994,7 +3031,7 @@ public class JellyfinController : ControllerBase
|
||||
Request.EnableBuffering();
|
||||
|
||||
// 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);
|
||||
|
||||
// Read body using StreamReader with proper encoding
|
||||
@@ -3018,7 +3055,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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);
|
||||
|
||||
// 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.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);
|
||||
}
|
||||
|
||||
@@ -3084,7 +3121,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -3146,7 +3183,7 @@ public class JellyfinController : ControllerBase
|
||||
var modified = false;
|
||||
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)
|
||||
{
|
||||
@@ -3179,7 +3216,7 @@ public class JellyfinController : ControllerBase
|
||||
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
||||
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);
|
||||
|
||||
// Fallback to legacy cache format
|
||||
@@ -3204,7 +3241,7 @@ public class JellyfinController : ControllerBase
|
||||
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
|
||||
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
|
||||
itemDict["ChildCount"] = fileItems.Count;
|
||||
modified = true;
|
||||
@@ -3237,13 +3274,13 @@ public class JellyfinController : ControllerBase
|
||||
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -3262,7 +3299,7 @@ public class JellyfinController : ControllerBase
|
||||
// Update ChildCount to show actual available tracks
|
||||
itemDict["ChildCount"] = totalAvailableCount;
|
||||
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);
|
||||
}
|
||||
else
|
||||
@@ -3288,7 +3325,7 @@ public class JellyfinController : ControllerBase
|
||||
return response;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Modified {Count} Spotify playlists, rebuilding response",
|
||||
_logger.LogDebug("Modified {Count} Spotify playlists, rebuilding response",
|
||||
updatedItems.Count(i => i.ContainsKey("ChildCount")));
|
||||
|
||||
// Rebuild the response with updated items
|
||||
@@ -3304,7 +3341,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to update Spotify playlist counts");
|
||||
_logger.LogError(ex, "Failed to update Spotify playlist counts");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -3336,7 +3373,7 @@ public class JellyfinController : ControllerBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 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>
|
||||
/// 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>
|
||||
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 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);
|
||||
|
||||
// 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
|
||||
{
|
||||
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
|
||||
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
|
||||
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);
|
||||
|
||||
// Restore to Redis cache
|
||||
await _cache.SetAsync(cacheKey, fileItems, TimeSpan.FromHours(24));
|
||||
await _cache.SetAsync(cacheKey, fileItems, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||
|
||||
return new JsonResult(new
|
||||
{
|
||||
@@ -3512,12 +3554,12 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
// Get existing Jellyfin playlist items (RAW - don't convert!)
|
||||
@@ -3529,8 +3571,17 @@ public class JellyfinController : ControllerBase
|
||||
return null; // Fall back to legacy mode
|
||||
}
|
||||
|
||||
// Request MediaSources field to get bitrate info
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||
// Pass through all requested fields from the original request
|
||||
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}",
|
||||
playlistId, userId);
|
||||
@@ -3597,7 +3648,7 @@ public class JellyfinController : ControllerBase
|
||||
var localUsedCount = 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))
|
||||
{
|
||||
@@ -3681,15 +3732,17 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||
_logger.LogDebug("🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
|
||||
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
|
||||
|
||||
// Save to file cache for persistence across restarts
|
||||
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
||||
|
||||
// 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 new JsonResult(new
|
||||
@@ -3710,7 +3763,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
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);
|
||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||
}
|
||||
@@ -3721,8 +3774,8 @@ public class JellyfinController : ControllerBase
|
||||
cachedTracks = await LoadMatchedTracksFromFile(spotifyPlaylistName);
|
||||
if (cachedTracks != null && cachedTracks.Count > 0)
|
||||
{
|
||||
// Restore to Redis with 1 hour TTL
|
||||
await _cache.SetAsync(cacheKey, cachedTracks, TimeSpan.FromHours(1));
|
||||
// Restore to Redis with configurable TTL
|
||||
await _cache.SetAsync(cacheKey, cachedTracks, CacheExtensions.SpotifyMatchedTracksTTL);
|
||||
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist}",
|
||||
cachedTracks.Count, spotifyPlaylistName);
|
||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||
@@ -3739,7 +3792,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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(
|
||||
@@ -3784,7 +3837,7 @@ public class JellyfinController : ControllerBase
|
||||
if (missingTracks != null && missingTracks.Count > 0)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3796,7 +3849,7 @@ public class JellyfinController : ControllerBase
|
||||
return _responseBuilder.CreateItemsResponse(existingTracks);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {Count} missing tracks for {Playlist}",
|
||||
_logger.LogDebug("Matching {Count} missing tracks for {Playlist}",
|
||||
missingTracks.Count, spotifyPlaylistName);
|
||||
|
||||
// Match missing tracks sequentially with rate limiting (excluding ones we already have locally)
|
||||
@@ -3855,7 +3908,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3879,7 +3932,7 @@ public class JellyfinController : ControllerBase
|
||||
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
|
||||
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
|
||||
@@ -3948,7 +4001,7 @@ public class JellyfinController : ControllerBase
|
||||
if (cacheFiles.Length > 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)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
|
||||
_logger.LogError(ex, "Failed to download track {ItemId}", itemId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3983,7 +4036,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
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
|
||||
var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
|
||||
@@ -4044,7 +4097,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4083,7 +4136,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_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 });
|
||||
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)
|
||||
@@ -4224,7 +4277,7 @@ public class JellyfinController : ControllerBase
|
||||
foreach (var trackFile in trackFiles)
|
||||
{
|
||||
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
|
||||
@@ -4242,7 +4295,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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 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);
|
||||
|
||||
return tracks;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4295,7 +4348,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4309,7 +4362,7 @@ public class JellyfinController : ControllerBase
|
||||
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 tracks = JsonSerializer.Deserialize<List<Song>>(json);
|
||||
@@ -4321,7 +4374,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
@@ -4367,7 +4468,7 @@ public class JellyfinController : ControllerBase
|
||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||
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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -4397,7 +4498,7 @@ public class JellyfinController : ControllerBase
|
||||
// Check if cache is too old (more than 24 hours)
|
||||
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);
|
||||
return null;
|
||||
}
|
||||
@@ -4407,14 +4508,14 @@ public class JellyfinController : ControllerBase
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
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);
|
||||
|
||||
return items;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4571,7 +4672,7 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,14 +145,14 @@ public class SubsonicController : ControllerBase
|
||||
|
||||
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
|
||||
{
|
||||
System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow);
|
||||
System.IO.File.SetLastWriteTimeUtc(localPath, DateTime.UtcNow);
|
||||
}
|
||||
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);
|
||||
@@ -590,8 +590,8 @@ public class SubsonicController : ControllerBase
|
||||
var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync();
|
||||
var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
|
||||
|
||||
// Cache for 1 hour (playlists can change, so don't cache too long)
|
||||
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
|
||||
// Cache for configurable duration (playlists can change)
|
||||
await _cache.SetAsync(cacheKey, imageBytes, CacheExtensions.PlaylistImagesTTL);
|
||||
_logger.LogDebug("Cached playlist cover art for {Id}", id);
|
||||
|
||||
return File(imageBytes, contentType);
|
||||
|
||||
@@ -46,7 +46,7 @@ public class ApiKeyAuthFilter : IAsyncActionFilter
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("API key authentication successful for {Path}", request.Path);
|
||||
_logger.LogInformation("API key authentication successful for {Path}", request.Path);
|
||||
await next();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ public class WebSocketProxyMiddleware
|
||||
_logger = logger;
|
||||
_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)
|
||||
@@ -139,10 +139,10 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
|
||||
// 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);
|
||||
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
||||
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
||||
|
||||
// Start bidirectional proxying
|
||||
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
|
||||
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
|
||||
{
|
||||
@@ -180,7 +180,7 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
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)
|
||||
{
|
||||
_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
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ public class WebSocketProxyMiddleware
|
||||
if (direction == "Server→Client")
|
||||
{
|
||||
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
|
||||
_logger.LogTrace("📥 WEBSOCKET {Direction}: {Preview}",
|
||||
_logger.LogDebug("📥 WEBSOCKET {Direction}: {Preview}",
|
||||
direction,
|
||||
messageText.Length > 500 ? messageText[..500] + "..." : messageText);
|
||||
}
|
||||
@@ -282,7 +282,7 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
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.
|
||||
/// </summary>
|
||||
public List<string> Artists { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// All artist IDs corresponding to the Artists list. Index-matched with Artists.
|
||||
/// </summary>
|
||||
public List<string> ArtistIds { get; set; } = new();
|
||||
|
||||
public string Album { get; set; } = string.Empty;
|
||||
public string? AlbumId { get; set; }
|
||||
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>
|
||||
/// Client version reported to Jellyfin
|
||||
/// </summary>
|
||||
public string ClientVersion { get; set; } = "1.0.0";
|
||||
public string ClientVersion { get; set; } = "1.0.1";
|
||||
|
||||
/// <summary>
|
||||
/// Device ID reported to Jellyfin
|
||||
|
||||
@@ -18,18 +18,6 @@ public class SpotifyApiSettings
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Spotify Client ID from https://developer.spotify.com/dashboard
|
||||
/// Used for OAuth token refresh and API access.
|
||||
/// </summary>
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Spotify Client Secret from https://developer.spotify.com/dashboard
|
||||
/// Optional - only needed for certain OAuth flows.
|
||||
/// </summary>
|
||||
public string ClientSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Spotify session cookie (sp_dc).
|
||||
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.
|
||||
|
||||
@@ -45,6 +45,14 @@ public class SpotifyPlaylistConfig
|
||||
/// Where to position local tracks: "first" or "last"
|
||||
/// </summary>
|
||||
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
|
||||
|
||||
/// <summary>
|
||||
/// Cron schedule for syncing this playlist with Spotify
|
||||
/// Format: minute hour day month dayofweek
|
||||
/// Example: "0 8 * * 1" = 8 AM every Monday
|
||||
/// Default: "0 8 * * 1" (weekly on Monday at 8 AM)
|
||||
/// </summary>
|
||||
public string SyncSchedule { get; set; } = "0 8 * * 1";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,12 +6,12 @@ namespace allstarr.Models.Settings;
|
||||
public class SquidWTFSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// No user auth should be needed for this site.
|
||||
/// </summary>
|
||||
|
||||
/// <summary>
|
||||
/// Preferred audio quality: FLAC, MP3_320, MP3_128
|
||||
/// If not specified or unavailable, the highest available quality will be used.
|
||||
/// Preferred audio quality:
|
||||
/// - HI_RES or HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest quality)
|
||||
/// - FLAC or LOSSLESS: 16-bit/44.1kHz FLAC (CD quality, default)
|
||||
/// - HIGH: 320kbps AAC (high quality, smaller files)
|
||||
/// - LOW: 96kbps AAC (low quality, smallest files)
|
||||
/// If not specified or unavailable, LOSSLESS will be used.
|
||||
/// </summary>
|
||||
public string? Quality { get; set; }
|
||||
}
|
||||
|
||||
+51
-14
@@ -13,9 +13,28 @@ using allstarr.Middleware;
|
||||
using allstarr.Filters;
|
||||
using Microsoft.Extensions.Http;
|
||||
using System.Text;
|
||||
using System.Net;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure forwarded headers for reverse proxy support (nginx, etc.)
|
||||
// This allows ASP.NET Core to read X-Forwarded-For, X-Real-IP, etc.
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
|
||||
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
|
||||
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedHost;
|
||||
|
||||
// Clear known networks and proxies to accept headers from any proxy
|
||||
// This is safe when running behind a trusted reverse proxy (nginx)
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
|
||||
// Trust X-Forwarded-* headers from any source
|
||||
// Only do this if your reverse proxy is properly configured and trusted
|
||||
options.ForwardLimit = null;
|
||||
});
|
||||
|
||||
// Decode SquidWTF API base URLs once at startup
|
||||
var squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||
static List<string> DecodeSquidWtfUrls()
|
||||
@@ -131,6 +150,8 @@ builder.Services.Configure<SquidWTFSettings>(
|
||||
builder.Configuration.GetSection("SquidWTF"));
|
||||
builder.Services.Configure<RedisSettings>(
|
||||
builder.Configuration.GetSection("Redis"));
|
||||
builder.Services.Configure<CacheSettings>(
|
||||
builder.Configuration.GetSection("Cache"));
|
||||
// Configure Spotify Import settings with custom playlist parsing from env var
|
||||
builder.Services.Configure<SpotifyImportSettings>(options =>
|
||||
{
|
||||
@@ -454,7 +475,8 @@ else if (musicService == MusicService.SquidWTF)
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
|
||||
sp.GetRequiredService<RedisCacheService>(),
|
||||
squidWtfApiUrls));
|
||||
squidWtfApiUrls,
|
||||
sp.GetRequiredService<GenreEnrichmentService>()));
|
||||
builder.Services.AddSingleton<IDownloadService>(sp =>
|
||||
new SquidWTFDownloadService(
|
||||
sp.GetRequiredService<IHttpClientFactory>(),
|
||||
@@ -505,6 +527,9 @@ builder.Services.AddHostedService<CacheCleanupService>();
|
||||
// Register cache warming service (loads file caches into Redis on startup)
|
||||
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
|
||||
// Configure from environment variables with SPOTIFY_API_ prefix
|
||||
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);
|
||||
}
|
||||
|
||||
var clientId = builder.Configuration.GetValue<string>("SpotifyApi:ClientId");
|
||||
if (!string.IsNullOrEmpty(clientId))
|
||||
{
|
||||
options.ClientId = clientId;
|
||||
}
|
||||
|
||||
var clientSecret = builder.Configuration.GetValue<string>("SpotifyApi:ClientSecret");
|
||||
if (!string.IsNullOrEmpty(clientSecret))
|
||||
{
|
||||
options.ClientSecret = clientSecret;
|
||||
}
|
||||
|
||||
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
|
||||
if (!string.IsNullOrEmpty(sessionCookie))
|
||||
{
|
||||
@@ -557,7 +570,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
|
||||
// Log configuration (mask sensitive values)
|
||||
Console.WriteLine($"SpotifyApi Configuration:");
|
||||
Console.WriteLine($" Enabled: {options.Enabled}");
|
||||
Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}");
|
||||
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
|
||||
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
|
||||
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");
|
||||
@@ -568,6 +580,12 @@ builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyApiClient>();
|
||||
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
||||
|
||||
// Register 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)
|
||||
builder.Services.AddSingleton<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();
|
||||
|
||||
// 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.
|
||||
|
||||
// IMPORTANT: UseForwardedHeaders must be called BEFORE other middleware
|
||||
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
app.UseExceptionHandler(_ => { }); // Global exception handler
|
||||
|
||||
// Enable response compression EARLY in the pipeline
|
||||
|
||||
@@ -104,10 +104,10 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
|
||||
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)
|
||||
{
|
||||
IOFile.SetLastAccessTime(localPath, DateTime.UtcNow);
|
||||
IOFile.SetLastWriteTime(localPath, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
// 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
|
||||
await DownloadLock.WaitAsync(cancellationToken);
|
||||
var lockHeld = true;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -273,10 +274,10 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
{
|
||||
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)
|
||||
{
|
||||
IOFile.SetLastAccessTime(existingPath, DateTime.UtcNow);
|
||||
IOFile.SetLastWriteTime(existingPath, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
return existingPath;
|
||||
@@ -288,6 +289,7 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
|
||||
// Release lock while waiting
|
||||
DownloadLock.Release();
|
||||
lockHeld = false;
|
||||
|
||||
// Wait for download to complete, checking every 100ms (faster than 500ms)
|
||||
// Also respect cancellation token so client timeouts are handled immediately
|
||||
@@ -443,10 +445,13 @@ public abstract class BaseDownloadService : IDownloadService
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (lockHeld)
|
||||
{
|
||||
DownloadLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task DownloadRemainingAlbumTracksAsync(string albumExternalId, string excludeTrackExternalId)
|
||||
{
|
||||
|
||||
@@ -66,7 +66,9 @@ public class CacheCleanupService : BackgroundService
|
||||
|
||||
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var cachePath = PathHelper.GetCachePath();
|
||||
// Get the actual cache path used by download services
|
||||
var downloadPath = _configuration["Library:DownloadPath"] ?? "downloads";
|
||||
var cachePath = Path.Combine(downloadPath, "cache");
|
||||
|
||||
if (!Directory.Exists(cachePath))
|
||||
{
|
||||
@@ -78,7 +80,7 @@ public class CacheCleanupService : BackgroundService
|
||||
var deletedCount = 0;
|
||||
var totalSize = 0L;
|
||||
|
||||
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime}", cutoffTime);
|
||||
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime} from {Path}", cutoffTime, cachePath);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -108,7 +110,7 @@ public class CacheCleanupService : BackgroundService
|
||||
}
|
||||
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
|
||||
{
|
||||
_logger.LogDebug("Cache cleanup completed: no files to delete");
|
||||
_logger.LogInformation("Cache cleanup completed: no files to delete");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -154,13 +156,13 @@ public class CacheCleanupService : BackgroundService
|
||||
}
|
||||
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)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error cleaning up empty directories");
|
||||
_logger.LogError(ex, "Error cleaning up empty directories");
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
var redisKey = $"genre:{cacheEntry.CacheKey}";
|
||||
await _cache.SetAsync(redisKey, cacheEntry.Genre, TimeSpan.FromDays(30));
|
||||
await _cache.SetAsync(redisKey, cacheEntry.Genre, CacheExtensions.GenreTTL);
|
||||
warmedCount++;
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
_logger.LogInformation("🔥 Warmed {Count} genre entries from file cache", warmedCount);
|
||||
_logger.LogDebug("🔥 Warmed {Count} genre entries from file cache", warmedCount);
|
||||
}
|
||||
|
||||
return warmedCount;
|
||||
@@ -162,7 +162,7 @@ public class CacheWarmingService : IHostedService
|
||||
var playlistName = fileName.Replace("_items", "");
|
||||
|
||||
var redisKey = $"spotify:playlist:items:{playlistName}";
|
||||
await _cache.SetAsync(redisKey, items, TimeSpan.FromHours(24));
|
||||
await _cache.SetAsync(redisKey, items, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||
warmedCount++;
|
||||
|
||||
_logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)",
|
||||
@@ -171,7 +171,7 @@ public class CacheWarmingService : IHostedService
|
||||
}
|
||||
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 redisKey = $"spotify:matched:ordered:{playlistName}";
|
||||
await _cache.SetAsync(redisKey, matchedTracks, TimeSpan.FromHours(1));
|
||||
await _cache.SetAsync(redisKey, matchedTracks, CacheExtensions.SpotifyMatchedTracksTTL);
|
||||
warmedCount++;
|
||||
|
||||
_logger.LogDebug("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
|
||||
_logger.LogInformation("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
|
||||
playlistName, matchedTracks.Count);
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
_logger.LogInformation("🔥 Warmed {Count} playlist caches from file system", warmedCount);
|
||||
_logger.LogDebug("🔥 Warmed {Count} playlist caches from file system", warmedCount);
|
||||
}
|
||||
|
||||
return warmedCount;
|
||||
@@ -276,13 +276,13 @@ public class CacheWarmingService : IHostedService
|
||||
}
|
||||
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)
|
||||
{
|
||||
_logger.LogInformation("🔥 Warmed {Count} manual mappings from file system", warmedCount);
|
||||
_logger.LogDebug("🔥 Warmed {Count} manual mappings from file system", warmedCount);
|
||||
}
|
||||
|
||||
return warmedCount;
|
||||
@@ -318,13 +318,13 @@ public class CacheWarmingService : IHostedService
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -356,7 +356,7 @@ public class CacheWarmingService : IHostedService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm lyrics cache");
|
||||
_logger.LogError(ex, "Failed to warm lyrics cache");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ public class EndpointBenchmarkService
|
||||
/// <summary>
|
||||
/// Benchmarks a list of endpoints by making test requests.
|
||||
/// Returns endpoints sorted by average response time (fastest first).
|
||||
///
|
||||
/// IMPORTANT: The testFunc should implement its own timeout to prevent slow endpoints
|
||||
/// from blocking startup. Recommended: 5-10 second timeout per ping.
|
||||
/// </summary>
|
||||
public async Task<List<string>> BenchmarkEndpointsAsync(
|
||||
List<string> endpoints,
|
||||
@@ -27,7 +30,7 @@ public class EndpointBenchmarkService
|
||||
int pingCount = 3,
|
||||
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 =>
|
||||
{
|
||||
@@ -51,7 +54,7 @@ public class EndpointBenchmarkService
|
||||
}
|
||||
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
|
||||
@@ -82,7 +85,7 @@ public class EndpointBenchmarkService
|
||||
_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);
|
||||
|
||||
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:
|
||||
/// 1. Strip decorators (already done by caller)
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static int CalculateSimilarity(string query, string target)
|
||||
@@ -103,11 +104,71 @@ public static class FuzzyMatcher
|
||||
return 85;
|
||||
}
|
||||
|
||||
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
||||
// Only use this for candidates that survived substring checks
|
||||
// STEP 3: TOKEN-BASED MATCHING (handles word order)
|
||||
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)
|
||||
{
|
||||
@@ -117,8 +178,9 @@ public static class FuzzyMatcher
|
||||
// Normalize distance by length: score = 1 - (distance / max_length)
|
||||
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
||||
|
||||
// Convert to 0-80 range (reserve 80-100 for substring matches)
|
||||
var score = (int)(normalizedSimilarity * 80);
|
||||
// Convert to 0-75 range (reserve 75-100 for substring/token matches)
|
||||
// Using 75 instead of 80 to be slightly stricter
|
||||
var score = (int)(normalizedSimilarity * 75);
|
||||
|
||||
return Math.Max(0, score);
|
||||
}
|
||||
@@ -154,7 +216,9 @@ public static class FuzzyMatcher
|
||||
/// <summary>
|
||||
/// Normalizes a string for matching by:
|
||||
/// - 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
|
||||
/// </summary>
|
||||
private static string NormalizeForMatching(string text)
|
||||
@@ -166,19 +230,43 @@ public static class FuzzyMatcher
|
||||
|
||||
var normalized = text.ToLowerInvariant().Trim();
|
||||
|
||||
// Normalize different apostrophe types to standard apostrophe
|
||||
normalized = normalized
|
||||
.Replace("\u2019", "'") // Right single quotation mark (')
|
||||
.Replace("\u2018", "'") // Left single quotation mark (')
|
||||
.Replace("`", "'") // Grave accent
|
||||
.Replace("\u00B4", "'"); // Acute accent (´)
|
||||
// Remove accents/diacritics (é -> e, ñ -> n, etc.)
|
||||
normalized = RemoveDiacritics(normalized);
|
||||
|
||||
// Replace hyphens and underscores with spaces (for word separation)
|
||||
// This ensures "Dua-Lipa" becomes "Dua Lipa" not "DuaLipa"
|
||||
normalized = normalized.Replace('-', ' ').Replace('_', ' ');
|
||||
|
||||
// Remove all other punctuation: periods, apostrophes, commas, etc.
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", "");
|
||||
|
||||
// Normalize whitespace
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
|
||||
|
||||
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>
|
||||
/// Calculates Levenshtein distance between two strings.
|
||||
/// </summary>
|
||||
|
||||
@@ -93,7 +93,7 @@ public class GenreEnrichmentService
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ public class GenreEnrichmentService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,7 @@ public class GenreEnrichmentService
|
||||
}
|
||||
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)
|
||||
{
|
||||
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
|
||||
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
|
||||
// Cache for configurable duration
|
||||
await _cache.SetAsync(cacheKey, spotifyId, CacheExtensions.OdesliLookupTTL);
|
||||
|
||||
return spotifyId;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public class OdesliService
|
||||
}
|
||||
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;
|
||||
@@ -122,10 +122,10 @@ public class OdesliService
|
||||
if (match.Success)
|
||||
{
|
||||
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
|
||||
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
|
||||
// Cache for configurable duration
|
||||
await _cache.SetAsync(cacheKey, spotifyId, CacheExtensions.OdesliLookupTTL);
|
||||
|
||||
return spotifyId;
|
||||
}
|
||||
@@ -135,7 +135,7 @@ public class OdesliService
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -51,7 +51,7 @@ public class ParallelMetadataService
|
||||
}
|
||||
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);
|
||||
}
|
||||
}).ToList();
|
||||
@@ -64,7 +64,7 @@ public class ParallelMetadataService
|
||||
|
||||
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);
|
||||
return result.Result;
|
||||
}
|
||||
@@ -110,7 +110,7 @@ public class ParallelMetadataService
|
||||
}
|
||||
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);
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
@@ -28,8 +28,10 @@ public static class PathHelper
|
||||
/// <param name="title">Track title (will be sanitized).</param>
|
||||
/// <param name="trackNumber">Optional track number for prefix.</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>
|
||||
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 safeAlbum = SanitizeFolderName(album);
|
||||
@@ -39,7 +41,10 @@ public static class PathHelper
|
||||
var albumFolder = Path.Combine(artistFolder, safeAlbum);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public class RedisCacheService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Redis connection failed. Caching disabled.");
|
||||
_logger.LogError(ex, "Redis connection failed. Caching disabled.");
|
||||
_redis = null;
|
||||
_db = null;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public class RedisCacheService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ public class RedisCacheService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,7 @@ public class RedisCacheService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ public class RedisCacheService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ public class RedisCacheService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ public class RedisCacheService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -190,12 +190,12 @@ public class RedisCacheService
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
_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
|
||||
lock (_healthCacheLock)
|
||||
@@ -137,7 +137,7 @@ public class RoundRobinFallbackHelper
|
||||
_apiUrls.AddRange(reordered);
|
||||
_currentUrlIndex = 0;
|
||||
|
||||
_logger.LogInformation("📊 {Service} endpoints reordered by benchmark: {Endpoints}",
|
||||
_logger.LogDebug("📊 {Service} endpoints reordered by benchmark: {Endpoints}",
|
||||
_serviceName, string.Join(", ", _apiUrls.Take(3)));
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ public class RoundRobinFallbackHelper
|
||||
}
|
||||
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);
|
||||
|
||||
// Mark as unhealthy in cache
|
||||
@@ -227,7 +227,7 @@ public class RoundRobinFallbackHelper
|
||||
}
|
||||
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);
|
||||
}
|
||||
}, raceCts.Token);
|
||||
@@ -243,7 +243,7 @@ public class RoundRobinFallbackHelper
|
||||
|
||||
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
|
||||
return result;
|
||||
}
|
||||
@@ -291,7 +291,7 @@ public class RoundRobinFallbackHelper
|
||||
}
|
||||
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);
|
||||
|
||||
// Mark as unhealthy in cache
|
||||
|
||||
@@ -111,7 +111,7 @@ public class DeezerDownloadService : BaseDownloadService
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine("downloads", "cache")
|
||||
: 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
|
||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||
|
||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services.Common;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -15,12 +16,17 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SubsonicSettings _settings;
|
||||
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||
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();
|
||||
_settings = settings.Value;
|
||||
_genreEnrichment = genreEnrichment;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -384,17 +407,23 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
}
|
||||
}
|
||||
|
||||
// Contributors
|
||||
// Contributors (all artists including features)
|
||||
var contributors = new List<string>();
|
||||
var contributorIds = new List<string>();
|
||||
if (track.TryGetProperty("contributors", out var contribs))
|
||||
{
|
||||
foreach (var contrib in contribs.EnumerateArray())
|
||||
{
|
||||
if (contrib.TryGetProperty("name", out var contribName))
|
||||
if (contrib.TryGetProperty("name", out var contribName) &&
|
||||
contrib.TryGetProperty("id", out var contribId))
|
||||
{
|
||||
var name = contribName.GetString();
|
||||
var id = contribId.GetInt64();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
contributors.Add(name);
|
||||
contributorIds.Add($"ext-deezer-artist-{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,6 +466,8 @@ public class DeezerMetadataService : IMusicMetadataService
|
||||
ArtistId = track.TryGetProperty("artist", out var artistForId)
|
||||
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
|
||||
: null,
|
||||
Artists = contributors.Count > 0 ? contributors : new List<string>(),
|
||||
ArtistIds = contributorIds.Count > 0 ? contributorIds : new List<string>(),
|
||||
Album = track.TryGetProperty("album", out var album)
|
||||
? album.GetProperty("title").GetString() ?? ""
|
||||
: "",
|
||||
|
||||
@@ -76,7 +76,7 @@ public class JellyfinModelMapper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error parsing Jellyfin items response");
|
||||
_logger.LogError(ex, "Error parsing Jellyfin items response");
|
||||
}
|
||||
|
||||
return (songs, albums, artists);
|
||||
@@ -126,7 +126,7 @@ public class JellyfinModelMapper
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -222,7 +222,7 @@ public class JellyfinProxyService
|
||||
// Forward as X-Emby-Authorization (Jellyfin's expected header)
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
authHeaderAdded = true;
|
||||
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
|
||||
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -265,11 +265,11 @@ public class JellyfinProxyService
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
_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
|
||||
@@ -374,7 +374,7 @@ public class JellyfinProxyService
|
||||
{
|
||||
// Forward as X-Emby-Authorization
|
||||
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
|
||||
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
|
||||
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -418,11 +418,11 @@ public class JellyfinProxyService
|
||||
// 401 is expected when tokens expire - don't spam logs
|
||||
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
|
||||
{
|
||||
_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);
|
||||
}
|
||||
|
||||
@@ -579,12 +579,12 @@ public class JellyfinProxyService
|
||||
|
||||
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"));
|
||||
|
||||
_logger.LogInformation("DELETE to Jellyfin: {Url}", url);
|
||||
_logger.LogDebug("DELETE to Jellyfin: {Url}", url);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
@@ -593,7 +593,7 @@ public class JellyfinProxyService
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
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);
|
||||
return (null, statusCode);
|
||||
}
|
||||
@@ -629,7 +629,7 @@ public class JellyfinProxyService
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -662,7 +662,7 @@ public class JellyfinProxyService
|
||||
if (!string.IsNullOrEmpty(_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)
|
||||
@@ -932,7 +932,7 @@ public class JellyfinProxyService
|
||||
if (result.Success && result.Body != null)
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -263,9 +263,11 @@ public class JellyfinResponseBuilder
|
||||
["Name"] = songTitle,
|
||||
["ServerId"] = "allstarr",
|
||||
["Id"] = song.Id,
|
||||
["PlaylistItemId"] = song.Id, // Required for playlist items
|
||||
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
||||
["Container"] = "flac",
|
||||
["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,
|
||||
["ProductionYear"] = song.Year,
|
||||
["IndexNumber"] = song.Track,
|
||||
@@ -273,6 +275,7 @@ public class JellyfinResponseBuilder
|
||||
["IsFolder"] = false,
|
||||
["Type"] = "Audio",
|
||||
["ChannelId"] = (object?)null,
|
||||
["ParentId"] = song.AlbumId,
|
||||
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
||||
? new[] { song.Genre }
|
||||
: new string[0],
|
||||
@@ -286,6 +289,9 @@ public class JellyfinResponseBuilder
|
||||
}
|
||||
}
|
||||
: new Dictionary<string, object?>[0],
|
||||
["Tags"] = new string[0],
|
||||
["People"] = new object[0],
|
||||
["SortName"] = songTitle,
|
||||
["ParentLogoItemId"] = song.AlbumId,
|
||||
["ParentBackdropItemId"] = song.AlbumId,
|
||||
["ParentBackdropImageTags"] = new string[0],
|
||||
@@ -299,13 +305,11 @@ public class JellyfinResponseBuilder
|
||||
["ItemId"] = song.Id
|
||||
},
|
||||
["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?>
|
||||
{
|
||||
["Name"] = name,
|
||||
["Id"] = index == 0 && song.ArtistId != null
|
||||
? song.ArtistId
|
||||
: $"{song.Id}-artist-{index}"
|
||||
["Id"] = song.ArtistIds[index]
|
||||
}).ToArray()
|
||||
: new[]
|
||||
{
|
||||
|
||||
@@ -33,7 +33,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
// Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity)
|
||||
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
|
||||
|
||||
_logger.LogDebug("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
|
||||
_logger.LogInformation("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -44,7 +44,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
{
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogWarning("Cannot create session - no device ID");
|
||||
_logger.LogError("Cannot create session - no device ID");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
||||
{
|
||||
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
|
||||
// If this returns false (401), the token expired and client needs to re-auth
|
||||
@@ -60,7 +60,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (!success)
|
||||
{
|
||||
// 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);
|
||||
return false;
|
||||
}
|
||||
@@ -78,13 +78,17 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (!success)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
||||
_logger.LogInformation("Session created for {DeviceId}", deviceId);
|
||||
|
||||
// Track this session
|
||||
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
||||
?? headers["X-Real-IP"].FirstOrDefault()
|
||||
?? "Unknown";
|
||||
|
||||
_sessions[deviceId] = new SessionInfo
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
@@ -92,7 +96,8 @@ public class JellyfinSessionManager : IDisposable
|
||||
Device = device,
|
||||
Version = version,
|
||||
LastActivity = DateTime.UtcNow,
|
||||
Headers = CloneHeaders(headers)
|
||||
Headers = CloneHeaders(headers),
|
||||
ClientIp = clientIp
|
||||
};
|
||||
|
||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
||||
@@ -138,7 +143,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
else if (statusCode == 401)
|
||||
{
|
||||
// Token expired - this is expected, client needs to re-authenticate
|
||||
_logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate");
|
||||
_logger.LogWarning("Capabilities returned 401 (token expired) - client should re-authenticate");
|
||||
return false;
|
||||
}
|
||||
else
|
||||
@@ -160,7 +165,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
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,
|
||||
Device = s.Device,
|
||||
Version = s.Version,
|
||||
ClientIp = s.ClientIp,
|
||||
LastActivity = s.LastActivity,
|
||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||
HasWebSocket = s.WebSocket != null,
|
||||
@@ -256,7 +262,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
||||
_logger.LogError(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -276,7 +282,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
};
|
||||
var stopJson = JsonSerializer.Serialize(stopPayload);
|
||||
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
||||
_logger.LogDebug("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
||||
}
|
||||
|
||||
@@ -285,7 +291,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
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))
|
||||
{
|
||||
_logger.LogDebug("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
|
||||
_logger.LogError("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -360,7 +366,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
if (!string.IsNullOrEmpty(_settings.ApiKey))
|
||||
{
|
||||
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
|
||||
_logger.LogDebug("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
|
||||
_logger.LogWarning("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -375,7 +381,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
|
||||
// Connect to Jellyfin
|
||||
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
|
||||
// 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 messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
|
||||
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
_logger.LogDebug("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
|
||||
_logger.LogInformation("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
|
||||
|
||||
// Also send SessionsStart to subscribe to session updates
|
||||
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
|
||||
@@ -516,20 +522,20 @@ public class JellyfinSessionManager : IDisposable
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -565,6 +571,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
public ClientWebSocket? WebSocket { get; set; }
|
||||
public string? LastPlayingItemId { get; set; }
|
||||
public long? LastPlayingPositionTicks { get; set; }
|
||||
public string? ClientIp { get; set; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -213,7 +213,7 @@ public class LocalLibraryService : ILocalLibraryService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public class LrclibService
|
||||
ILogger<LrclibService> logger)
|
||||
{
|
||||
_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;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -75,7 +75,7 @@ public class LrclibService
|
||||
}
|
||||
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)}&" +
|
||||
$"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);
|
||||
|
||||
@@ -157,12 +157,12 @@ public class LrclibService
|
||||
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;
|
||||
}
|
||||
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
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
@@ -214,7 +214,7 @@ public class LrclibService
|
||||
}
|
||||
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;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -350,7 +350,7 @@ public class LrclibService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -368,7 +368,7 @@ public class LrclibService
|
||||
}
|
||||
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
|
||||
};
|
||||
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
|
||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL);
|
||||
|
||||
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
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Running initial lyrics prefetch on startup");
|
||||
_logger.LogDebug("Running initial lyrics prefetch on startup");
|
||||
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -115,7 +115,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
string playlistName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Prefetching lyrics for playlist: {Playlist}", playlistName);
|
||||
_logger.LogDebug("Prefetching lyrics for playlist: {Playlist}", playlistName);
|
||||
|
||||
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
if (!string.IsNullOrEmpty(existingLyrics))
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
if (hasLocalLyrics)
|
||||
{
|
||||
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);
|
||||
|
||||
// Remove any previously cached LRCLib lyrics for this track
|
||||
@@ -239,12 +239,12 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
|
||||
_logger.LogDebug("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
|
||||
playlistName, fetched, cached, missing);
|
||||
|
||||
return (fetched, cached, missing);
|
||||
@@ -264,7 +264,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
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))
|
||||
{
|
||||
_logger.LogInformation("Lyrics cache directory does not exist, skipping cache warming");
|
||||
_logger.LogWarning("Lyrics cache directory does not exist, skipping cache warming");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔥 Warming lyrics cache from {Count} files...", files.Length);
|
||||
_logger.LogDebug("🔥 Warming lyrics cache from {Count} files...", files.Length);
|
||||
|
||||
var loaded = 0;
|
||||
foreach (var file in files)
|
||||
@@ -301,17 +301,17 @@ public class LyricsPrefetchService : BackgroundService
|
||||
if (lyrics != null)
|
||||
{
|
||||
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++;
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
@@ -351,7 +351,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
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)
|
||||
{
|
||||
_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);
|
||||
return spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||
}
|
||||
@@ -384,7 +384,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -423,7 +423,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -528,7 +528,7 @@ public class LyricsPrefetchService : BackgroundService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,16 +167,14 @@ public class LyricsStartupValidator : BaseStartupValidator
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(_spotifySettings.ClientId))
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
WriteStatus("Spotify API", "NOT CONFIGURED", ConsoleColor.Yellow);
|
||||
WriteDetail("Set SpotifyApi__ClientId to enable");
|
||||
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Gray);
|
||||
return true;
|
||||
}
|
||||
|
||||
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, not lyrics");
|
||||
WriteDetail("Note: Spotify API is used for track matching and lyrics");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -50,13 +50,13 @@ public class SpotifyLyricsService
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_settings.LyricsApiUrl))
|
||||
{
|
||||
_logger.LogWarning("Spotify lyrics API URL not configured");
|
||||
_logger.LogInformation("Spotify lyrics API URL not configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ public class SpotifyLyricsService
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ public class SpotifyLyricsService
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -110,14 +110,14 @@ public class SpotifyLyricsService
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
// The sidecar API only supports track ID, not search
|
||||
// So we skip Spotify lyrics for search-based requests
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ public class SpotifyLyricsService
|
||||
// Check for error
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ public class MusicBrainzService
|
||||
ILogger<MusicBrainzService> logger)
|
||||
{
|
||||
_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"));
|
||||
|
||||
_settings = settings.Value;
|
||||
@@ -92,6 +92,7 @@ public class MusicBrainzService
|
||||
|
||||
/// <summary>
|
||||
/// Searches for recordings by title and artist.
|
||||
/// Note: Search API doesn't return genres, only MBIDs. Use LookupByMbidAsync to get genres.
|
||||
/// </summary>
|
||||
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
|
||||
{
|
||||
@@ -107,7 +108,8 @@ public class MusicBrainzService
|
||||
// Build Lucene query
|
||||
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
|
||||
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);
|
||||
|
||||
@@ -128,7 +130,7 @@ public class MusicBrainzService
|
||||
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);
|
||||
|
||||
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>
|
||||
/// 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>
|
||||
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
|
||||
{
|
||||
@@ -153,17 +202,23 @@ public class MusicBrainzService
|
||||
|
||||
MusicBrainzRecording? recording = null;
|
||||
|
||||
// Try ISRC lookup first (most accurate)
|
||||
// Try ISRC lookup first (most accurate and includes genres)
|
||||
if (!string.IsNullOrEmpty(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)
|
||||
{
|
||||
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)
|
||||
@@ -186,7 +241,7 @@ public class MusicBrainzService
|
||||
.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));
|
||||
|
||||
return genres;
|
||||
|
||||
@@ -92,18 +92,18 @@ public class QobuzBundleService
|
||||
|
||||
// Step 1: Get the bundle URL from login page
|
||||
var bundleUrl = await GetBundleUrlAsync();
|
||||
_logger.LogInformation("Found bundle URL: {BundleUrl}", bundleUrl);
|
||||
_logger.LogDebug("Found bundle URL: {BundleUrl}", bundleUrl);
|
||||
|
||||
// Step 2: Download the bundle JavaScript
|
||||
var bundleJs = await DownloadBundleAsync(bundleUrl);
|
||||
|
||||
// Step 3: Extract App ID
|
||||
_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)
|
||||
_cachedSecrets = ExtractSecrets(bundleJs);
|
||||
_logger.LogInformation("Extracted {Count} secrets", _cachedSecrets.Count);
|
||||
_logger.LogDebug("Extracted {Count} secrets", _cachedSecrets.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -253,7 +253,7 @@ public class QobuzBundleService
|
||||
}
|
||||
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
|
||||
? Path.Combine(DownloadPath, "cache")
|
||||
: 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)!;
|
||||
EnsureDirectoryExists(albumFolder);
|
||||
|
||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
||||
using allstarr.Models.Download;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Services.Common;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -18,6 +19,7 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
private readonly SubsonicSettings _settings;
|
||||
private readonly QobuzBundleService _bundleService;
|
||||
private readonly ILogger<QobuzMetadataService> _logger;
|
||||
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||
private readonly string? _userAuthToken;
|
||||
private readonly string? _userId;
|
||||
|
||||
@@ -28,12 +30,14 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
IOptions<SubsonicSettings> settings,
|
||||
IOptions<QobuzSettings> qobuzSettings,
|
||||
QobuzBundleService bundleService,
|
||||
ILogger<QobuzMetadataService> logger)
|
||||
ILogger<QobuzMetadataService> logger,
|
||||
GenreEnrichmentService? genreEnrichment = null)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_settings = settings.Value;
|
||||
_bundleService = bundleService;
|
||||
_logger = logger;
|
||||
_genreEnrichment = genreEnrichment;
|
||||
|
||||
var qobuzConfig = qobuzSettings.Value;
|
||||
_userAuthToken = qobuzConfig.UserAuthToken;
|
||||
@@ -177,7 +181,26 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -98,7 +98,7 @@ public class SpotifyApiClient : IDisposable
|
||||
{
|
||||
if (string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
_logger.LogWarning("No Spotify session cookie configured");
|
||||
_logger.LogInformation("No Spotify session cookie configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -349,6 +349,17 @@ public class SpotifyApiClient : IDisposable
|
||||
|
||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||
|
||||
// Handle 429 rate limiting with exponential backoff
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds);
|
||||
await Task.Delay(retryAfter, cancellationToken);
|
||||
|
||||
// Retry the request
|
||||
response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
||||
@@ -519,7 +530,7 @@ public class SpotifyApiClient : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse GraphQL track");
|
||||
_logger.LogError(ex, "Failed to parse GraphQL track");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -735,6 +746,18 @@ public class SpotifyApiClient : IDisposable
|
||||
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
||||
string searchName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await GetUserPlaylistsAsync(searchName, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all playlists from the user's library, optionally filtered by name.
|
||||
/// Uses GraphQL API which is less rate-limited than REST API.
|
||||
/// </summary>
|
||||
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
|
||||
public async Task<List<SpotifyPlaylist>> GetUserPlaylistsAsync(
|
||||
string? searchName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
@@ -744,61 +767,204 @@ public class SpotifyApiClient : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
// Use GraphQL endpoint instead of REST API to avoid rate limiting
|
||||
// GraphQL is less aggressive with rate limits
|
||||
var playlists = new List<SpotifyPlaylist>();
|
||||
var offset = 0;
|
||||
const int limit = 50;
|
||||
|
||||
while (true)
|
||||
{
|
||||
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);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) break;
|
||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||
|
||||
// Handle 429 rate limiting with exponential backoff
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds);
|
||||
await Task.Delay(retryAfter, cancellationToken);
|
||||
|
||||
// Retry the request
|
||||
response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
|
||||
break;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("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;
|
||||
}
|
||||
|
||||
// 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())
|
||||
{
|
||||
var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
itemCount++;
|
||||
|
||||
// Check if name matches (case-insensitive)
|
||||
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
if (!item.TryGetProperty("item", out var playlistItem) ||
|
||||
!playlistItem.TryGetProperty("data", out var playlist))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check __typename to filter out folders and only include playlists
|
||||
if (playlistItem.TryGetProperty("__typename", out var typename))
|
||||
{
|
||||
var typeStr = typename.GetString();
|
||||
// Skip folders - only process Playlist types
|
||||
if (typeStr != null && typeStr.Contains("Folder", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Get playlist URI/ID
|
||||
string? uri = null;
|
||||
if (playlistItem.TryGetProperty("uri", out var uriProp))
|
||||
{
|
||||
uri = uriProp.GetString();
|
||||
}
|
||||
else if (playlistItem.TryGetProperty("_uri", out var uriProp2))
|
||||
{
|
||||
uri = uriProp2.GetString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(uri)) continue;
|
||||
|
||||
// Skip if not a playlist URI (e.g., folders have different URI format)
|
||||
if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
|
||||
// Check if name matches (case-insensitive) - if searchName is provided
|
||||
if (!string.IsNullOrEmpty(searchName) &&
|
||||
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get track count if available - try multiple possible paths
|
||||
var trackCount = 0;
|
||||
if (playlist.TryGetProperty("content", out var content))
|
||||
{
|
||||
if (content.TryGetProperty("totalCount", out var totalTrackCount))
|
||||
{
|
||||
trackCount = totalTrackCount.GetInt32();
|
||||
}
|
||||
}
|
||||
// Fallback: try attributes.itemCount
|
||||
else if (playlist.TryGetProperty("attributes", out var attributes) &&
|
||||
attributes.TryGetProperty("itemCount", out var itemCountProp))
|
||||
{
|
||||
trackCount = itemCountProp.GetInt32();
|
||||
}
|
||||
// Fallback: try totalCount directly
|
||||
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
|
||||
{
|
||||
trackCount = directTotalCount.GetInt32();
|
||||
}
|
||||
|
||||
// Log if we couldn't find track count for debugging
|
||||
if (trackCount == 0)
|
||||
{
|
||||
_logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}",
|
||||
itemName, spotifyId, playlist.GetRawText());
|
||||
}
|
||||
|
||||
// Get owner name
|
||||
string? ownerName = null;
|
||||
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
||||
ownerV2.TryGetProperty("data", out var ownerData) &&
|
||||
ownerData.TryGetProperty("username", out var ownerNameProp))
|
||||
{
|
||||
ownerName = ownerNameProp.GetString();
|
||||
}
|
||||
|
||||
// Get image URL
|
||||
string? imageUrl = null;
|
||||
if (playlist.TryGetProperty("images", out var images) &&
|
||||
images.TryGetProperty("items", out var imageItems) &&
|
||||
imageItems.GetArrayLength() > 0)
|
||||
{
|
||||
var firstImage = imageItems[0];
|
||||
if (firstImage.TryGetProperty("sources", out var sources) &&
|
||||
sources.GetArrayLength() > 0)
|
||||
{
|
||||
var firstSource = sources[0];
|
||||
if (firstSource.TryGetProperty("url", out var urlProp))
|
||||
{
|
||||
imageUrl = urlProp.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playlists.Add(new SpotifyPlaylist
|
||||
{
|
||||
SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "",
|
||||
SpotifyId = spotifyId,
|
||||
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
|
||||
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;
|
||||
|
||||
if (_settings.RateLimitDelayMs > 0)
|
||||
{
|
||||
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
||||
}
|
||||
// Add delay between pages to avoid rate limiting
|
||||
// Library fetching can be aggressive, so use a longer delay
|
||||
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
|
||||
_logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs);
|
||||
await Task.Delay(delayMs, cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Found {Count} playlists{Filter} via GraphQL",
|
||||
playlists.Count,
|
||||
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
||||
return playlists;
|
||||
}
|
||||
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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
if (_spotifyApiSettings.Value.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.Value.SessionCookie))
|
||||
{
|
||||
_logger.LogInformation("SpotifyApi is enabled with session cookie - using direct Spotify API instead of Jellyfin scraping");
|
||||
_logger.LogInformation("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists.");
|
||||
_logger.LogDebug("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists.");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
@@ -77,7 +77,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
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("========================================");
|
||||
return;
|
||||
}
|
||||
@@ -115,7 +115,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Skipping startup fetch - already have cached files");
|
||||
_logger.LogWarning("Skipping startup fetch - already have cached files");
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
if (File.Exists(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
|
||||
if (!await _cache.ExistsAsync(cacheKey))
|
||||
@@ -207,7 +207,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
// Check Redis cache
|
||||
if (await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
_logger.LogInformation(" {Playlist}: Found in Redis cache", playlistName);
|
||||
_logger.LogDebug(" {Playlist}: Found in Redis cache", playlistName);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
if (allPlaylistsHaveCache)
|
||||
{
|
||||
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
|
||||
_logger.LogWarning("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -250,13 +250,13 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
// No expiration - cache persists until next Jellyfin job generates new file
|
||||
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);
|
||||
}
|
||||
}
|
||||
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 json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true });
|
||||
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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -279,7 +279,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_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
|
||||
DateTime? firstFoundTime = null;
|
||||
@@ -324,11 +324,11 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
|
||||
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
|
||||
{
|
||||
_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;
|
||||
@@ -428,7 +428,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
// Keep the existing cache - don't let it expire
|
||||
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
|
||||
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)
|
||||
{
|
||||
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)
|
||||
@@ -476,7 +476,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
try
|
||||
{
|
||||
// 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);
|
||||
if (response.IsSuccessStatusCode)
|
||||
@@ -502,7 +502,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
|
||||
_logger.LogError(ex, "Failed to fetch {Filename}", filename);
|
||||
}
|
||||
|
||||
return (false, null);
|
||||
|
||||
@@ -3,6 +3,7 @@ using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Cronos;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
@@ -14,6 +15,9 @@ namespace allstarr.Services.Spotify;
|
||||
/// - ISRC codes available for exact matching
|
||||
/// - Real-time data without waiting for plugin sync schedules
|
||||
/// - Full track metadata (duration, release date, etc.)
|
||||
///
|
||||
/// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval.
|
||||
/// Cache persists until next cron run to prevent excess Spotify API calls.
|
||||
/// </summary>
|
||||
public class SpotifyPlaylistFetcher : BackgroundService
|
||||
{
|
||||
@@ -23,7 +27,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
private readonly SpotifyApiClient _spotifyClient;
|
||||
private readonly RedisCacheService _cache;
|
||||
|
||||
private const string CacheDirectory = "/app/cache/spotify";
|
||||
private const string CacheKeyPrefix = "spotify:playlist:";
|
||||
|
||||
// Track Spotify playlist IDs after discovery
|
||||
@@ -45,6 +48,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Spotify playlist tracks in order, using cache if available.
|
||||
/// Cache persists until next cron run to prevent excess API calls.
|
||||
/// </summary>
|
||||
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
||||
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
||||
@@ -57,7 +61,38 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
if (cached != null && cached.Tracks.Count > 0)
|
||||
{
|
||||
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)",
|
||||
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
||||
@@ -65,47 +100,23 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
// Try file cache
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var filePlaylist = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
|
||||
if (filePlaylist != null && filePlaylist.Tracks.Count > 0)
|
||||
{
|
||||
var age = DateTime.UtcNow - filePlaylist.FetchedAt;
|
||||
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
||||
{
|
||||
_logger.LogDebug("Using file-cached playlist '{Name}' ({Count} tracks)",
|
||||
playlistName, filePlaylist.Tracks.Count);
|
||||
return filePlaylist.Tracks;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read file cache for '{Name}'", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Need to fetch fresh - try to use cached or configured Spotify playlist ID
|
||||
// Cache miss or expired - need to fetch fresh from Spotify
|
||||
// Try to use cached or configured Spotify playlist ID
|
||||
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
||||
{
|
||||
// Check if we have a configured Spotify ID for this playlist
|
||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
|
||||
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
if (config != null && !string.IsNullOrEmpty(config.Id))
|
||||
{
|
||||
// Use the configured Spotify playlist ID directly
|
||||
spotifyId = playlistConfig.Id;
|
||||
spotifyId = config.Id;
|
||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No configured ID, try searching by name (works for public/followed playlists)
|
||||
_logger.LogDebug("No configured Spotify ID for '{Name}', searching...", playlistName);
|
||||
_logger.LogInformation("No configured Spotify ID for '{Name}', searching...", playlistName);
|
||||
var playlists = await _spotifyClient.SearchUserPlaylistsAsync(playlistName);
|
||||
|
||||
var exactMatch = playlists.FirstOrDefault(p =>
|
||||
@@ -113,21 +124,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
if (exactMatch == null)
|
||||
{
|
||||
_logger.LogWarning("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
|
||||
|
||||
// Return file cache even if expired, as a fallback
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var fallback = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
|
||||
if (fallback != null)
|
||||
{
|
||||
_logger.LogWarning("Using expired file cache as fallback for '{Name}'", playlistName);
|
||||
return fallback.Tracks;
|
||||
}
|
||||
}
|
||||
|
||||
return new List<SpotifyPlaylistTrack>();
|
||||
_logger.LogInformation("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
|
||||
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
||||
}
|
||||
|
||||
spotifyId = exactMatch.SpotifyId;
|
||||
@@ -140,16 +138,42 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId);
|
||||
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>();
|
||||
}
|
||||
|
||||
// Update cache
|
||||
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
|
||||
await SaveToFileCacheAsync(playlistName, playlist);
|
||||
// Calculate cache expiration based on cron schedule
|
||||
var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
|
||||
|
||||
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
|
||||
playlistName, playlist.Tracks.Count);
|
||||
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(playlistCfg.SyncSchedule);
|
||||
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
|
||||
// Add 5 minutes buffer
|
||||
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
|
||||
|
||||
_logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)",
|
||||
playlistName, nextRun.Value, timeUntilNextRun.TotalHours);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.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;
|
||||
}
|
||||
@@ -206,9 +230,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("SpotifyPlaylistFetcher: Starting up...");
|
||||
|
||||
// Ensure cache directory exists
|
||||
Directory.CreateDirectory(CacheDirectory);
|
||||
|
||||
if (!_spotifyApiSettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify API integration is DISABLED");
|
||||
@@ -218,13 +239,13 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
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("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
@@ -235,32 +256,99 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
_logger.LogInformation("Spotify API ENABLED");
|
||||
_logger.LogInformation("Authenticated via sp_dc session cookie");
|
||||
_logger.LogInformation("Cache duration: {Minutes} minutes", _spotifyApiSettings.CacheDurationMinutes);
|
||||
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
||||
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
||||
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
_logger.LogInformation(" - {Name}", playlist.Name);
|
||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
||||
}
|
||||
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Initial fetch of all playlists
|
||||
await FetchAllPlaylistsAsync(stoppingToken);
|
||||
|
||||
// Periodic refresh loop
|
||||
// Cron-based refresh loop - only fetch when cron schedule triggers
|
||||
// This prevents excess Spotify API calls
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
|
||||
try
|
||||
{
|
||||
// Check each playlist to see if it needs refreshing based on cron schedule
|
||||
var now = DateTime.UtcNow;
|
||||
var needsRefresh = new List<string>();
|
||||
|
||||
foreach (var config in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * 1" : config.SyncSchedule;
|
||||
|
||||
try
|
||||
{
|
||||
await FetchAllPlaylistsAsync(stoppingToken);
|
||||
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, "Error during periodic playlist refresh");
|
||||
_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)
|
||||
{
|
||||
_logger.LogError(ex, "Error in playlist fetcher loop");
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,7 +364,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
try
|
||||
{
|
||||
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
|
||||
if (tracks.Count > 0)
|
||||
@@ -301,36 +389,11 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
// Wait 3 seconds between each playlist to avoid 429 TooManyRequests errors
|
||||
if (config != _spotifyImportSettings.Playlists.Last())
|
||||
{
|
||||
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
|
||||
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", config.Name);
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED FETCHING SPOTIFY PLAYLISTS ===");
|
||||
}
|
||||
|
||||
private string GetCacheFilePath(string playlistName)
|
||||
{
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
return Path.Combine(CacheDirectory, $"{safeName}_spotify.json");
|
||||
}
|
||||
|
||||
private async Task SaveToFileCacheAsync(string playlistName, SpotifyPlaylist playlist)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var json = JsonSerializer.Serialize(playlist, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
_logger.LogDebug("Saved playlist '{Name}' to file cache", playlistName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save file cache for '{Name}'", playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using allstarr.Services.Jellyfin;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using Cronos;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
@@ -17,6 +18,9 @@ namespace allstarr.Services.Spotify;
|
||||
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
||||
///
|
||||
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
||||
///
|
||||
/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers.
|
||||
/// Manual refresh is always allowed. Cache persists until next cron run.
|
||||
/// </summary>
|
||||
public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
@@ -27,8 +31,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
||||
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
||||
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(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
@@ -57,17 +63,29 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||
? "ISRC-preferred" : "fuzzy";
|
||||
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
||||
_logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule");
|
||||
|
||||
// Log all playlist schedules
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
||||
}
|
||||
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Wait a bit for the fetcher to run first
|
||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||
@@ -75,7 +93,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Run once on startup to match any existing missing tracks
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Running initial track matching on startup");
|
||||
_logger.LogInformation("Running initial track matching on startup (one-time)");
|
||||
await MatchAllPlaylistsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -83,52 +101,106 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
_logger.LogError(ex, "Error during startup track matching");
|
||||
}
|
||||
|
||||
// Now start the periodic matching loop
|
||||
// Now start the cron-based scheduling loop
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Wait for configured interval before next run (default 24 hours)
|
||||
var intervalHours = _spotifySettings.MatchingIntervalHours;
|
||||
if (intervalHours <= 0)
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Periodic matching disabled (MatchingIntervalHours = {Hours}), only startup run will execute", intervalHours);
|
||||
break; // Exit loop - only run once on startup
|
||||
}
|
||||
// Calculate next run time for each playlist
|
||||
var now = DateTime.UtcNow;
|
||||
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
|
||||
|
||||
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||
|
||||
try
|
||||
{
|
||||
await MatchAllPlaylistsAsync(stoppingToken);
|
||||
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, "Error in track matching service");
|
||||
_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)
|
||||
{
|
||||
_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).
|
||||
/// Matches tracks for a single playlist (called by cron scheduler or manual trigger).
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingAsync()
|
||||
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for all playlists");
|
||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger matching for a specific playlist (called from controller).
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
|
||||
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogWarning("Playlist {Playlist} not found in configuration", playlistName);
|
||||
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,63 +214,6 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (playlistFetcher != null)
|
||||
{
|
||||
// Use new direct API mode with ISRC support
|
||||
await MatchPlaylistTracksWithIsrcAsync(
|
||||
playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to legacy mode
|
||||
await MatchPlaylistTracksLegacyAsync(
|
||||
playlist.Name, metadataService, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Check if we've run too recently (cooldown period)
|
||||
var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
|
||||
if (timeSinceLastRun < _minimumMatchingInterval)
|
||||
{
|
||||
_logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)",
|
||||
(int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
||||
_lastMatchingRun = DateTime.UtcNow;
|
||||
|
||||
var playlists = _spotifySettings.Playlists;
|
||||
if (playlists.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No playlists configured for matching");
|
||||
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)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
if (playlistFetcher != null)
|
||||
@@ -217,10 +232,70 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
||||
/// <summary>
|
||||
/// Public method to trigger matching manually for all playlists (called from controller).
|
||||
/// This bypasses cron schedules and runs immediately.
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingAsync()
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
|
||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger matching for a specific playlist (called from controller).
|
||||
/// This bypasses cron schedules and runs immediately.
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (bypassing cron schedule)", playlistName);
|
||||
|
||||
// Check cooldown to prevent abuse
|
||||
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
||||
{
|
||||
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||
if (timeSinceLastRun < _minimumRunInterval)
|
||||
{
|
||||
_logger.LogWarning("Skipping manual refresh for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
||||
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before refreshing again");
|
||||
}
|
||||
}
|
||||
|
||||
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
|
||||
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||
|
||||
var playlists = _spotifySettings.Playlists;
|
||||
if (playlists.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No playlists configured for matching");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -241,7 +316,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -272,7 +347,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
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(
|
||||
@@ -304,7 +379,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
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)
|
||||
{
|
||||
_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);
|
||||
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);
|
||||
|
||||
// Check cache - use snapshot/timestamp to detect changes
|
||||
@@ -355,7 +430,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -413,7 +488,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
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);
|
||||
return (spotifyTrack, new List<(Song, double, string)>());
|
||||
}
|
||||
@@ -497,8 +572,37 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
if (matchedTracks.Count > 0)
|
||||
{
|
||||
// Cache matched tracks with position data
|
||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
||||
// Calculate cache expiration: until next cron run (not just cache duration from settings)
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
|
||||
|
||||
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(playlist.SyncSchedule);
|
||||
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
|
||||
// Add 5 minutes buffer to ensure cache doesn't expire before next run
|
||||
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
|
||||
|
||||
_logger.LogInformation("Cache will persist until next cron run: {NextRun} UTC (in {Hours:F1} hours)",
|
||||
nextRun.Value, timeUntilNextRun.TotalHours);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.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
|
||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||
@@ -506,15 +610,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Also update legacy cache for backward compatibility
|
||||
var legacyKey = $"spotify:matched:{playlistName}";
|
||||
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
||||
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
|
||||
|
||||
_logger.LogInformation(
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - cache expires in {Hours:F1}h",
|
||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours);
|
||||
|
||||
// Pre-build playlist items cache for instant serving
|
||||
// This is what makes the UI show all matched tracks at once
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -706,7 +810,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
|
||||
if (existingMatched != null && existingMatched.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
||||
_logger.LogWarning("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
||||
playlistName, existingMatched.Count);
|
||||
return;
|
||||
}
|
||||
@@ -715,11 +819,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No missing tracks found for {Playlist}, skipping matching", playlistName);
|
||||
_logger.LogWarning("No missing tracks found for {Playlist}, skipping matching", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {Count} tracks for {Playlist} (with rate limiting)",
|
||||
_logger.LogWarning("Matching {Count} tracks for {Playlist} (with rate limiting)",
|
||||
missingTracks.Count, playlistName);
|
||||
|
||||
var matchedSongs = new List<Song>();
|
||||
@@ -774,15 +878,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedSongs.Count > 0)
|
||||
{
|
||||
// Cache matched tracks for 1 hour
|
||||
await _cache.SetAsync(matchedTracksKey, matchedSongs, TimeSpan.FromHours(1));
|
||||
// Cache matched tracks for configurable duration
|
||||
await _cache.SetAsync(matchedTracksKey, matchedSongs, CacheExtensions.SpotifyMatchedTracksTTL);
|
||||
_logger.LogInformation("✓ Cached {Matched}/{Total} matched tracks for {Playlist}",
|
||||
matchedSongs.Count, missingTracks.Count, playlistName);
|
||||
}
|
||||
@@ -843,21 +947,23 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// <summary>
|
||||
/// Pre-builds the playlist items cache for instant serving.
|
||||
/// 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>
|
||||
private async Task PreBuildPlaylistItemsCacheAsync(
|
||||
string playlistName,
|
||||
string? jellyfinPlaylistId,
|
||||
List<SpotifyPlaylistTrack> spotifyTracks,
|
||||
List<MatchedTrack> matchedTracks,
|
||||
List<MatchedTrack> externalMatchedTracks,
|
||||
TimeSpan cacheExpiration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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))
|
||||
{
|
||||
_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;
|
||||
}
|
||||
|
||||
@@ -876,7 +982,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var userId = jellyfinSettings.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;
|
||||
}
|
||||
|
||||
@@ -887,12 +993,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -923,8 +1030,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
// 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 usedJellyfinItems = new HashSet<string>();
|
||||
var matchedSpotifyIds = new HashSet<string>(); // Track which Spotify tracks got local matches
|
||||
var localUsedCount = 0;
|
||||
var externalUsedCount = 0;
|
||||
var manualExternalCount = 0;
|
||||
@@ -962,19 +1071,42 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||
if (itemDict != null)
|
||||
{
|
||||
// Add Spotify ID to ProviderIds so lyrics can work for local tracks too
|
||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
// Add Jellyfin ID to ProviderIds for easy identification
|
||||
if (itemDict.TryGetValue("Id", out var jellyfinIdObj) && jellyfinIdObj != null)
|
||||
{
|
||||
var jellyfinId = jellyfinIdObj.ToString();
|
||||
if (!string.IsNullOrEmpty(jellyfinId))
|
||||
{
|
||||
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
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (itemDict["ProviderIds"] is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||
_logger.LogDebug("Added Spotify ID {SpotifyId} to local track for lyrics support", spotifyTrack.SpotifyId);
|
||||
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);
|
||||
}
|
||||
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as locally matched
|
||||
localUsedCount++;
|
||||
}
|
||||
continue; // Skip to next track
|
||||
@@ -1031,7 +1164,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
|
||||
|
||||
@@ -1088,6 +1212,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
finalItems.Add(externalItem);
|
||||
externalUsedCount++;
|
||||
manualExternalCount++;
|
||||
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as matched (external)
|
||||
|
||||
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
|
||||
spotifyTrack.Title, provider, externalId);
|
||||
@@ -1096,11 +1221,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
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;
|
||||
|
||||
foreach (var kvp in jellyfinItemsByName)
|
||||
@@ -1140,19 +1265,42 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
|
||||
if (itemDict != null)
|
||||
{
|
||||
// Add Spotify ID to ProviderIds so lyrics can work for fuzzy-matched local tracks too
|
||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
// Add Jellyfin ID to ProviderIds for easy identification
|
||||
if (itemDict.TryGetValue("Id", out var jellyfinIdObj) && jellyfinIdObj != null)
|
||||
{
|
||||
var jellyfinId = jellyfinIdObj.ToString();
|
||||
if (!string.IsNullOrEmpty(jellyfinId))
|
||||
{
|
||||
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
|
||||
Dictionary<string, string>? providerIds = null;
|
||||
|
||||
if (itemDict["ProviderIds"] is Dictionary<string, string> dict)
|
||||
{
|
||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||
_logger.LogDebug("Added Spotify ID {SpotifyId} to fuzzy-matched local track for lyrics support", spotifyTrack.SpotifyId);
|
||||
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);
|
||||
}
|
||||
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as locally matched
|
||||
localUsedCount++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No local match - try to find external track
|
||||
var matched = matchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||
// FOURTH: No local match - try to find external track (ONLY for unmatched tracks)
|
||||
var matched = externalMatchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||
if (matched != null && matched.MatchedSong != null)
|
||||
{
|
||||
// Convert external song to Jellyfin item format
|
||||
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
|
||||
|
||||
// Add Spotify ID to ProviderIds so lyrics can work
|
||||
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
|
||||
{
|
||||
if (!externalItem.ContainsKey("ProviderIds"))
|
||||
{
|
||||
externalItem["ProviderIds"] = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
|
||||
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
|
||||
{
|
||||
providerIds["Spotify"] = spotifyTrack.SpotifyId;
|
||||
}
|
||||
}
|
||||
|
||||
finalItems.Add(externalItem);
|
||||
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as matched (external)
|
||||
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)
|
||||
{
|
||||
// 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}";
|
||||
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
||||
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
||||
|
||||
// Save to file cache for persistence
|
||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||
@@ -1209,9 +1421,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
manualMappingInfo = $" [Manual external: {manualExternalCount}]";
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
|
||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
|
||||
_logger.LogDebug("✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
|
||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1264,7 +1475,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
|
||||
_logger.LogInformation("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -129,7 +129,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||
? Path.Combine("downloads", "cache")
|
||||
: 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
|
||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||
|
||||
@@ -56,6 +56,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||
|
||||
public SquidWTFMetadataService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
@@ -63,13 +64,15 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
IOptions<SquidWTFSettings> squidwtfSettings,
|
||||
ILogger<SquidWTFMetadataService> logger,
|
||||
RedisCacheService cache,
|
||||
List<string> apiUrls)
|
||||
List<string> apiUrls,
|
||||
GenreEnrichmentService? genreEnrichment = null)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||
_genreEnrichment = genreEnrichment;
|
||||
|
||||
// Set up default headers
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||
@@ -83,19 +86,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||
{
|
||||
// Race all endpoints for fastest search results
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
// Use round-robin to distribute load across endpoints (allows parallel processing of multiple tracks)
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Use 's' parameter for track search as per hifi-api spec
|
||||
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
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
|
||||
var result = JsonDocument.Parse(json);
|
||||
@@ -129,19 +132,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
||||
{
|
||||
// Race all endpoints for fastest search results
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
||||
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
||||
var response = await _httpClient.GetAsync(url, ct);
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
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 albums = new List<Album>();
|
||||
@@ -166,14 +169,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
||||
{
|
||||
// Race all endpoints for fastest search results
|
||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
// Per hifi-api spec: use 'a' parameter for artist search
|
||||
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)
|
||||
{
|
||||
@@ -181,7 +184,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
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 artists = new List<Artist>();
|
||||
@@ -237,7 +240,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -286,6 +289,23 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
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)
|
||||
// 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
|
||||
await _cache.SetAsync(cacheKey, album, TimeSpan.FromHours(24));
|
||||
// Cache for configurable duration
|
||||
await _cache.SetAsync(cacheKey, album, CacheExtensions.MetadataTTL);
|
||||
|
||||
return album;
|
||||
}, (Album?)null);
|
||||
@@ -347,14 +367,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
{
|
||||
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
|
||||
var cacheKey = $"squidwtf:artist:{externalId}";
|
||||
var cached = await _cache.GetAsync<Artist>(cacheKey);
|
||||
if (cached != null)
|
||||
{
|
||||
_logger.LogInformation("Returning cached artist {ArtistName}", cached.Name);
|
||||
_logger.LogDebug("Returning cached artist {ArtistName}", cached.Name);
|
||||
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
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -388,7 +408,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
if (albumItems[0].TryGetProperty("artist", out var 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());
|
||||
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
|
||||
await _cache.SetAsync(cacheKey, artist, TimeSpan.FromHours(24));
|
||||
// Cache for configurable duration
|
||||
await _cache.SetAsync(cacheKey, artist, CacheExtensions.MetadataTTL);
|
||||
|
||||
return artist;
|
||||
}, (Artist?)null);
|
||||
@@ -438,16 +458,16 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
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
|
||||
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);
|
||||
|
||||
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>();
|
||||
}
|
||||
|
||||
@@ -468,7 +488,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
parsedAlbum.Title, parsedAlbum.Artist, parsedAlbum.ArtistId);
|
||||
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
|
||||
{
|
||||
@@ -595,6 +615,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
|
||||
var allArtists = new List<string>();
|
||||
var allArtistIds = new List<string>();
|
||||
string artistName = "";
|
||||
string? artistId = null;
|
||||
|
||||
@@ -604,9 +625,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
foreach (var artistEl in artists.EnumerateArray())
|
||||
{
|
||||
var name = artistEl.GetProperty("name").GetString();
|
||||
var id = artistEl.GetProperty("id").GetInt64();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
allArtists.Add(name);
|
||||
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,7 +637,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
if (allArtists.Count > 0)
|
||||
{
|
||||
artistName = allArtists[0];
|
||||
artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}";
|
||||
artistId = allArtistIds[0];
|
||||
}
|
||||
}
|
||||
// Fallback to singular "artist" field
|
||||
@@ -623,6 +646,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
||||
allArtists.Add(artistName);
|
||||
allArtistIds.Add(artistId);
|
||||
}
|
||||
|
||||
// Get album info
|
||||
@@ -649,6 +673,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
Artist = artistName,
|
||||
ArtistId = artistId,
|
||||
Artists = allArtists,
|
||||
ArtistIds = allArtistIds,
|
||||
Album = albumTitle,
|
||||
AlbumId = albumId,
|
||||
Duration = track.TryGetProperty("duration", out var duration)
|
||||
@@ -711,6 +736,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
// Get all artists - prefer "artists" array for collaborations
|
||||
var allArtists = new List<string>();
|
||||
var allArtistIds = new List<string>();
|
||||
string artistName = "";
|
||||
long artistIdNum = 0;
|
||||
|
||||
@@ -719,9 +745,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
foreach (var artistEl in artists.EnumerateArray())
|
||||
{
|
||||
var name = artistEl.GetProperty("name").GetString();
|
||||
var id = artistEl.GetProperty("id").GetInt64();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
allArtists.Add(name);
|
||||
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,6 +764,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||
artistIdNum = artist.GetProperty("id").GetInt64();
|
||||
allArtists.Add(artistName);
|
||||
allArtistIds.Add($"ext-squidwtf-artist-{artistIdNum}");
|
||||
}
|
||||
|
||||
// Album artist - same as main artist for Tidal tracks
|
||||
@@ -771,6 +800,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
Artist = artistName,
|
||||
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
|
||||
Artists = allArtists,
|
||||
ArtistIds = allArtistIds,
|
||||
Album = albumTitle,
|
||||
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
|
||||
AlbumArtist = albumArtist,
|
||||
|
||||
@@ -73,7 +73,11 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
{
|
||||
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;
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -187,7 +187,7 @@ public class PlaylistSyncService
|
||||
}
|
||||
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());
|
||||
_logger.LogInformation("Created M3U playlist: {Path}", playlistPath);
|
||||
_logger.LogDebug("Created M3U playlist: {Path}", playlistPath);
|
||||
}
|
||||
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)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ public class PlaylistSyncService
|
||||
|
||||
// Write the M3U file (overwrites existing)
|
||||
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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -382,7 +382,7 @@ public class PlaylistSyncService
|
||||
|
||||
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)
|
||||
@@ -392,7 +392,7 @@ public class PlaylistSyncService
|
||||
}
|
||||
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)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error parsing Subsonic search response");
|
||||
_logger.LogError(ex, "Error parsing Subsonic search response");
|
||||
}
|
||||
|
||||
return (songs, albums, artists);
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>allstarr</RootNamespace>
|
||||
<Version>1.0.0</Version>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<Version>1.0.1</Version>
|
||||
<AssemblyVersion>1.0.1.0</AssemblyVersion>
|
||||
<FileVersion>1.0.1.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<PackageReference Include="Cronos" Version="0.11.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||
|
||||
@@ -32,8 +32,7 @@
|
||||
"EnableExternalPlaylists": true
|
||||
},
|
||||
"Library": {
|
||||
"DownloadPath": "./downloads",
|
||||
"KeptPath": "/app/kept"
|
||||
"DownloadPath": "./downloads"
|
||||
},
|
||||
"Qobuz": {
|
||||
"UserAuthToken": "your-qobuz-token",
|
||||
@@ -52,6 +51,17 @@
|
||||
"Enabled": true,
|
||||
"ConnectionString": "localhost:6379"
|
||||
},
|
||||
"Cache": {
|
||||
"SearchResultsMinutes": 120,
|
||||
"PlaylistImagesHours": 168,
|
||||
"SpotifyPlaylistItemsHours": 168,
|
||||
"SpotifyMatchedTracksDays": 30,
|
||||
"LyricsDays": 14,
|
||||
"GenreDays": 30,
|
||||
"MetadataDays": 7,
|
||||
"OdesliLookupDays": 60,
|
||||
"ProxyImagesDays": 14
|
||||
},
|
||||
"SpotifyImport": {
|
||||
"Enabled": false,
|
||||
"SyncStartHour": 16,
|
||||
@@ -62,8 +72,6 @@
|
||||
},
|
||||
"SpotifyApi": {
|
||||
"Enabled": false,
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"SessionCookie": "",
|
||||
"CacheDurationMinutes": 60,
|
||||
"RateLimitDelayMs": 100,
|
||||
|
||||
+591
-52
@@ -75,6 +75,7 @@
|
||||
.status-badge.success { background: rgba(63, 185, 80, 0.2); color: var(--success); }
|
||||
.status-badge.warning { background: rgba(210, 153, 34, 0.2); color: var(--warning); }
|
||||
.status-badge.error { background: rgba(248, 81, 73, 0.2); color: var(--error); }
|
||||
.status-badge.info { background: rgba(59, 130, 246, 0.2); color: #3b82f6; }
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
@@ -537,7 +538,7 @@
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Active Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
@@ -652,21 +653,22 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>
|
||||
Active Spotify Playlists
|
||||
Injected Spotify Playlists
|
||||
<div class="actions">
|
||||
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
|
||||
<button onclick="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="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>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
These are the Spotify playlists currently being monitored and filled with tracks from your music service.
|
||||
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music service.
|
||||
</p>
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Spotify ID</th>
|
||||
<th>Sync Schedule</th>
|
||||
<th>Tracks</th>
|
||||
<th>Completion</th>
|
||||
<th>Cache Age</th>
|
||||
@@ -675,7 +677,7 @@
|
||||
</thead>
|
||||
<tbody id="playlist-table-body">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">
|
||||
<td colspan="7" class="loading">
|
||||
<span class="spinner"></span> Loading playlists...
|
||||
</td>
|
||||
</tr>
|
||||
@@ -806,8 +808,62 @@
|
||||
|
||||
<!-- Configuration Tab -->
|
||||
<div class="tab-content" id="tab-config">
|
||||
<div class="card">
|
||||
<h2>Core Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Backend Type <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-backend-type">-</span>
|
||||
<button onclick="openEditSetting('BACKEND_TYPE', 'Backend Type', 'select', 'Choose your media server backend', ['Jellyfin', 'Subsonic'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Music Service <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-music-service">-</span>
|
||||
<button onclick="openEditSetting('MUSIC_SERVICE', 'Music Service', 'select', 'Choose your music download provider', ['SquidWTF', 'Deezer', 'Qobuz'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Storage Mode</span>
|
||||
<span class="value" id="config-storage-mode">-</span>
|
||||
<button onclick="openEditSetting('STORAGE_MODE', 'Storage Mode', 'select', 'Permanent keeps files forever, Cache auto-deletes after duration', ['Permanent', 'Cache'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item" id="cache-duration-row" style="display: none;">
|
||||
<span class="label">Cache Duration (hours)</span>
|
||||
<span class="value" id="config-cache-duration-hours">-</span>
|
||||
<button onclick="openEditSetting('CACHE_DURATION_HOURS', 'Cache Duration (hours)', 'number', 'How long to keep cached files before deletion')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Download Mode</span>
|
||||
<span class="value" id="config-download-mode">-</span>
|
||||
<button onclick="openEditSetting('DOWNLOAD_MODE', 'Download Mode', 'select', 'Download individual tracks or full albums', ['Track', 'Album'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Explicit Filter</span>
|
||||
<span class="value" id="config-explicit-filter">-</span>
|
||||
<button onclick="openEditSetting('EXPLICIT_FILTER', 'Explicit Filter', 'select', 'Filter explicit content', ['All', 'Explicit', 'Clean'])">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Enable External Playlists</span>
|
||||
<span class="value" id="config-enable-external-playlists">-</span>
|
||||
<button onclick="openEditSetting('ENABLE_EXTERNAL_PLAYLISTS', 'Enable External Playlists', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Playlists Directory</span>
|
||||
<span class="value" id="config-playlists-directory">-</span>
|
||||
<button onclick="openEditSetting('PLAYLISTS_DIRECTORY', 'Playlists Directory', 'text', 'Directory path for external playlists')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Redis Enabled</span>
|
||||
<span class="value" id="config-redis-enabled">-</span>
|
||||
<button onclick="openEditSetting('REDIS_ENABLED', 'Redis Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Spotify API Settings</h2>
|
||||
<div style="background: rgba(248, 81, 73, 0.15); border: 1px solid var(--error); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
|
||||
⚠️ For active playlists and link functionality to work, sp_dc session cookie must be set!
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">API Enabled</span>
|
||||
@@ -815,7 +871,7 @@
|
||||
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Session Cookie (sp_dc)</span>
|
||||
<span class="label">Session Cookie (sp_dc) <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-spotify-cookie">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
|
||||
</div>
|
||||
@@ -858,7 +914,7 @@
|
||||
<div class="config-item">
|
||||
<span class="label">Quality</span>
|
||||
<span class="value" id="config-squid-quality">-</span>
|
||||
<button onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', '', ['LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
|
||||
<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>
|
||||
@@ -904,17 +960,17 @@
|
||||
<h2>Jellyfin Settings</h2>
|
||||
<div class="config-section">
|
||||
<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>
|
||||
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
|
||||
</div>
|
||||
<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>
|
||||
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">User ID</span>
|
||||
<span class="label">User ID <span style="color: var(--error);">*</span></span>
|
||||
<span class="value" id="config-jellyfin-user-id">-</span>
|
||||
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
|
||||
</div>
|
||||
@@ -943,17 +999,71 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sync Schedule</h2>
|
||||
<h2>Spotify Import Settings</h2>
|
||||
<div class="config-section">
|
||||
<div class="config-item">
|
||||
<span class="label">Sync Start Time</span>
|
||||
<span class="value" id="config-sync-time">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_START_HOUR', 'Sync Start Hour (0-23)', 'number')">Edit</button>
|
||||
<span class="label">Spotify Import Enabled</span>
|
||||
<span class="value" id="config-spotify-import-enabled">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_ENABLED', 'Spotify Import Enabled', 'toggle')">Edit</button>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<span class="label">Sync Window</span>
|
||||
<span class="value" id="config-sync-window">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_WINDOW_HOURS', 'Sync Window (hours)', 'number')">Edit</button>
|
||||
<span class="label">Matching Interval (hours)</span>
|
||||
<span class="value" id="config-matching-interval">-</span>
|
||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS', 'Matching Interval (hours)', 'number', 'How often to check for playlist updates')">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>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>
|
||||
@@ -1119,7 +1229,7 @@
|
||||
<div class="modal-content" style="max-width: 600px;">
|
||||
<h3>Map Track to External Provider</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Jellyfin mapping modal instead.
|
||||
</p>
|
||||
|
||||
<!-- Track Info -->
|
||||
@@ -1161,25 +1271,94 @@
|
||||
</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 -->
|
||||
<div class="modal" id="link-playlist-modal">
|
||||
<div class="modal-content">
|
||||
<h3>Link to Spotify Playlist</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Enter the Spotify playlist ID or URL. Allstarr will automatically download missing tracks from your configured music service.
|
||||
Select a playlist from your Spotify library or enter a playlist ID/URL manually. Allstarr will automatically download missing tracks from your configured music service.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label>Jellyfin Playlist</label>
|
||||
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
|
||||
<input type="hidden" id="link-jellyfin-id">
|
||||
</div>
|
||||
<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>
|
||||
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Sync Schedule -->
|
||||
<div class="form-group">
|
||||
<label>Sync Schedule (Cron)</label>
|
||||
<input type="text" id="link-sync-schedule" placeholder="0 8 * * 1" value="0 8 * * 1" style="font-family: monospace;">
|
||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||
Cron format: <code>minute hour day month dayofweek</code><br>
|
||||
Default: <code>0 8 * * 1</code> = 8 AM every Monday<br>
|
||||
Examples: <code>0 6 * * *</code> = daily at 6 AM, <code>0 20 * * 5</code> = Fridays at 8 PM<br>
|
||||
<a href="https://crontab.guru/" target="_blank" style="color: var(--primary);">Use crontab.guru to build your schedule</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
||||
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
|
||||
@@ -1460,7 +1639,7 @@
|
||||
|
||||
if (data.playlists.length === 0) {
|
||||
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;
|
||||
}
|
||||
@@ -1491,13 +1670,13 @@
|
||||
// Show breakdown with color coding
|
||||
let breakdownParts = [];
|
||||
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) {
|
||||
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} matched</span>`);
|
||||
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} External</span>`);
|
||||
}
|
||||
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
|
||||
@@ -1514,25 +1693,31 @@
|
||||
// Debug logging
|
||||
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
|
||||
|
||||
const syncSchedule = p.syncSchedule || '0 8 * * 1';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
|
||||
<td style="font-family:monospace;font-size:0.85rem;">
|
||||
${escapeHtml(syncSchedule)}
|
||||
<button onclick="editPlaylistSchedule('${escapeJs(p.name)}', '${escapeJs(syncSchedule)}')" style="margin-left:4px;font-size:0.75rem;padding:2px 6px;">Edit</button>
|
||||
</td>
|
||||
<td>${statsHtml}${breakdown}</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;">
|
||||
<div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div>
|
||||
<div style="width:${externalPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMatched} external matched tracks"></div>
|
||||
<div style="width:${missingPct}%;height:100%;background:#6b7280;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
|
||||
<div style="width:${externalPct}%;height:100%;background:#3b82f6;transition:width 0.3s;" title="${externalMatched} external tracks"></div>
|
||||
<div style="width:${missingPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
|
||||
</div>
|
||||
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||
<td>
|
||||
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')" title="Re-match when local library changed (uses cached Spotify data)">Re-match Local</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 class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
||||
</td>
|
||||
@@ -1776,6 +1961,23 @@
|
||||
const res = await fetch('/api/admin/config');
|
||||
const data = await res.json();
|
||||
|
||||
// Core settings
|
||||
document.getElementById('config-backend-type').textContent = data.backendType || 'Jellyfin';
|
||||
document.getElementById('config-music-service').textContent = data.musicService || 'SquidWTF';
|
||||
document.getElementById('config-storage-mode').textContent = data.library?.storageMode || 'Cache';
|
||||
document.getElementById('config-cache-duration-hours').textContent = data.library?.cacheDurationHours || '24';
|
||||
document.getElementById('config-download-mode').textContent = data.library?.downloadMode || 'Track';
|
||||
document.getElementById('config-explicit-filter').textContent = data.explicitFilter || 'All';
|
||||
document.getElementById('config-enable-external-playlists').textContent = data.enableExternalPlaylists ? 'Yes' : 'No';
|
||||
document.getElementById('config-playlists-directory').textContent = data.playlistsDirectory || '(not set)';
|
||||
document.getElementById('config-redis-enabled').textContent = data.redisEnabled ? 'Yes' : 'No';
|
||||
|
||||
// Show/hide cache duration based on storage mode
|
||||
const cacheDurationRow = document.getElementById('cache-duration-row');
|
||||
if (cacheDurationRow) {
|
||||
cacheDurationRow.style.display = data.library?.storageMode === 'Cache' ? 'grid' : 'none';
|
||||
}
|
||||
|
||||
// Spotify API settings
|
||||
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
||||
@@ -1817,10 +2019,21 @@
|
||||
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
|
||||
|
||||
// Sync settings
|
||||
const syncHour = data.spotifyImport.syncStartHour;
|
||||
const syncMin = data.spotifyImport.syncStartMinute;
|
||||
document.getElementById('config-sync-time').textContent = `${String(syncHour).padStart(2, '0')}:${String(syncMin).padStart(2, '0')}`;
|
||||
document.getElementById('config-sync-window').textContent = data.spotifyImport.syncWindowHours + ' hours';
|
||||
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
|
||||
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' 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) {
|
||||
console.error('Failed to fetch config:', error);
|
||||
}
|
||||
@@ -1896,22 +2109,137 @@
|
||||
}
|
||||
}
|
||||
|
||||
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-name').value = name;
|
||||
document.getElementById('link-spotify-id').value = '';
|
||||
|
||||
// Reset to select mode
|
||||
switchLinkMode('select');
|
||||
|
||||
// Fetch user playlists if not already cached
|
||||
if (spotifyUserPlaylists.length === 0) {
|
||||
const select = document.getElementById('link-spotify-select');
|
||||
select.innerHTML = '<option value="">Loading playlists...</option>';
|
||||
|
||||
spotifyUserPlaylists = await fetchSpotifyUserPlaylists();
|
||||
|
||||
// Filter out already-linked playlists
|
||||
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
|
||||
|
||||
if (availablePlaylists.length === 0) {
|
||||
if (spotifyUserPlaylists.length > 0) {
|
||||
select.innerHTML = '<option value="">All your playlists are already linked</option>';
|
||||
} else {
|
||||
select.innerHTML = '<option value="">No playlists found or Spotify not configured</option>';
|
||||
}
|
||||
// Switch to manual mode if no available playlists
|
||||
switchLinkMode('manual');
|
||||
} else {
|
||||
// Populate dropdown with only unlinked playlists
|
||||
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
|
||||
availablePlaylists.map(p =>
|
||||
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
|
||||
).join('');
|
||||
}
|
||||
} else {
|
||||
// Re-filter in case playlists were linked since last fetch
|
||||
const select = document.getElementById('link-spotify-select');
|
||||
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
|
||||
|
||||
if (availablePlaylists.length === 0) {
|
||||
select.innerHTML = '<option value="">All your playlists are already linked</option>';
|
||||
switchLinkMode('manual');
|
||||
} else {
|
||||
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
|
||||
availablePlaylists.map(p =>
|
||||
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
|
||||
openModal('link-playlist-modal');
|
||||
}
|
||||
|
||||
async function linkPlaylist() {
|
||||
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
||||
const name = document.getElementById('link-jellyfin-name').value;
|
||||
const spotifyId = document.getElementById('link-spotify-id').value.trim();
|
||||
const syncSchedule = document.getElementById('link-sync-schedule').value.trim();
|
||||
|
||||
// Validate sync schedule (basic cron format check)
|
||||
if (!syncSchedule) {
|
||||
showToast('Sync schedule is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const cronParts = syncSchedule.split(/\s+/);
|
||||
if (cronParts.length !== 5) {
|
||||
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get Spotify ID based on current mode
|
||||
let spotifyId = '';
|
||||
if (currentLinkMode === 'select') {
|
||||
spotifyId = document.getElementById('link-spotify-select').value;
|
||||
if (!spotifyId) {
|
||||
showToast('Please select a Spotify playlist', 'error');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
spotifyId = document.getElementById('link-spotify-id').value.trim();
|
||||
if (!spotifyId) {
|
||||
showToast('Spotify Playlist ID is required', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract ID from various Spotify formats:
|
||||
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
||||
@@ -1935,7 +2263,11 @@
|
||||
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
spotifyPlaylistId: cleanSpotifyId,
|
||||
syncSchedule: syncSchedule
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
@@ -1945,6 +2277,9 @@
|
||||
showRestartBanner();
|
||||
closeModal('link-playlist-modal');
|
||||
|
||||
// Clear the Spotify playlists cache so it refreshes next time
|
||||
spotifyUserPlaylists = [];
|
||||
|
||||
// Update UI state without refetching all playlists
|
||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||
if (playlistsTable) {
|
||||
@@ -1982,6 +2317,9 @@
|
||||
showToast('Playlist unlinked.', 'success');
|
||||
showRestartBanner();
|
||||
|
||||
// Clear the Spotify playlists cache so it refreshes next time
|
||||
spotifyUserPlaylists = [];
|
||||
|
||||
// Update UI state without refetching all playlists
|
||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||
if (playlistsTable) {
|
||||
@@ -2020,18 +2358,18 @@
|
||||
}
|
||||
|
||||
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 {
|
||||
// Show warning banner
|
||||
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 data = await res.json();
|
||||
|
||||
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
|
||||
setTimeout(() => {
|
||||
fetchPlaylists();
|
||||
@@ -2039,7 +2377,7 @@
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 3000);
|
||||
} 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';
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2053,7 +2391,7 @@
|
||||
// Show warning banner
|
||||
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 data = await res.json();
|
||||
|
||||
@@ -2066,17 +2404,17 @@
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 2000);
|
||||
} 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';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to match tracks', 'error');
|
||||
showToast('Failed to re-match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function matchAllPlaylists() {
|
||||
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
||||
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 {
|
||||
// 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) {
|
||||
if (!confirm(`Remove playlist "${name}"?`)) return;
|
||||
|
||||
@@ -2374,8 +2745,23 @@
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('Failed to fetch tracks:', res.status, res.statusText);
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + res.status + ' ' + res.statusText + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
console.log('Tracks data received:', data);
|
||||
|
||||
if (!data || !data.tracks) {
|
||||
console.error('Invalid data structure:', data);
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Invalid data received from server</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.tracks.length === 0) {
|
||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
|
||||
return;
|
||||
@@ -2399,7 +2785,7 @@
|
||||
}
|
||||
} else if (t.isLocal === false) {
|
||||
const provider = capitalizeProvider(t.externalProvider) || 'External';
|
||||
statusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
|
||||
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
|
||||
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>';
|
||||
@@ -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>`;
|
||||
} else {
|
||||
// isLocal is null/undefined - track is missing (not found locally or externally)
|
||||
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:var(--bg-tertiary);color:var(--text-secondary);"><span class="status-dot" style="background:var(--text-secondary);"></span>Missing</span>';
|
||||
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
|
||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||
mapButton = `<button class="small map-track-btn"
|
||||
@@ -2490,7 +2876,8 @@
|
||||
});
|
||||
});
|
||||
} 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) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
@@ -2715,8 +3124,27 @@
|
||||
saveBtn.disabled = !externalId;
|
||||
}
|
||||
|
||||
// Open manual mapping modal (external only)
|
||||
// Open local Jellyfin mapping modal
|
||||
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-position').textContent = position + 1;
|
||||
document.getElementById('map-spotify-title').textContent = title;
|
||||
@@ -2731,12 +3159,123 @@
|
||||
openModal('manual-map-modal');
|
||||
}
|
||||
|
||||
// Alias for backward compatibility
|
||||
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||
// Search Jellyfin tracks for local mapping
|
||||
async function searchJellyfinTracks() {
|
||||
const query = document.getElementById('local-map-search').value.trim();
|
||||
if (!query) {
|
||||
showToast('Please enter a search query', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save manual mapping (external only)
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const playlistName = document.getElementById('map-playlist-name').value;
|
||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||
|
||||
+14
-2
@@ -17,8 +17,11 @@ services:
|
||||
networks:
|
||||
- allstarr-network
|
||||
|
||||
# Spotify Lyrics API sidecar service
|
||||
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
|
||||
spotify-lyrics:
|
||||
image: akashrchandran/spotify-lyrics-api:latest
|
||||
platform: linux/amd64
|
||||
container_name: allstarr-spotify-lyrics
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -70,6 +73,17 @@ services:
|
||||
- Redis__ConnectionString=redis:6379
|
||||
- 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__Url=${SUBSONIC_URL:-http://localhost:4533}
|
||||
- Subsonic__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly}
|
||||
@@ -104,8 +118,6 @@ services:
|
||||
|
||||
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
|
||||
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
|
||||
- SpotifyApi__ClientId=${SPOTIFY_API_CLIENT_ID:-}
|
||||
- SpotifyApi__ClientSecret=${SPOTIFY_API_CLIENT_SECRET:-}
|
||||
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
|
||||
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
|
||||
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
||||
|
||||
Reference in New Issue
Block a user