mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-11 00:18:38 -05:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
87467be61b
|
|||
|
713ecd4ec8
|
|||
|
0ff1e3a428
|
|||
|
cef18b9482
|
|||
|
1bfe30b216
|
|||
|
c9c82a650d
|
|||
|
d0a7dbcc96
|
|||
|
9c9a827a91
|
|||
|
96889738df
|
|||
|
f3c791496e
|
@@ -40,7 +40,7 @@ MUSIC_SERVICE=SquidWTF
|
|||||||
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
# - downloads/permanent/ - Permanently downloaded tracks (STORAGE_MODE=Permanent)
|
||||||
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
# - downloads/cache/ - Temporarily cached tracks (STORAGE_MODE=Cache)
|
||||||
# - downloads/kept/ - Favorited external tracks (always permanent)
|
# - downloads/kept/ - Favorited external tracks (always permanent)
|
||||||
DOWNLOAD_PATH=./downloads
|
Library__DownloadPath=./downloads
|
||||||
|
|
||||||
# ===== SQUIDWTF CONFIGURATION =====
|
# ===== SQUIDWTF CONFIGURATION =====
|
||||||
# Different quality options for SquidWTF. Only FLAC supported right now
|
# Different quality options for SquidWTF. Only FLAC supported right now
|
||||||
@@ -143,13 +143,6 @@ SPOTIFY_IMPORT_PLAYLISTS=[]
|
|||||||
# Enable direct Spotify API access (default: false)
|
# Enable direct Spotify API access (default: false)
|
||||||
SPOTIFY_API_ENABLED=false
|
SPOTIFY_API_ENABLED=false
|
||||||
|
|
||||||
# Spotify Client ID from https://developer.spotify.com/dashboard
|
|
||||||
# Create an app in the Spotify Developer Dashboard to get this
|
|
||||||
SPOTIFY_API_CLIENT_ID=
|
|
||||||
|
|
||||||
# Spotify Client Secret (optional - only needed for certain OAuth flows)
|
|
||||||
SPOTIFY_API_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
|
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
|
||||||
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
|
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
|
||||||
# via session cookie because they're not accessible through the official API.
|
# via session cookie because they're not accessible through the official API.
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -37,6 +37,8 @@ The proxy will be available at `http://localhost:5274`.
|
|||||||
## Web Dashboard
|
## Web Dashboard
|
||||||
|
|
||||||
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
Allstarr includes a web UI for easy configuration and playlist management, accessible at `http://localhost:5275`
|
||||||
|
<img width="1664" height="1101" alt="image" src="https://github.com/user-attachments/assets/9159100b-7e11-449e-8530-517d336d6bd2" />
|
||||||
|
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
@@ -74,8 +76,6 @@ There's an environment variable to modify this.
|
|||||||
|
|
||||||
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
|
**Recommended workflow**: Use the `sp_dc` cookie method alongside the [Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import?tab=readme-ov-file).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Nginx Proxy Setup (Required)
|
### Nginx Proxy Setup (Required)
|
||||||
|
|
||||||
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
|
This service only exposes ports internally. You can use nginx to proxy to it, however PLEASE take significant precautions before exposing this! Everyone decides their own level of risk, but this is currently untested, potentially dangerous software, with almost unfettered access to your Jellyfin server. My recommendation is use Tailscale or something similar!
|
||||||
@@ -139,8 +139,14 @@ This project brings together all the music streaming providers into one unified
|
|||||||
**Compatible Jellyfin clients:**
|
**Compatible Jellyfin clients:**
|
||||||
|
|
||||||
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
|
- [Feishin](https://github.com/jeffvli/feishin) (Mac/Windows/Linux)
|
||||||
- [Musiver](https://music.aqzscn.cn/en/) (Android/IOS/Windows/Android)
|
<img width="1691" height="1128" alt="image" src="https://github.com/user-attachments/assets/c602f71c-c4dd-49a9-b533-1558e24a9f45" />
|
||||||
- [Finamp](https://github.com/jmshrv/finamp) ()
|
|
||||||
|
|
||||||
|
- [Musiver](https://music.aqzscn.cn/en/) (Android/iOS/Windows/Android)
|
||||||
|
<img width="523" height="1025" alt="image" src="https://github.com/user-attachments/assets/135e2721-5fd7-482f-bb06-b0736003cfe7" />
|
||||||
|
|
||||||
|
|
||||||
|
- [Finamp](https://github.com/jmshrv/finamp) (Android/iOS)
|
||||||
|
|
||||||
_Working on getting more currently_
|
_Working on getting more currently_
|
||||||
|
|
||||||
@@ -336,6 +342,9 @@ Subsonic__EnableExternalPlaylists=false
|
|||||||
|
|
||||||
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
|
Allstarr automatically fills your Spotify playlists (like Release Radar and Discover Weekly) with tracks from your configured streaming provider (SquidWTF, Deezer, or Qobuz). This works by intercepting playlists created by the Jellyfin Spotify Import plugin and matching missing tracks with your streaming service.
|
||||||
|
|
||||||
|
<img width="1649" height="3764" alt="image" src="https://github.com/user-attachments/assets/a4d3d79c-7741-427f-8c01-ffc90f3a579b" />
|
||||||
|
|
||||||
|
|
||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
1. **Install the Jellyfin Spotify Import Plugin**
|
1. **Install the Jellyfin Spotify Import Plugin**
|
||||||
@@ -914,4 +923,4 @@ GPL-3.0
|
|||||||
- [Deezer](https://www.deezer.com/) - Music streaming service
|
- [Deezer](https://www.deezer.com/) - Music streaming service
|
||||||
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
|
- [Qobuz](https://www.qobuz.com/) - Hi-Res music streaming service
|
||||||
- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
|
- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) - Thank them for the fact that we have access to Spotify's lyrics!
|
||||||
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of
|
- [LRCLIB](https://github.com/tranxuanthang/lrclib) - The GOATS for giving us a free api for lyrics! They power LRCGET, which I'm sure some of you have heard of
|
||||||
|
|||||||
162
allstarr.Tests/FuzzyMatcherTests.cs
Normal file
162
allstarr.Tests/FuzzyMatcherTests.cs
Normal file
@@ -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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
96
allstarr.Tests/LrclibServiceTests.cs
Normal file
96
allstarr.Tests/LrclibServiceTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
260
allstarr.Tests/RedisCacheServiceTests.cs
Normal file
260
allstarr.Tests/RedisCacheServiceTests.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
82
allstarr.Tests/SpotifyApiClientTests.cs
Normal file
82
allstarr.Tests/SpotifyApiClientTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
342
allstarr.Tests/SquidWTFMetadataServiceTests.cs
Normal file
342
allstarr.Tests/SquidWTFMetadataServiceTests.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -259,6 +259,7 @@ public class AdminController : ControllerBase
|
|||||||
["id"] = config.Id,
|
["id"] = config.Id,
|
||||||
["jellyfinId"] = config.JellyfinId,
|
["jellyfinId"] = config.JellyfinId,
|
||||||
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
["localTracksPosition"] = config.LocalTracksPosition.ToString(),
|
||||||
|
["syncSchedule"] = config.SyncSchedule ?? "0 8 * * 1",
|
||||||
["trackCount"] = 0,
|
["trackCount"] = 0,
|
||||||
["localTracks"] = 0,
|
["localTracks"] = 0,
|
||||||
["externalTracks"] = 0,
|
["externalTracks"] = 0,
|
||||||
@@ -1379,6 +1380,12 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
|
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
|
||||||
|
musicService = _configuration.GetValue<string>("MusicService") ?? "SquidWTF",
|
||||||
|
explicitFilter = _configuration.GetValue<string>("ExplicitFilter") ?? "All",
|
||||||
|
enableExternalPlaylists = _configuration.GetValue<bool>("EnableExternalPlaylists", false),
|
||||||
|
playlistsDirectory = _configuration.GetValue<string>("PlaylistsDirectory") ?? "(not set)",
|
||||||
|
redisEnabled = _configuration.GetValue<bool>("Redis:Enabled", false),
|
||||||
spotifyApi = new
|
spotifyApi = new
|
||||||
{
|
{
|
||||||
enabled = _spotifyApiSettings.Enabled,
|
enabled = _spotifyApiSettings.Enabled,
|
||||||
@@ -1521,6 +1528,12 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
||||||
|
|
||||||
|
// Invalidate playlist summary cache if playlists were updated
|
||||||
|
if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS"))
|
||||||
|
{
|
||||||
|
InvalidatePlaylistSummaryCache();
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
message = "Configuration updated. Restart container to apply changes.",
|
message = "Configuration updated. Restart container to apply changes.",
|
||||||
@@ -1919,6 +1932,53 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all playlists from the user's Spotify account
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("spotify/user-playlists")]
|
||||||
|
public async Task<IActionResult> GetSpotifyUserPlaylists()
|
||||||
|
{
|
||||||
|
if (!_spotifyApiSettings.Enabled || string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Spotify API not configured. Please set sp_dc session cookie." });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get list of already-configured Spotify playlist IDs
|
||||||
|
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
||||||
|
var linkedSpotifyIds = new HashSet<string>(
|
||||||
|
configuredPlaylists.Select(p => p.Id),
|
||||||
|
StringComparer.OrdinalIgnoreCase
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
|
||||||
|
var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
|
||||||
|
|
||||||
|
if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
|
||||||
|
{
|
||||||
|
return Ok(new { playlists = new List<object>() });
|
||||||
|
}
|
||||||
|
|
||||||
|
var playlists = spotifyPlaylists.Select(p => new
|
||||||
|
{
|
||||||
|
id = p.SpotifyId,
|
||||||
|
name = p.Name,
|
||||||
|
trackCount = p.TotalTracks,
|
||||||
|
owner = p.OwnerName ?? "",
|
||||||
|
isPublic = p.Public,
|
||||||
|
isLinked = linkedSpotifyIds.Contains(p.SpotifyId)
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return Ok(new { playlists });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching Spotify user playlists");
|
||||||
|
return StatusCode(500, new { error = "Failed to fetch Spotify playlists", details = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get all playlists from Jellyfin
|
/// Get all playlists from Jellyfin
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1990,11 +2050,16 @@ public class AdminController : ControllerBase
|
|||||||
trackStats = await GetPlaylistTrackStats(id!);
|
trackStats = await GetPlaylistTrackStats(id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use actual track stats for configured playlists, otherwise use Jellyfin's count
|
||||||
|
var actualTrackCount = isConfigured
|
||||||
|
? trackStats.LocalTracks + trackStats.ExternalTracks
|
||||||
|
: childCount;
|
||||||
|
|
||||||
playlists.Add(new
|
playlists.Add(new
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
trackCount = childCount,
|
trackCount = actualTrackCount,
|
||||||
linkedSpotifyId,
|
linkedSpotifyId,
|
||||||
isConfigured,
|
isConfigured,
|
||||||
localTracks = trackStats.LocalTracks,
|
localTracks = trackStats.LocalTracks,
|
||||||
@@ -2163,12 +2228,19 @@ public class AdminController : ControllerBase
|
|||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
Id = request.SpotifyPlaylistId,
|
Id = request.SpotifyPlaylistId,
|
||||||
JellyfinId = jellyfinPlaylistId,
|
JellyfinId = jellyfinPlaylistId,
|
||||||
LocalTracksPosition = LocalTracksPosition.First // Use Spotify order
|
LocalTracksPosition = LocalTracksPosition.First, // Use Spotify order
|
||||||
|
SyncSchedule = request.SyncSchedule ?? "0 8 * * 1" // Default to Monday 8 AM
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
|
||||||
var playlistsJson = JsonSerializer.Serialize(
|
var playlistsJson = JsonSerializer.Serialize(
|
||||||
currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray()
|
currentPlaylists.Select(p => new[] {
|
||||||
|
p.Name,
|
||||||
|
p.Id,
|
||||||
|
p.JellyfinId,
|
||||||
|
p.LocalTracksPosition.ToString().ToLower(),
|
||||||
|
p.SyncSchedule ?? "0 8 * * 1"
|
||||||
|
}).ToArray()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update .env file
|
// Update .env file
|
||||||
@@ -2193,6 +2265,60 @@ public class AdminController : ControllerBase
|
|||||||
return await RemovePlaylist(decodedName);
|
return await RemovePlaylist(decodedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Update playlist sync schedule
|
||||||
|
/// </summary>
|
||||||
|
[HttpPut("playlists/{name}/schedule")]
|
||||||
|
public async Task<IActionResult> UpdatePlaylistSchedule(string name, [FromBody] UpdateScheduleRequest request)
|
||||||
|
{
|
||||||
|
var decodedName = Uri.UnescapeDataString(name);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.SyncSchedule))
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "SyncSchedule is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic cron validation
|
||||||
|
var cronParts = request.SyncSchedule.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (cronParts.Length != 5)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = "Invalid cron format. Expected: minute hour day month dayofweek" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read current playlists
|
||||||
|
var currentPlaylists = await ReadPlaylistsFromEnvFile();
|
||||||
|
var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (playlist == null)
|
||||||
|
{
|
||||||
|
return NotFound(new { error = $"Playlist '{decodedName}' not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the schedule
|
||||||
|
playlist.SyncSchedule = request.SyncSchedule.Trim();
|
||||||
|
|
||||||
|
// Save back to .env
|
||||||
|
var playlistsJson = JsonSerializer.Serialize(
|
||||||
|
currentPlaylists.Select(p => new[] {
|
||||||
|
p.Name,
|
||||||
|
p.Id,
|
||||||
|
p.JellyfinId,
|
||||||
|
p.LocalTracksPosition.ToString().ToLower(),
|
||||||
|
p.SyncSchedule ?? "0 8 * * 1"
|
||||||
|
}).ToArray()
|
||||||
|
);
|
||||||
|
|
||||||
|
var updateRequest = new ConfigUpdateRequest
|
||||||
|
{
|
||||||
|
Updates = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return await UpdateConfig(updateRequest);
|
||||||
|
}
|
||||||
|
|
||||||
private string GetJellyfinAuthHeader()
|
private string GetJellyfinAuthHeader()
|
||||||
{
|
{
|
||||||
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
|
||||||
@@ -2224,7 +2350,7 @@ public class AdminController : ControllerBase
|
|||||||
return playlists;
|
return playlists;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last"],...]
|
// Parse JSON array format: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
|
||||||
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
|
var playlistArrays = JsonSerializer.Deserialize<string[][]>(value);
|
||||||
if (playlistArrays != null)
|
if (playlistArrays != null)
|
||||||
{
|
{
|
||||||
@@ -2240,7 +2366,8 @@ public class AdminController : ControllerBase
|
|||||||
LocalTracksPosition = arr.Length >= 4 &&
|
LocalTracksPosition = arr.Length >= 4 &&
|
||||||
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase)
|
||||||
? LocalTracksPosition.Last
|
? LocalTracksPosition.Last
|
||||||
: LocalTracksPosition.First
|
: LocalTracksPosition.First,
|
||||||
|
SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * 1"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3295,6 +3422,12 @@ public class LinkPlaylistRequest
|
|||||||
{
|
{
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
||||||
|
public string SyncSchedule { get; set; } = "0 8 * * 1"; // Default: 8 AM every Monday
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateScheduleRequest
|
||||||
|
{
|
||||||
|
public string SyncSchedule { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly PlaylistSyncService? _playlistSyncService;
|
private readonly PlaylistSyncService? _playlistSyncService;
|
||||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||||
|
private readonly LyricsPlusService? _lyricsPlusService;
|
||||||
private readonly LrclibService? _lrclibService;
|
private readonly LrclibService? _lrclibService;
|
||||||
|
private readonly LyricsOrchestrator? _lyricsOrchestrator;
|
||||||
private readonly OdesliService _odesliService;
|
private readonly OdesliService _odesliService;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
@@ -64,7 +66,9 @@ public class JellyfinController : ControllerBase
|
|||||||
PlaylistSyncService? playlistSyncService = null,
|
PlaylistSyncService? playlistSyncService = null,
|
||||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||||
SpotifyLyricsService? spotifyLyricsService = null,
|
SpotifyLyricsService? spotifyLyricsService = null,
|
||||||
LrclibService? lrclibService = null)
|
LyricsPlusService? lyricsPlusService = null,
|
||||||
|
LrclibService? lrclibService = null,
|
||||||
|
LyricsOrchestrator? lyricsOrchestrator = null)
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_spotifySettings = spotifySettings.Value;
|
_spotifySettings = spotifySettings.Value;
|
||||||
@@ -80,7 +84,9 @@ public class JellyfinController : ControllerBase
|
|||||||
_playlistSyncService = playlistSyncService;
|
_playlistSyncService = playlistSyncService;
|
||||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||||
_spotifyLyricsService = spotifyLyricsService;
|
_spotifyLyricsService = spotifyLyricsService;
|
||||||
|
_lyricsPlusService = lyricsPlusService;
|
||||||
_lrclibService = lrclibService;
|
_lrclibService = lrclibService;
|
||||||
|
_lyricsOrchestrator = lyricsOrchestrator;
|
||||||
_odesliService = odesliService;
|
_odesliService = odesliService;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
@@ -279,53 +285,50 @@ public class JellyfinController : ControllerBase
|
|||||||
// Parse Jellyfin results into domain models
|
// Parse Jellyfin results into domain models
|
||||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||||
|
|
||||||
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
|
// Sort all results by match score (local tracks get +10 boost)
|
||||||
// Just interleave local and external results based on which source has better overall match
|
// This ensures best matches appear first regardless of source
|
||||||
|
var allSongs = localSongs.Concat(externalResult.Songs)
|
||||||
// Calculate average match score for each source to determine which should come first
|
.Select(s => new { Song = s, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + (s.IsLocal ? 10.0 : 0.0) })
|
||||||
var localSongsAvgScore = localSongs.Any()
|
.OrderByDescending(x => x.Score)
|
||||||
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
.Select(x => x.Song)
|
||||||
: 0.0;
|
.ToList();
|
||||||
var externalSongsAvgScore = externalResult.Songs.Any()
|
|
||||||
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
var localAlbumsAvgScore = localAlbums.Any()
|
var allAlbums = localAlbums.Concat(externalResult.Albums)
|
||||||
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
.Select(a => new { Album = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + (a.IsLocal ? 10.0 : 0.0) })
|
||||||
: 0.0;
|
.OrderByDescending(x => x.Score)
|
||||||
var externalAlbumsAvgScore = externalResult.Albums.Any()
|
.Select(x => x.Album)
|
||||||
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
.ToList();
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
var localArtistsAvgScore = localArtists.Any()
|
var allArtists = localArtists.Concat(externalResult.Artists)
|
||||||
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
.Select(a => new { Artist = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + (a.IsLocal ? 10.0 : 0.0) })
|
||||||
: 0.0;
|
.OrderByDescending(x => x.Score)
|
||||||
var externalArtistsAvgScore = externalResult.Artists.Any()
|
.Select(x => x.Artist)
|
||||||
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
.ToList();
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
// Interleave results: put better-matching source first, preserve original ordering within each source
|
// Log top results for debugging
|
||||||
var allSongs = localSongsAvgScore >= externalSongsAvgScore
|
|
||||||
? localSongs.Concat(externalResult.Songs).ToList()
|
|
||||||
: externalResult.Songs.Concat(localSongs).ToList();
|
|
||||||
|
|
||||||
var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
|
|
||||||
? localAlbums.Concat(externalResult.Albums).ToList()
|
|
||||||
: externalResult.Albums.Concat(localAlbums).ToList();
|
|
||||||
|
|
||||||
var allArtists = localArtistsAvgScore >= externalArtistsAvgScore
|
|
||||||
? localArtists.Concat(externalResult.Artists).ToList()
|
|
||||||
: externalResult.Artists.Concat(localArtists).ToList();
|
|
||||||
|
|
||||||
// Log results for debugging
|
|
||||||
if (_logger.IsEnabled(LogLevel.Debug))
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
if (allSongs.Any())
|
||||||
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
|
{
|
||||||
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
var topSong = allSongs.First();
|
||||||
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
|
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topSong.Title) + (topSong.IsLocal ? 10.0 : 0.0);
|
||||||
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
_logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal}, score={Score:F2})",
|
||||||
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
|
topSong.Title, topSong.IsLocal, topScore);
|
||||||
|
}
|
||||||
|
if (allAlbums.Any())
|
||||||
|
{
|
||||||
|
var topAlbum = allAlbums.First();
|
||||||
|
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topAlbum.Title) + (topAlbum.IsLocal ? 10.0 : 0.0);
|
||||||
|
_logger.LogDebug("💿 Top album: '{Title}' (local={IsLocal}, score={Score:F2})",
|
||||||
|
topAlbum.Title, topAlbum.IsLocal, topScore);
|
||||||
|
}
|
||||||
|
if (allArtists.Any())
|
||||||
|
{
|
||||||
|
var topArtist = allArtists.First();
|
||||||
|
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topArtist.Name) + (topArtist.IsLocal ? 10.0 : 0.0);
|
||||||
|
_logger.LogDebug("🎤 Top artist: '{Name}' (local={IsLocal}, score={Score:F2})",
|
||||||
|
topArtist.Name, topArtist.IsLocal, topScore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to Jellyfin format
|
// Convert to Jellyfin format
|
||||||
@@ -343,7 +346,7 @@ public class JellyfinController : ControllerBase
|
|||||||
mergedAlbums.AddRange(playlistItems);
|
mergedAlbums.AddRange(playlistItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Merged results (preserving source order): Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
_logger.LogInformation("Merged and sorted results by score: Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||||
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
||||||
|
|
||||||
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
||||||
@@ -1274,50 +1277,53 @@ public class JellyfinController : ControllerBase
|
|||||||
searchArtists.Add(searchArtist);
|
searchArtists.Add(searchArtist);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use orchestrator for clean, modular lyrics fetching
|
||||||
LyricsInfo? lyrics = null;
|
LyricsInfo? lyrics = null;
|
||||||
|
|
||||||
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
|
if (_lyricsOrchestrator != null)
|
||||||
// Spotify lyrics only work for tracks from injected playlists that have been matched
|
|
||||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
|
||||||
{
|
{
|
||||||
// Validate that this is a real Spotify ID (not spotify:local or other invalid formats)
|
lyrics = await _lyricsOrchestrator.GetLyricsAsync(
|
||||||
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
trackName: searchTitle,
|
||||||
|
artistNames: searchArtists.ToArray(),
|
||||||
// Spotify track IDs are 22 characters, base62 encoded
|
albumName: searchAlbum,
|
||||||
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
|
durationSeconds: song.Duration ?? 0,
|
||||||
{
|
spotifyTrackId: spotifyTrackId);
|
||||||
_logger.LogInformation("Trying Spotify lyrics for track ID: {SpotifyId} ({Artist} - {Title})",
|
|
||||||
cleanSpotifyId, searchArtist, searchTitle);
|
|
||||||
|
|
||||||
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
|
||||||
|
|
||||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
|
|
||||||
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
|
||||||
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
// Fall back to LRCLIB if no Spotify lyrics
|
|
||||||
if (lyrics == null)
|
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
// Fallback to manual fetching if orchestrator not available
|
||||||
string.Join(", ", searchArtists),
|
_logger.LogWarning("LyricsOrchestrator not available, using fallback method");
|
||||||
searchTitle);
|
|
||||||
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
|
||||||
if (lrclibService != null)
|
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||||
{
|
{
|
||||||
lyrics = await lrclibService.GetLyricsAsync(
|
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
||||||
|
|
||||||
|
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
|
||||||
|
{
|
||||||
|
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
||||||
|
|
||||||
|
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||||
|
{
|
||||||
|
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to LyricsPlus
|
||||||
|
if (lyrics == null && _lyricsPlusService != null)
|
||||||
|
{
|
||||||
|
lyrics = await _lyricsPlusService.GetLyricsAsync(
|
||||||
|
searchTitle,
|
||||||
|
searchArtists.ToArray(),
|
||||||
|
searchAlbum,
|
||||||
|
song.Duration ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to LRCLIB
|
||||||
|
if (lyrics == null && _lrclibService != null)
|
||||||
|
{
|
||||||
|
lyrics = await _lrclibService.GetLyricsAsync(
|
||||||
searchTitle,
|
searchTitle,
|
||||||
searchArtists.ToArray(),
|
searchArtists.ToArray(),
|
||||||
searchAlbum,
|
searchAlbum,
|
||||||
@@ -1498,6 +1504,21 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
|
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
|
||||||
|
|
||||||
|
// Use orchestrator for prefetching
|
||||||
|
if (_lyricsOrchestrator != null)
|
||||||
|
{
|
||||||
|
await _lyricsOrchestrator.PrefetchLyricsAsync(
|
||||||
|
trackName: searchTitle,
|
||||||
|
artistNames: searchArtists.ToArray(),
|
||||||
|
albumName: searchAlbum,
|
||||||
|
durationSeconds: song.Duration ?? 0,
|
||||||
|
spotifyTrackId: spotifyTrackId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to manual prefetching if orchestrator not available
|
||||||
|
_logger.LogWarning("LyricsOrchestrator not available for prefetch, using fallback method");
|
||||||
|
|
||||||
// Try Spotify lyrics if we have a valid Spotify track ID
|
// Try Spotify lyrics if we have a valid Spotify track ID
|
||||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||||
{
|
{
|
||||||
@@ -1516,6 +1537,22 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to LyricsPlus
|
||||||
|
if (_lyricsPlusService != null)
|
||||||
|
{
|
||||||
|
var lyrics = await _lyricsPlusService.GetLyricsAsync(
|
||||||
|
searchTitle,
|
||||||
|
searchArtists.ToArray(),
|
||||||
|
searchAlbum,
|
||||||
|
song.Duration ?? 0);
|
||||||
|
|
||||||
|
if (lyrics != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ Prefetched LyricsPlus lyrics for {Artist} - {Title}", searchArtist, searchTitle);
|
||||||
|
return; // Success, lyrics are now cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fall back to LRCLIB
|
// Fall back to LRCLIB
|
||||||
if (_lrclibService != null)
|
if (_lrclibService != null)
|
||||||
{
|
{
|
||||||
@@ -3529,8 +3566,17 @@ public class JellyfinController : ControllerBase
|
|||||||
return null; // Fall back to legacy mode
|
return null; // Fall back to legacy mode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request MediaSources field to get bitrate info
|
// Pass through all requested fields from the original request
|
||||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
|
var queryString = Request.QueryString.Value ?? "";
|
||||||
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
|
||||||
|
|
||||||
|
// Append the original query string (which includes Fields parameter)
|
||||||
|
if (!string.IsNullOrEmpty(queryString))
|
||||||
|
{
|
||||||
|
// Remove the leading ? if present
|
||||||
|
queryString = queryString.TrimStart('?');
|
||||||
|
playlistItemsUrl = $"{playlistItemsUrl}&{queryString}";
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
||||||
playlistId, userId);
|
playlistId, userId);
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ public class Song
|
|||||||
/// All artists for this track (main + featured). For display in Jellyfin clients.
|
/// All artists for this track (main + featured). For display in Jellyfin clients.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string> Artists { get; set; } = new();
|
public List<string> Artists { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All artist IDs corresponding to the Artists list. Index-matched with Artists.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> ArtistIds { get; set; } = new();
|
||||||
|
|
||||||
public string Album { get; set; } = string.Empty;
|
public string Album { get; set; } = string.Empty;
|
||||||
public string? AlbumId { get; set; }
|
public string? AlbumId { get; set; }
|
||||||
public int? Duration { get; set; } // In seconds
|
public int? Duration { get; set; } // In seconds
|
||||||
|
|||||||
@@ -18,18 +18,6 @@ public class SpotifyApiSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Spotify Client ID from https://developer.spotify.com/dashboard
|
|
||||||
/// Used for OAuth token refresh and API access.
|
|
||||||
/// </summary>
|
|
||||||
public string ClientId { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Spotify Client Secret from https://developer.spotify.com/dashboard
|
|
||||||
/// Optional - only needed for certain OAuth flows.
|
|
||||||
/// </summary>
|
|
||||||
public string ClientSecret { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Spotify session cookie (sp_dc).
|
/// Spotify session cookie (sp_dc).
|
||||||
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.
|
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ public class SpotifyPlaylistConfig
|
|||||||
/// Where to position local tracks: "first" or "last"
|
/// Where to position local tracks: "first" or "last"
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
|
public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cron schedule for syncing this playlist with Spotify
|
||||||
|
/// Format: minute hour day month dayofweek
|
||||||
|
/// Example: "0 8 * * 1" = 8 AM every Monday
|
||||||
|
/// Default: "0 8 * * 1" (weekly on Monday at 8 AM)
|
||||||
|
/// </summary>
|
||||||
|
public string SyncSchedule { get; set; } = "0 8 * * 1";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -13,9 +13,28 @@ using allstarr.Middleware;
|
|||||||
using allstarr.Filters;
|
using allstarr.Filters;
|
||||||
using Microsoft.Extensions.Http;
|
using Microsoft.Extensions.Http;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Configure forwarded headers for reverse proxy support (nginx, etc.)
|
||||||
|
// This allows ASP.NET Core to read X-Forwarded-For, X-Real-IP, etc.
|
||||||
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
|
||||||
|
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto
|
||||||
|
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedHost;
|
||||||
|
|
||||||
|
// Clear known networks and proxies to accept headers from any proxy
|
||||||
|
// This is safe when running behind a trusted reverse proxy (nginx)
|
||||||
|
options.KnownIPNetworks.Clear();
|
||||||
|
options.KnownProxies.Clear();
|
||||||
|
|
||||||
|
// Trust X-Forwarded-* headers from any source
|
||||||
|
// Only do this if your reverse proxy is properly configured and trusted
|
||||||
|
options.ForwardLimit = null;
|
||||||
|
});
|
||||||
|
|
||||||
// Decode SquidWTF API base URLs once at startup
|
// Decode SquidWTF API base URLs once at startup
|
||||||
var squidWtfApiUrls = DecodeSquidWtfUrls();
|
var squidWtfApiUrls = DecodeSquidWtfUrls();
|
||||||
static List<string> DecodeSquidWtfUrls()
|
static List<string> DecodeSquidWtfUrls()
|
||||||
@@ -454,7 +473,8 @@ else if (musicService == MusicService.SquidWTF)
|
|||||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||||
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
|
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
|
||||||
sp.GetRequiredService<RedisCacheService>(),
|
sp.GetRequiredService<RedisCacheService>(),
|
||||||
squidWtfApiUrls));
|
squidWtfApiUrls,
|
||||||
|
sp.GetRequiredService<GenreEnrichmentService>()));
|
||||||
builder.Services.AddSingleton<IDownloadService>(sp =>
|
builder.Services.AddSingleton<IDownloadService>(sp =>
|
||||||
new SquidWTFDownloadService(
|
new SquidWTFDownloadService(
|
||||||
sp.GetRequiredService<IHttpClientFactory>(),
|
sp.GetRequiredService<IHttpClientFactory>(),
|
||||||
@@ -518,18 +538,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
|
|||||||
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientId = builder.Configuration.GetValue<string>("SpotifyApi:ClientId");
|
|
||||||
if (!string.IsNullOrEmpty(clientId))
|
|
||||||
{
|
|
||||||
options.ClientId = clientId;
|
|
||||||
}
|
|
||||||
|
|
||||||
var clientSecret = builder.Configuration.GetValue<string>("SpotifyApi:ClientSecret");
|
|
||||||
if (!string.IsNullOrEmpty(clientSecret))
|
|
||||||
{
|
|
||||||
options.ClientSecret = clientSecret;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
|
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
|
||||||
if (!string.IsNullOrEmpty(sessionCookie))
|
if (!string.IsNullOrEmpty(sessionCookie))
|
||||||
{
|
{
|
||||||
@@ -557,7 +565,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
|
|||||||
// Log configuration (mask sensitive values)
|
// Log configuration (mask sensitive values)
|
||||||
Console.WriteLine($"SpotifyApi Configuration:");
|
Console.WriteLine($"SpotifyApi Configuration:");
|
||||||
Console.WriteLine($" Enabled: {options.Enabled}");
|
Console.WriteLine($" Enabled: {options.Enabled}");
|
||||||
Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}");
|
|
||||||
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
|
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
|
||||||
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
|
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
|
||||||
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");
|
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");
|
||||||
@@ -568,6 +575,12 @@ builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyApiClient>();
|
|||||||
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
|
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
|
||||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
||||||
|
|
||||||
|
// Register LyricsPlus service (multi-source lyrics API)
|
||||||
|
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
|
||||||
|
|
||||||
|
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
|
||||||
|
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
|
||||||
|
|
||||||
// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled)
|
// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled)
|
||||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyPlaylistFetcher>();
|
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyPlaylistFetcher>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyPlaylistFetcher>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyPlaylistFetcher>());
|
||||||
@@ -626,7 +639,23 @@ builder.Services.AddCors(options =>
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Migrate old .env file format on startup
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var migrationService = new EnvMigrationService(app.Services.GetRequiredService<ILogger<EnvMigrationService>>());
|
||||||
|
migrationService.MigrateEnvFile();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
app.Logger.LogWarning(ex, "Failed to run .env migration");
|
||||||
|
}
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
|
|
||||||
|
// IMPORTANT: UseForwardedHeaders must be called BEFORE other middleware
|
||||||
|
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
|
||||||
|
app.UseForwardedHeaders();
|
||||||
|
|
||||||
app.UseExceptionHandler(_ => { }); // Global exception handler
|
app.UseExceptionHandler(_ => { }); // Global exception handler
|
||||||
|
|
||||||
// Enable response compression EARLY in the pipeline
|
// Enable response compression EARLY in the pipeline
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
|
|
||||||
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
|
// Acquire lock BEFORE checking existence to prevent race conditions with concurrent requests
|
||||||
await DownloadLock.WaitAsync(cancellationToken);
|
await DownloadLock.WaitAsync(cancellationToken);
|
||||||
|
var lockHeld = true;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -288,6 +289,7 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
|
Logger.LogDebug("Download already in progress for {SongId}, waiting for completion...", songId);
|
||||||
// Release lock while waiting
|
// Release lock while waiting
|
||||||
DownloadLock.Release();
|
DownloadLock.Release();
|
||||||
|
lockHeld = false;
|
||||||
|
|
||||||
// Wait for download to complete, checking every 100ms (faster than 500ms)
|
// Wait for download to complete, checking every 100ms (faster than 500ms)
|
||||||
// Also respect cancellation token so client timeouts are handled immediately
|
// Also respect cancellation token so client timeouts are handled immediately
|
||||||
@@ -444,7 +446,10 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
DownloadLock.Release();
|
if (lockHeld)
|
||||||
|
{
|
||||||
|
DownloadLock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ public class CacheCleanupService : BackgroundService
|
|||||||
|
|
||||||
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
|
private async Task CleanupOldCachedFilesAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var cachePath = PathHelper.GetCachePath();
|
// Get the actual cache path used by download services
|
||||||
|
var downloadPath = _configuration["Library:DownloadPath"] ?? "downloads";
|
||||||
|
var cachePath = Path.Combine(downloadPath, "cache");
|
||||||
|
|
||||||
if (!Directory.Exists(cachePath))
|
if (!Directory.Exists(cachePath))
|
||||||
{
|
{
|
||||||
@@ -78,7 +80,7 @@ public class CacheCleanupService : BackgroundService
|
|||||||
var deletedCount = 0;
|
var deletedCount = 0;
|
||||||
var totalSize = 0L;
|
var totalSize = 0L;
|
||||||
|
|
||||||
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime}", cutoffTime);
|
_logger.LogInformation("Starting cache cleanup: deleting files older than {CutoffTime} from {Path}", cutoffTime, cachePath);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public class EndpointBenchmarkService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Benchmarks a list of endpoints by making test requests.
|
/// Benchmarks a list of endpoints by making test requests.
|
||||||
/// Returns endpoints sorted by average response time (fastest first).
|
/// Returns endpoints sorted by average response time (fastest first).
|
||||||
|
///
|
||||||
|
/// IMPORTANT: The testFunc should implement its own timeout to prevent slow endpoints
|
||||||
|
/// from blocking startup. Recommended: 5-10 second timeout per ping.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<string>> BenchmarkEndpointsAsync(
|
public async Task<List<string>> BenchmarkEndpointsAsync(
|
||||||
List<string> endpoints,
|
List<string> endpoints,
|
||||||
|
|||||||
59
allstarr/Services/Common/EnvMigrationService.cs
Normal file
59
allstarr/Services/Common/EnvMigrationService.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service that runs on startup to migrate old .env file format to new format
|
||||||
|
/// </summary>
|
||||||
|
public class EnvMigrationService
|
||||||
|
{
|
||||||
|
private readonly ILogger<EnvMigrationService> _logger;
|
||||||
|
private readonly string _envFilePath;
|
||||||
|
|
||||||
|
public EnvMigrationService(ILogger<EnvMigrationService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_envFilePath = Path.Combine(Directory.GetCurrentDirectory(), ".env");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MigrateEnvFile()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_envFilePath))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("No .env file found, skipping migration");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(_envFilePath);
|
||||||
|
var modified = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
var line = lines[i].Trim();
|
||||||
|
|
||||||
|
// Skip comments and empty lines
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Migrate DOWNLOAD_PATH to Library__DownloadPath
|
||||||
|
if (line.StartsWith("DOWNLOAD_PATH="))
|
||||||
|
{
|
||||||
|
var value = line.Substring("DOWNLOAD_PATH=".Length);
|
||||||
|
lines[i] = $"Library__DownloadPath={value}";
|
||||||
|
modified = true;
|
||||||
|
_logger.LogInformation("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified)
|
||||||
|
{
|
||||||
|
File.WriteAllLines(_envFilePath, lines);
|
||||||
|
_logger.LogInformation("✅ .env file migration completed successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to migrate .env file - please manually update DOWNLOAD_PATH to Library__DownloadPath");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,8 @@ public static class FuzzyMatcher
|
|||||||
/// Calculates similarity score following OPTIMAL ORDER:
|
/// Calculates similarity score following OPTIMAL ORDER:
|
||||||
/// 1. Strip decorators (already done by caller)
|
/// 1. Strip decorators (already done by caller)
|
||||||
/// 2. Substring matching (cheap, high-precision)
|
/// 2. Substring matching (cheap, high-precision)
|
||||||
/// 3. Levenshtein distance (expensive, fuzzy)
|
/// 3. Token-based matching (handles word order)
|
||||||
|
/// 4. Levenshtein distance (expensive, fuzzy)
|
||||||
/// Returns score 0-100.
|
/// Returns score 0-100.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static int CalculateSimilarity(string query, string target)
|
public static int CalculateSimilarity(string query, string target)
|
||||||
@@ -103,11 +104,71 @@ public static class FuzzyMatcher
|
|||||||
return 85;
|
return 85;
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
// STEP 3: TOKEN-BASED MATCHING (handles word order)
|
||||||
// Only use this for candidates that survived substring checks
|
var tokens1 = queryNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var tokens2 = targetNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
var distance = LevenshteinDistance(queryNorm, targetNorm);
|
|
||||||
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
|
if (tokens1.Length > 0 && tokens2.Length > 0)
|
||||||
|
{
|
||||||
|
// Calculate how many tokens match (order-independent)
|
||||||
|
var matchedTokens = 0.0; // Use double for partial matches
|
||||||
|
var usedTokens = new HashSet<int>();
|
||||||
|
|
||||||
|
foreach (var token1 in tokens1)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < tokens2.Length; i++)
|
||||||
|
{
|
||||||
|
if (usedTokens.Contains(i)) continue;
|
||||||
|
|
||||||
|
var token2 = tokens2[i];
|
||||||
|
|
||||||
|
// Exact token match
|
||||||
|
if (token1 == token2)
|
||||||
|
{
|
||||||
|
matchedTokens++;
|
||||||
|
usedTokens.Add(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Partial token match (one contains the other)
|
||||||
|
else if (token1.Contains(token2) || token2.Contains(token1))
|
||||||
|
{
|
||||||
|
matchedTokens += 0.8; // Partial credit
|
||||||
|
usedTokens.Add(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate token match percentage
|
||||||
|
var maxTokens = Math.Max(tokens1.Length, tokens2.Length);
|
||||||
|
var tokenMatchScore = (matchedTokens / maxTokens) * 100.0;
|
||||||
|
|
||||||
|
// If token match is very high (90%+), return it
|
||||||
|
if (tokenMatchScore >= 90)
|
||||||
|
{
|
||||||
|
return (int)Math.Round(tokenMatchScore, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If token match is decent (70%+), use it as a floor for Levenshtein
|
||||||
|
if (tokenMatchScore >= 70)
|
||||||
|
{
|
||||||
|
var levenshteinScore = CalculateLevenshteinScore(queryNorm, targetNorm);
|
||||||
|
return (int)Math.Max(tokenMatchScore, levenshteinScore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 4: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
||||||
|
return CalculateLevenshteinScore(queryNorm, targetNorm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates similarity score based on Levenshtein distance.
|
||||||
|
/// Returns score 0-75 (reserve 75-100 for substring/token matches).
|
||||||
|
/// </summary>
|
||||||
|
private static int CalculateLevenshteinScore(string str1, string str2)
|
||||||
|
{
|
||||||
|
var distance = LevenshteinDistance(str1, str2);
|
||||||
|
var maxLength = Math.Max(str1.Length, str2.Length);
|
||||||
|
|
||||||
if (maxLength == 0)
|
if (maxLength == 0)
|
||||||
{
|
{
|
||||||
@@ -117,8 +178,9 @@ public static class FuzzyMatcher
|
|||||||
// Normalize distance by length: score = 1 - (distance / max_length)
|
// Normalize distance by length: score = 1 - (distance / max_length)
|
||||||
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
||||||
|
|
||||||
// Convert to 0-80 range (reserve 80-100 for substring matches)
|
// Convert to 0-75 range (reserve 75-100 for substring/token matches)
|
||||||
var score = (int)(normalizedSimilarity * 80);
|
// Using 75 instead of 80 to be slightly stricter
|
||||||
|
var score = (int)(normalizedSimilarity * 75);
|
||||||
|
|
||||||
return Math.Max(0, score);
|
return Math.Max(0, score);
|
||||||
}
|
}
|
||||||
@@ -154,7 +216,9 @@ public static class FuzzyMatcher
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Normalizes a string for matching by:
|
/// Normalizes a string for matching by:
|
||||||
/// - Converting to lowercase
|
/// - Converting to lowercase
|
||||||
/// - Normalizing apostrophes (', ', ') to standard '
|
/// - Removing accents/diacritics
|
||||||
|
/// - Converting hyphens/underscores to spaces (for word separation)
|
||||||
|
/// - Removing other punctuation (periods, apostrophes, commas, etc.)
|
||||||
/// - Removing extra whitespace
|
/// - Removing extra whitespace
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string NormalizeForMatching(string text)
|
private static string NormalizeForMatching(string text)
|
||||||
@@ -166,18 +230,42 @@ public static class FuzzyMatcher
|
|||||||
|
|
||||||
var normalized = text.ToLowerInvariant().Trim();
|
var normalized = text.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
// Normalize different apostrophe types to standard apostrophe
|
// Remove accents/diacritics (é -> e, ñ -> n, etc.)
|
||||||
normalized = normalized
|
normalized = RemoveDiacritics(normalized);
|
||||||
.Replace("\u2019", "'") // Right single quotation mark (')
|
|
||||||
.Replace("\u2018", "'") // Left single quotation mark (')
|
// Replace hyphens and underscores with spaces (for word separation)
|
||||||
.Replace("`", "'") // Grave accent
|
// This ensures "Dua-Lipa" becomes "Dua Lipa" not "DuaLipa"
|
||||||
.Replace("\u00B4", "'"); // Acute accent (´)
|
normalized = normalized.Replace('-', ' ').Replace('_', ' ');
|
||||||
|
|
||||||
|
// Remove all other punctuation: periods, apostrophes, commas, etc.
|
||||||
|
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", "");
|
||||||
|
|
||||||
// Normalize whitespace
|
// Normalize whitespace
|
||||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
|
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes diacritics (accents) from characters.
|
||||||
|
/// Example: é -> e, ñ -> n, ü -> u
|
||||||
|
/// </summary>
|
||||||
|
private static string RemoveDiacritics(string text)
|
||||||
|
{
|
||||||
|
var normalizedString = text.Normalize(System.Text.NormalizationForm.FormD);
|
||||||
|
var stringBuilder = new System.Text.StringBuilder();
|
||||||
|
|
||||||
|
foreach (var c in normalizedString)
|
||||||
|
{
|
||||||
|
var unicodeCategory = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
|
||||||
|
if (unicodeCategory != System.Globalization.UnicodeCategory.NonSpacingMark)
|
||||||
|
{
|
||||||
|
stringBuilder.Append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stringBuilder.ToString().Normalize(System.Text.NormalizationForm.FormC);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates Levenshtein distance between two strings.
|
/// Calculates Levenshtein distance between two strings.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
|||||||
using allstarr.Models.Download;
|
using allstarr.Models.Download;
|
||||||
using allstarr.Models.Search;
|
using allstarr.Models.Search;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
|
using allstarr.Services.Common;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -15,12 +16,17 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SubsonicSettings _settings;
|
private readonly SubsonicSettings _settings;
|
||||||
|
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||||
private const string BaseUrl = "https://api.deezer.com";
|
private const string BaseUrl = "https://api.deezer.com";
|
||||||
|
|
||||||
public DeezerMetadataService(IHttpClientFactory httpClientFactory, IOptions<SubsonicSettings> settings)
|
public DeezerMetadataService(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IOptions<SubsonicSettings> settings,
|
||||||
|
GenreEnrichmentService? genreEnrichment = null)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
|
_genreEnrichment = genreEnrichment;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||||
@@ -203,6 +209,23 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich with MusicBrainz genres if missing
|
||||||
|
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
|
||||||
|
{
|
||||||
|
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _genreEnrichment.EnrichSongGenreAsync(song);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Silently ignore genre enrichment failures
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return song;
|
return song;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,17 +407,23 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contributors
|
// Contributors (all artists including features)
|
||||||
var contributors = new List<string>();
|
var contributors = new List<string>();
|
||||||
|
var contributorIds = new List<string>();
|
||||||
if (track.TryGetProperty("contributors", out var contribs))
|
if (track.TryGetProperty("contributors", out var contribs))
|
||||||
{
|
{
|
||||||
foreach (var contrib in contribs.EnumerateArray())
|
foreach (var contrib in contribs.EnumerateArray())
|
||||||
{
|
{
|
||||||
if (contrib.TryGetProperty("name", out var contribName))
|
if (contrib.TryGetProperty("name", out var contribName) &&
|
||||||
|
contrib.TryGetProperty("id", out var contribId))
|
||||||
{
|
{
|
||||||
var name = contribName.GetString();
|
var name = contribName.GetString();
|
||||||
|
var id = contribId.GetInt64();
|
||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
contributors.Add(name);
|
contributors.Add(name);
|
||||||
|
contributorIds.Add($"ext-deezer-artist-{id}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,6 +466,8 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
ArtistId = track.TryGetProperty("artist", out var artistForId)
|
ArtistId = track.TryGetProperty("artist", out var artistForId)
|
||||||
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
|
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
|
||||||
: null,
|
: null,
|
||||||
|
Artists = contributors.Count > 0 ? contributors : new List<string>(),
|
||||||
|
ArtistIds = contributorIds.Count > 0 ? contributorIds : new List<string>(),
|
||||||
Album = track.TryGetProperty("album", out var album)
|
Album = track.TryGetProperty("album", out var album)
|
||||||
? album.GetProperty("title").GetString() ?? ""
|
? album.GetProperty("title").GetString() ?? ""
|
||||||
: "",
|
: "",
|
||||||
|
|||||||
@@ -263,9 +263,11 @@ public class JellyfinResponseBuilder
|
|||||||
["Name"] = songTitle,
|
["Name"] = songTitle,
|
||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Id"] = song.Id,
|
["Id"] = song.Id,
|
||||||
|
["PlaylistItemId"] = song.Id, // Required for playlist items
|
||||||
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
||||||
["Container"] = "flac",
|
["Container"] = "flac",
|
||||||
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
|
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
|
||||||
|
["DateCreated"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"),
|
||||||
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
||||||
["ProductionYear"] = song.Year,
|
["ProductionYear"] = song.Year,
|
||||||
["IndexNumber"] = song.Track,
|
["IndexNumber"] = song.Track,
|
||||||
@@ -273,6 +275,7 @@ public class JellyfinResponseBuilder
|
|||||||
["IsFolder"] = false,
|
["IsFolder"] = false,
|
||||||
["Type"] = "Audio",
|
["Type"] = "Audio",
|
||||||
["ChannelId"] = (object?)null,
|
["ChannelId"] = (object?)null,
|
||||||
|
["ParentId"] = song.AlbumId,
|
||||||
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
||||||
? new[] { song.Genre }
|
? new[] { song.Genre }
|
||||||
: new string[0],
|
: new string[0],
|
||||||
@@ -286,6 +289,9 @@ public class JellyfinResponseBuilder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
: new Dictionary<string, object?>[0],
|
: new Dictionary<string, object?>[0],
|
||||||
|
["Tags"] = new string[0],
|
||||||
|
["People"] = new object[0],
|
||||||
|
["SortName"] = songTitle,
|
||||||
["ParentLogoItemId"] = song.AlbumId,
|
["ParentLogoItemId"] = song.AlbumId,
|
||||||
["ParentBackdropItemId"] = song.AlbumId,
|
["ParentBackdropItemId"] = song.AlbumId,
|
||||||
["ParentBackdropImageTags"] = new string[0],
|
["ParentBackdropImageTags"] = new string[0],
|
||||||
@@ -299,13 +305,11 @@ public class JellyfinResponseBuilder
|
|||||||
["ItemId"] = song.Id
|
["ItemId"] = song.Id
|
||||||
},
|
},
|
||||||
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
|
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
|
||||||
["ArtistItems"] = artistNames.Count > 0
|
["ArtistItems"] = artistNames.Count > 0 && song.ArtistIds.Count == artistNames.Count
|
||||||
? artistNames.Select((name, index) => new Dictionary<string, object?>
|
? artistNames.Select((name, index) => new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Name"] = name,
|
["Name"] = name,
|
||||||
["Id"] = index == 0 && song.ArtistId != null
|
["Id"] = song.ArtistIds[index]
|
||||||
? song.ArtistId
|
|
||||||
: $"{song.Id}-artist-{index}"
|
|
||||||
}).ToArray()
|
}).ToArray()
|
||||||
: new[]
|
: new[]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Track this session
|
// Track this session
|
||||||
|
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
||||||
|
?? headers["X-Real-IP"].FirstOrDefault()
|
||||||
|
?? "Unknown";
|
||||||
|
|
||||||
_sessions[deviceId] = new SessionInfo
|
_sessions[deviceId] = new SessionInfo
|
||||||
{
|
{
|
||||||
DeviceId = deviceId,
|
DeviceId = deviceId,
|
||||||
@@ -92,7 +96,8 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
Device = device,
|
Device = device,
|
||||||
Version = version,
|
Version = version,
|
||||||
LastActivity = DateTime.UtcNow,
|
LastActivity = DateTime.UtcNow,
|
||||||
Headers = CloneHeaders(headers)
|
Headers = CloneHeaders(headers),
|
||||||
|
ClientIp = clientIp
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
// Start a WebSocket connection to Jellyfin on behalf of this client
|
||||||
@@ -222,6 +227,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
Client = s.Client,
|
Client = s.Client,
|
||||||
Device = s.Device,
|
Device = s.Device,
|
||||||
Version = s.Version,
|
Version = s.Version,
|
||||||
|
ClientIp = s.ClientIp,
|
||||||
LastActivity = s.LastActivity,
|
LastActivity = s.LastActivity,
|
||||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||||
HasWebSocket = s.WebSocket != null,
|
HasWebSocket = s.WebSocket != null,
|
||||||
@@ -565,6 +571,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
public ClientWebSocket? WebSocket { get; set; }
|
public ClientWebSocket? WebSocket { get; set; }
|
||||||
public string? LastPlayingItemId { get; set; }
|
public string? LastPlayingItemId { get; set; }
|
||||||
public long? LastPlayingPositionTicks { get; set; }
|
public long? LastPlayingPositionTicks { get; set; }
|
||||||
|
public string? ClientIp { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Domain;
|
using allstarr.Models.Domain;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Models.Download;
|
using allstarr.Models.Download;
|
||||||
using allstarr.Models.Search;
|
using allstarr.Models.Search;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
using allstarr.Services;
|
using allstarr.Services;
|
||||||
|
|
||||||
namespace allstarr.Services.Local;
|
namespace allstarr.Services.Local;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Local library service implementation
|
/// Local library service implementation
|
||||||
/// Uses a simple JSON file to store mappings (can be replaced with a database)
|
/// Uses a simple JSON file to store mappings (can be replaced with a database)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LocalLibraryService : ILocalLibraryService
|
public class LocalLibraryService : ILocalLibraryService
|
||||||
{
|
{
|
||||||
private readonly string _mappingFilePath;
|
private readonly string _mappingFilePath;
|
||||||
private readonly string _downloadDirectory;
|
private readonly string _downloadDirectory;
|
||||||
|
|||||||
228
allstarr/Services/Lyrics/LyricsOrchestrator.cs
Normal file
228
allstarr/Services/Lyrics/LyricsOrchestrator.cs
Normal file
@@ -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.LogDebug("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.LogDebug("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.LogInformation("✓ 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.LogDebug(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.LogDebug(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.LogDebug(ex, "Error fetching LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
254
allstarr/Services/Lyrics/LyricsPlusService.cs
Normal file
254
allstarr/Services/Lyrics/LyricsPlusService.cs
Normal file
@@ -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.0 (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.LogWarning(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), TimeSpan.FromDays(30));
|
||||||
|
_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.LogWarning(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,16 +167,14 @@ public class LyricsStartupValidator : BaseStartupValidator
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_spotifySettings.ClientId))
|
if (!_spotifySettings.Enabled)
|
||||||
{
|
{
|
||||||
WriteStatus("Spotify API", "NOT CONFIGURED", ConsoleColor.Yellow);
|
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Gray);
|
||||||
WriteDetail("Set SpotifyApi__ClientId to enable");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
|
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
|
||||||
WriteDetail($"Client ID: {_spotifySettings.ClientId.Substring(0, Math.Min(8, _spotifySettings.ClientId.Length))}...");
|
WriteDetail("Note: Spotify API is used for track matching and lyrics");
|
||||||
WriteDetail("Note: Spotify API is used for track matching, not lyrics");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
|||||||
using allstarr.Models.Download;
|
using allstarr.Models.Download;
|
||||||
using allstarr.Models.Search;
|
using allstarr.Models.Search;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
|
using allstarr.Services.Common;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
private readonly SubsonicSettings _settings;
|
private readonly SubsonicSettings _settings;
|
||||||
private readonly QobuzBundleService _bundleService;
|
private readonly QobuzBundleService _bundleService;
|
||||||
private readonly ILogger<QobuzMetadataService> _logger;
|
private readonly ILogger<QobuzMetadataService> _logger;
|
||||||
|
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||||
private readonly string? _userAuthToken;
|
private readonly string? _userAuthToken;
|
||||||
private readonly string? _userId;
|
private readonly string? _userId;
|
||||||
|
|
||||||
@@ -28,12 +30,14 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
IOptions<SubsonicSettings> settings,
|
IOptions<SubsonicSettings> settings,
|
||||||
IOptions<QobuzSettings> qobuzSettings,
|
IOptions<QobuzSettings> qobuzSettings,
|
||||||
QobuzBundleService bundleService,
|
QobuzBundleService bundleService,
|
||||||
ILogger<QobuzMetadataService> logger)
|
ILogger<QobuzMetadataService> logger,
|
||||||
|
GenreEnrichmentService? genreEnrichment = null)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_bundleService = bundleService;
|
_bundleService = bundleService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_genreEnrichment = genreEnrichment;
|
||||||
|
|
||||||
var qobuzConfig = qobuzSettings.Value;
|
var qobuzConfig = qobuzSettings.Value;
|
||||||
_userAuthToken = qobuzConfig.UserAuthToken;
|
_userAuthToken = qobuzConfig.UserAuthToken;
|
||||||
@@ -177,7 +181,26 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
if (track.TryGetProperty("error", out _)) return null;
|
if (track.TryGetProperty("error", out _)) return null;
|
||||||
|
|
||||||
return ParseQobuzTrackFull(track);
|
var song = ParseQobuzTrackFull(track);
|
||||||
|
|
||||||
|
// Enrich with MusicBrainz genres if missing
|
||||||
|
if (_genreEnrichment != null && song != null && string.IsNullOrEmpty(song.Genre))
|
||||||
|
{
|
||||||
|
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _genreEnrichment.EnrichSongGenreAsync(song);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return song;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -349,6 +349,17 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
// Handle 429 rate limiting with exponential backoff
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||||
|
{
|
||||||
|
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
||||||
|
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds);
|
||||||
|
await Task.Delay(retryAfter, cancellationToken);
|
||||||
|
|
||||||
|
// Retry the request
|
||||||
|
response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
||||||
@@ -735,6 +746,18 @@ public class SpotifyApiClient : IDisposable
|
|||||||
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
||||||
string searchName,
|
string searchName,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetUserPlaylistsAsync(searchName, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all playlists from the user's library, optionally filtered by name.
|
||||||
|
/// Uses GraphQL API which is less rate-limited than REST API.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
|
||||||
|
public async Task<List<SpotifyPlaylist>> GetUserPlaylistsAsync(
|
||||||
|
string? searchName = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||||
if (string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
@@ -744,61 +767,204 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Use GraphQL endpoint instead of REST API to avoid rate limiting
|
||||||
|
// GraphQL is less aggressive with rate limits
|
||||||
var playlists = new List<SpotifyPlaylist>();
|
var playlists = new List<SpotifyPlaylist>();
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
const int limit = 50;
|
const int limit = 50;
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}";
|
// GraphQL query to fetch user playlists - using libraryV3 operation
|
||||||
|
var queryParams = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "operationName", "libraryV3" },
|
||||||
|
{ "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
|
||||||
|
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
||||||
|
var url = $"{WebApiBase}/query?{queryString}";
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode) break;
|
|
||||||
|
// Handle 429 rate limiting with exponential backoff
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||||
|
{
|
||||||
|
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
||||||
|
_logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds);
|
||||||
|
await Task.Delay(retryAfter, cancellationToken);
|
||||||
|
|
||||||
|
// Retry the request
|
||||||
|
response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
|
if (!root.TryGetProperty("data", out var data) ||
|
||||||
|
!data.TryGetProperty("me", out var me) ||
|
||||||
|
!me.TryGetProperty("libraryV3", out var library) ||
|
||||||
|
!library.TryGetProperty("items", out var items))
|
||||||
|
{
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
if (library.TryGetProperty("totalCount", out var totalCount))
|
||||||
|
{
|
||||||
|
var total = totalCount.GetInt32();
|
||||||
|
if (total == 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemCount = 0;
|
||||||
foreach (var item in items.EnumerateArray())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
var itemName = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
itemCount++;
|
||||||
|
|
||||||
// Check if name matches (case-insensitive)
|
if (!item.TryGetProperty("item", out var playlistItem) ||
|
||||||
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
!playlistItem.TryGetProperty("data", out var playlist))
|
||||||
{
|
{
|
||||||
playlists.Add(new SpotifyPlaylist
|
continue;
|
||||||
{
|
|
||||||
SpotifyId = item.TryGetProperty("id", out var itemId) ? itemId.GetString() ?? "" : "",
|
|
||||||
Name = itemName,
|
|
||||||
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
|
||||||
TotalTracks = item.TryGetProperty("tracks", out var tracks) &&
|
|
||||||
tracks.TryGetProperty("total", out var total)
|
|
||||||
? total.GetInt32() : 0,
|
|
||||||
SnapshotId = item.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check __typename to filter out folders and only include playlists
|
||||||
|
if (playlistItem.TryGetProperty("__typename", out var typename))
|
||||||
|
{
|
||||||
|
var typeStr = typename.GetString();
|
||||||
|
// Skip folders - only process Playlist types
|
||||||
|
if (typeStr != null && typeStr.Contains("Folder", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get playlist URI/ID
|
||||||
|
string? uri = null;
|
||||||
|
if (playlistItem.TryGetProperty("uri", out var uriProp))
|
||||||
|
{
|
||||||
|
uri = uriProp.GetString();
|
||||||
|
}
|
||||||
|
else if (playlistItem.TryGetProperty("_uri", out var uriProp2))
|
||||||
|
{
|
||||||
|
uri = uriProp2.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(uri)) continue;
|
||||||
|
|
||||||
|
// Skip if not a playlist URI (e.g., folders have different URI format)
|
||||||
|
if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||||
|
|
||||||
|
// Check if name matches (case-insensitive) - if searchName is provided
|
||||||
|
if (!string.IsNullOrEmpty(searchName) &&
|
||||||
|
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get track count if available - try multiple possible paths
|
||||||
|
var trackCount = 0;
|
||||||
|
if (playlist.TryGetProperty("content", out var content))
|
||||||
|
{
|
||||||
|
if (content.TryGetProperty("totalCount", out var totalTrackCount))
|
||||||
|
{
|
||||||
|
trackCount = totalTrackCount.GetInt32();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: try attributes.itemCount
|
||||||
|
else if (playlist.TryGetProperty("attributes", out var attributes) &&
|
||||||
|
attributes.TryGetProperty("itemCount", out var itemCountProp))
|
||||||
|
{
|
||||||
|
trackCount = itemCountProp.GetInt32();
|
||||||
|
}
|
||||||
|
// Fallback: try totalCount directly
|
||||||
|
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
|
||||||
|
{
|
||||||
|
trackCount = directTotalCount.GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log if we couldn't find track count for debugging
|
||||||
|
if (trackCount == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}",
|
||||||
|
itemName, spotifyId, playlist.GetRawText());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get owner name
|
||||||
|
string? ownerName = null;
|
||||||
|
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
||||||
|
ownerV2.TryGetProperty("data", out var ownerData) &&
|
||||||
|
ownerData.TryGetProperty("username", out var ownerNameProp))
|
||||||
|
{
|
||||||
|
ownerName = ownerNameProp.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get image URL
|
||||||
|
string? imageUrl = null;
|
||||||
|
if (playlist.TryGetProperty("images", out var images) &&
|
||||||
|
images.TryGetProperty("items", out var imageItems) &&
|
||||||
|
imageItems.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var firstImage = imageItems[0];
|
||||||
|
if (firstImage.TryGetProperty("sources", out var sources) &&
|
||||||
|
sources.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var firstSource = sources[0];
|
||||||
|
if (firstSource.TryGetProperty("url", out var urlProp))
|
||||||
|
{
|
||||||
|
imageUrl = urlProp.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playlists.Add(new SpotifyPlaylist
|
||||||
|
{
|
||||||
|
SpotifyId = spotifyId,
|
||||||
|
Name = itemName,
|
||||||
|
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||||
|
TotalTracks = trackCount,
|
||||||
|
OwnerName = ownerName,
|
||||||
|
ImageUrl = imageUrl,
|
||||||
|
SnapshotId = null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items.GetArrayLength() < limit) break;
|
if (itemCount < limit) break;
|
||||||
offset += limit;
|
offset += limit;
|
||||||
|
|
||||||
if (_settings.RateLimitDelayMs > 0)
|
// Add delay between pages to avoid rate limiting
|
||||||
{
|
// Library fetching can be aggressive, so use a longer delay
|
||||||
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
|
||||||
}
|
_logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs);
|
||||||
|
await Task.Delay(delayMs, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Found {Count} playlists{Filter} via GraphQL",
|
||||||
|
playlists.Count,
|
||||||
|
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
||||||
return playlists;
|
return playlists;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error searching user playlists for '{SearchName}'", searchName);
|
_logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL",
|
||||||
|
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
||||||
return new List<SpotifyPlaylist>();
|
return new List<SpotifyPlaylist>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using allstarr.Models.Spotify;
|
|||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Cronos;
|
||||||
|
|
||||||
namespace allstarr.Services.Spotify;
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ namespace allstarr.Services.Spotify;
|
|||||||
/// - ISRC codes available for exact matching
|
/// - ISRC codes available for exact matching
|
||||||
/// - Real-time data without waiting for plugin sync schedules
|
/// - Real-time data without waiting for plugin sync schedules
|
||||||
/// - Full track metadata (duration, release date, etc.)
|
/// - Full track metadata (duration, release date, etc.)
|
||||||
|
///
|
||||||
|
/// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval.
|
||||||
|
/// Cache persists until next cron run to prevent excess Spotify API calls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SpotifyPlaylistFetcher : BackgroundService
|
public class SpotifyPlaylistFetcher : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -45,6 +49,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the Spotify playlist tracks in order, using cache if available.
|
/// Gets the Spotify playlist tracks in order, using cache if available.
|
||||||
|
/// Cache persists until next cron run to prevent excess API calls.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
||||||
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
||||||
@@ -57,7 +62,38 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
if (cached != null && cached.Tracks.Count > 0)
|
if (cached != null && cached.Tracks.Count > 0)
|
||||||
{
|
{
|
||||||
var age = DateTime.UtcNow - cached.FetchedAt;
|
var age = DateTime.UtcNow - cached.FetchedAt;
|
||||||
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
|
||||||
|
// Calculate if cache should still be valid based on cron schedule
|
||||||
|
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||||
|
var shouldRefresh = false;
|
||||||
|
|
||||||
|
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.SyncSchedule))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cron = CronExpression.Parse(playlistConfig.SyncSchedule);
|
||||||
|
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
|
||||||
|
|
||||||
|
if (nextRun.HasValue && DateTime.UtcNow >= nextRun.Value)
|
||||||
|
{
|
||||||
|
shouldRefresh = true;
|
||||||
|
_logger.LogInformation("Cache expired for '{Name}' - next cron run was at {NextRun} UTC",
|
||||||
|
playlistName, nextRun.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not parse cron schedule for '{Name}', falling back to cache duration", playlistName);
|
||||||
|
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No cron schedule, use cache duration from settings
|
||||||
|
shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldRefresh)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
|
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
|
||||||
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
||||||
@@ -94,11 +130,11 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
||||||
{
|
{
|
||||||
// Check if we have a configured Spotify ID for this playlist
|
// Check if we have a configured Spotify ID for this playlist
|
||||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||||
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
|
if (config != null && !string.IsNullOrEmpty(config.Id))
|
||||||
{
|
{
|
||||||
// Use the configured Spotify playlist ID directly
|
// Use the configured Spotify playlist ID directly
|
||||||
spotifyId = playlistConfig.Id;
|
spotifyId = config.Id;
|
||||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||||
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
||||||
}
|
}
|
||||||
@@ -144,12 +180,39 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cache
|
// Calculate cache expiration based on cron schedule
|
||||||
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
|
var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||||
|
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
|
||||||
|
|
||||||
|
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cron = CronExpression.Parse(playlistCfg.SyncSchedule);
|
||||||
|
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
|
||||||
|
|
||||||
|
if (nextRun.HasValue)
|
||||||
|
{
|
||||||
|
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
|
||||||
|
// Add 5 minutes buffer
|
||||||
|
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
_logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)",
|
||||||
|
playlistName, nextRun.Value, timeUntilNextRun.TotalHours);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache with cron-based expiration
|
||||||
|
await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
|
||||||
await SaveToFileCacheAsync(playlistName, playlist);
|
await SaveToFileCacheAsync(playlistName, playlist);
|
||||||
|
|
||||||
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
|
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
|
||||||
playlistName, playlist.Tracks.Count);
|
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
|
||||||
|
|
||||||
return playlist.Tracks;
|
return playlist.Tracks;
|
||||||
}
|
}
|
||||||
@@ -235,32 +298,102 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
_logger.LogInformation("Spotify API ENABLED");
|
_logger.LogInformation("Spotify API ENABLED");
|
||||||
_logger.LogInformation("Authenticated via sp_dc session cookie");
|
_logger.LogInformation("Authenticated via sp_dc session cookie");
|
||||||
_logger.LogInformation("Cache duration: {Minutes} minutes", _spotifyApiSettings.CacheDurationMinutes);
|
|
||||||
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
||||||
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
||||||
|
|
||||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(" - {Name}", playlist.Name);
|
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||||
|
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("========================================");
|
_logger.LogInformation("========================================");
|
||||||
|
|
||||||
// Initial fetch of all playlists
|
// Initial fetch of all playlists on startup
|
||||||
await FetchAllPlaylistsAsync(stoppingToken);
|
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)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await FetchAllPlaylistsAsync(stoppingToken);
|
// Check each playlist to see if it needs refreshing based on cron schedule
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var needsRefresh = new List<string>();
|
||||||
|
|
||||||
|
foreach (var config in _spotifyImportSettings.Playlists)
|
||||||
|
{
|
||||||
|
var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * 1" : config.SyncSchedule;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cron = CronExpression.Parse(schedule);
|
||||||
|
|
||||||
|
// Check if we have cached data
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{config.Name}";
|
||||||
|
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||||
|
|
||||||
|
if (cached != null)
|
||||||
|
{
|
||||||
|
// Calculate when the next run should be after the last fetch
|
||||||
|
var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc);
|
||||||
|
|
||||||
|
if (nextRun.HasValue && now >= nextRun.Value)
|
||||||
|
{
|
||||||
|
needsRefresh.Add(config.Name);
|
||||||
|
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
|
||||||
|
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No cache, fetch it
|
||||||
|
needsRefresh.Add(config.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}", config.Name, schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch playlists that need refreshing
|
||||||
|
if (needsRefresh.Count > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
|
||||||
|
|
||||||
|
foreach (var playlistName in needsRefresh)
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await GetPlaylistTracksAsync(playlistName);
|
||||||
|
|
||||||
|
// Rate limiting between playlists
|
||||||
|
if (playlistName != needsRefresh.Last())
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("=== FINISHED FETCHING PLAYLISTS ===");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep for 1 hour before checking again
|
||||||
|
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error during periodic playlist refresh");
|
_logger.LogError(ex, "Error in playlist fetcher loop");
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using allstarr.Services.Jellyfin;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Cronos;
|
||||||
|
|
||||||
namespace allstarr.Services.Spotify;
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
@@ -17,6 +18,9 @@ namespace allstarr.Services.Spotify;
|
|||||||
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
||||||
///
|
///
|
||||||
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
||||||
|
///
|
||||||
|
/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers.
|
||||||
|
/// Manual refresh is always allowed. Cache persists until next cron run.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SpotifyTrackMatchingService : BackgroundService
|
public class SpotifyTrackMatchingService : BackgroundService
|
||||||
{
|
{
|
||||||
@@ -27,8 +31,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
||||||
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
||||||
private DateTime _lastMatchingRun = DateTime.MinValue;
|
|
||||||
private readonly TimeSpan _minimumMatchingInterval = TimeSpan.FromMinutes(5); // Don't run more than once per 5 minutes
|
// Track last run time per playlist to prevent duplicate runs
|
||||||
|
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
|
||||||
|
private readonly TimeSpan _minimumRunInterval = TimeSpan.FromMinutes(5); // Cooldown between runs
|
||||||
|
|
||||||
public SpotifyTrackMatchingService(
|
public SpotifyTrackMatchingService(
|
||||||
IOptions<SpotifyImportSettings> spotifySettings,
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
@@ -57,17 +63,29 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||||
|
|
||||||
if (!_spotifySettings.Enabled)
|
if (!_spotifySettings.Enabled)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||||
? "ISRC-preferred" : "fuzzy";
|
? "ISRC-preferred" : "fuzzy";
|
||||||
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
||||||
|
_logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule");
|
||||||
|
|
||||||
|
// Log all playlist schedules
|
||||||
|
foreach (var playlist in _spotifySettings.Playlists)
|
||||||
|
{
|
||||||
|
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||||
|
_logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("========================================");
|
||||||
|
|
||||||
// Wait a bit for the fetcher to run first
|
// Wait a bit for the fetcher to run first
|
||||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||||
@@ -75,7 +93,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Run once on startup to match any existing missing tracks
|
// Run once on startup to match any existing missing tracks
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Running initial track matching on startup");
|
_logger.LogInformation("Running initial track matching on startup (one-time)");
|
||||||
await MatchAllPlaylistsAsync(stoppingToken);
|
await MatchAllPlaylistsAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -83,46 +101,100 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
_logger.LogError(ex, "Error during startup track matching");
|
_logger.LogError(ex, "Error during startup track matching");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now start the periodic matching loop
|
// Now start the cron-based scheduling loop
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// Wait for configured interval before next run (default 24 hours)
|
|
||||||
var intervalHours = _spotifySettings.MatchingIntervalHours;
|
|
||||||
if (intervalHours <= 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Periodic matching disabled (MatchingIntervalHours = {Hours}), only startup run will execute", intervalHours);
|
|
||||||
break; // Exit loop - only run once on startup
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await MatchAllPlaylistsAsync(stoppingToken);
|
// Calculate next run time for each playlist
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
|
||||||
|
|
||||||
|
foreach (var playlist in _spotifySettings.Playlists)
|
||||||
|
{
|
||||||
|
var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cron = CronExpression.Parse(schedule);
|
||||||
|
var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
|
||||||
|
|
||||||
|
if (nextRun.HasValue)
|
||||||
|
{
|
||||||
|
nextRuns.Add((playlist.Name, nextRun.Value, cron));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Could not calculate next run for playlist {Name} with schedule {Schedule}",
|
||||||
|
playlist.Name, schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}",
|
||||||
|
playlist.Name, schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextRuns.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No valid cron schedules found, sleeping for 1 hour");
|
||||||
|
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the next playlist that needs to run
|
||||||
|
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
|
||||||
|
var waitTime = nextPlaylist.NextRun - now;
|
||||||
|
|
||||||
|
if (waitTime.TotalSeconds > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
|
||||||
|
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
|
||||||
|
|
||||||
|
// Wait until next run (or max 1 hour to re-check schedules)
|
||||||
|
var maxWait = TimeSpan.FromHours(1);
|
||||||
|
var actualWait = waitTime > maxWait ? maxWait : waitTime;
|
||||||
|
await Task.Delay(actualWait, stoppingToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time to run this playlist
|
||||||
|
_logger.LogInformation("=== CRON TRIGGER: Running scheduled match for {Playlist} ===", nextPlaylist.PlaylistName);
|
||||||
|
|
||||||
|
// Check cooldown to prevent duplicate runs
|
||||||
|
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
|
||||||
|
{
|
||||||
|
var timeSinceLastRun = now - lastRun;
|
||||||
|
if (timeSinceLastRun < _minimumRunInterval)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||||
|
nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run matching for this playlist
|
||||||
|
await MatchSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
|
||||||
|
_lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
|
||||||
|
|
||||||
|
_logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
|
||||||
|
nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error in track matching service");
|
_logger.LogError(ex, "Error in cron scheduling loop");
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Public method to trigger matching manually for all playlists (called from controller).
|
|
||||||
/// </summary>
|
|
||||||
public async Task TriggerMatchingAsync()
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Manual track matching triggered for all playlists");
|
|
||||||
await MatchAllPlaylistsAsync(CancellationToken.None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Public method to trigger matching for a specific playlist (called from controller).
|
/// Matches tracks for a single playlist (called by cron scheduler or manual trigger).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist}", playlistName);
|
|
||||||
|
|
||||||
var playlist = _spotifySettings.Playlists
|
var playlist = _spotifySettings.Playlists
|
||||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
@@ -148,13 +220,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
// Use new direct API mode with ISRC support
|
// Use new direct API mode with ISRC support
|
||||||
await MatchPlaylistTracksWithIsrcAsync(
|
await MatchPlaylistTracksWithIsrcAsync(
|
||||||
playlist.Name, playlistFetcher, metadataService, CancellationToken.None);
|
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fall back to legacy mode
|
// Fall back to legacy mode
|
||||||
await MatchPlaylistTracksLegacyAsync(
|
await MatchPlaylistTracksLegacyAsync(
|
||||||
playlist.Name, metadataService, CancellationToken.None);
|
playlist.Name, metadataService, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -164,19 +236,43 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
/// <summary>
|
||||||
|
/// Public method to trigger matching manually for all playlists (called from controller).
|
||||||
|
/// This bypasses cron schedules and runs immediately.
|
||||||
|
/// </summary>
|
||||||
|
public async Task TriggerMatchingAsync()
|
||||||
{
|
{
|
||||||
// Check if we've run too recently (cooldown period)
|
_logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)");
|
||||||
var timeSinceLastRun = DateTime.UtcNow - _lastMatchingRun;
|
await MatchAllPlaylistsAsync(CancellationToken.None);
|
||||||
if (timeSinceLastRun < _minimumMatchingInterval)
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public method to trigger matching for a specific playlist (called from controller).
|
||||||
|
/// This bypasses cron schedules and runs immediately.
|
||||||
|
/// </summary>
|
||||||
|
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (bypassing cron schedule)", playlistName);
|
||||||
|
|
||||||
|
// Check cooldown to prevent abuse
|
||||||
|
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Skipping track matching - last run was {Seconds}s ago (minimum interval: {MinSeconds}s)",
|
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||||
(int)timeSinceLastRun.TotalSeconds, (int)_minimumMatchingInterval.TotalSeconds);
|
if (timeSinceLastRun < _minimumRunInterval)
|
||||||
return;
|
{
|
||||||
|
_logger.LogWarning("Skipping manual refresh for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||||
|
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
||||||
|
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before refreshing again");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
|
||||||
_lastMatchingRun = DateTime.UtcNow;
|
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||||
|
|
||||||
var playlists = _spotifySettings.Playlists;
|
var playlists = _spotifySettings.Playlists;
|
||||||
if (playlists.Count == 0)
|
if (playlists.Count == 0)
|
||||||
@@ -185,34 +281,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
|
||||||
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
|
||||||
|
|
||||||
// Check if we should use the new SpotifyPlaylistFetcher
|
|
||||||
SpotifyPlaylistFetcher? playlistFetcher = null;
|
|
||||||
if (_spotifyApiSettings.Enabled)
|
|
||||||
{
|
|
||||||
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var playlist in playlists)
|
foreach (var playlist in playlists)
|
||||||
{
|
{
|
||||||
if (cancellationToken.IsCancellationRequested) break;
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (playlistFetcher != null)
|
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
|
||||||
{
|
|
||||||
// Use new direct API mode with ISRC support
|
|
||||||
await MatchPlaylistTracksWithIsrcAsync(
|
|
||||||
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Fall back to legacy mode
|
|
||||||
await MatchPlaylistTracksLegacyAsync(
|
|
||||||
playlist.Name, metadataService, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -220,7 +295,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
_logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ===");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -497,8 +572,37 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (matchedTracks.Count > 0)
|
if (matchedTracks.Count > 0)
|
||||||
{
|
{
|
||||||
// Cache matched tracks with position data
|
// Calculate cache expiration: until next cron run (not just cache duration from settings)
|
||||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
var playlist = _spotifySettings.Playlists
|
||||||
|
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
|
||||||
|
|
||||||
|
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cron = CronExpression.Parse(playlist.SyncSchedule);
|
||||||
|
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
|
||||||
|
|
||||||
|
if (nextRun.HasValue)
|
||||||
|
{
|
||||||
|
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
|
||||||
|
// Add 5 minutes buffer to ensure cache doesn't expire before next run
|
||||||
|
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
_logger.LogInformation("Cache will persist until next cron run: {NextRun} UTC (in {Hours:F1} hours)",
|
||||||
|
nextRun.Value, timeUntilNextRun.TotalHours);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not calculate next cron run for {Playlist}, using default cache duration", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache matched tracks with position data until next cron run
|
||||||
|
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
|
||||||
|
|
||||||
// Save matched tracks to file for persistence across restarts
|
// Save matched tracks to file for persistence across restarts
|
||||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||||
@@ -506,15 +610,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Also update legacy cache for backward compatibility
|
// Also update legacy cache for backward compatibility
|
||||||
var legacyKey = $"spotify:matched:{playlistName}";
|
var legacyKey = $"spotify:matched:{playlistName}";
|
||||||
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||||
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - manual mappings will be applied next",
|
"✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - cache expires in {Hours:F1}h",
|
||||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours);
|
||||||
|
|
||||||
// Pre-build playlist items cache for instant serving
|
// Pre-build playlist items cache for instant serving
|
||||||
// This is what makes the UI show all matched tracks at once
|
// This is what makes the UI show all matched tracks at once
|
||||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
|
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -849,6 +953,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
string? jellyfinPlaylistId,
|
string? jellyfinPlaylistId,
|
||||||
List<SpotifyPlaylistTrack> spotifyTracks,
|
List<SpotifyPlaylistTrack> spotifyTracks,
|
||||||
List<MatchedTrack> matchedTracks,
|
List<MatchedTrack> matchedTracks,
|
||||||
|
TimeSpan cacheExpiration,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -887,7 +992,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
|
// Request all fields that clients typically need (not just MediaSources)
|
||||||
|
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=Genres,DateCreated,MediaSources,ParentId,People,Tags,SortName,ProviderIds";
|
||||||
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
||||||
|
|
||||||
if (statusCode != 200 || existingTracksResponse == null)
|
if (statusCode != 200 || existingTracksResponse == null)
|
||||||
@@ -1196,9 +1302,64 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (finalItems.Count > 0)
|
if (finalItems.Count > 0)
|
||||||
{
|
{
|
||||||
// Save to Redis cache
|
// Enrich external tracks with genres from MusicBrainz
|
||||||
|
if (externalUsedCount > 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var genreEnrichment = _serviceProvider.GetService<GenreEnrichmentService>();
|
||||||
|
if (genreEnrichment != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🎨 Enriching {Count} external tracks with genres from MusicBrainz...", externalUsedCount);
|
||||||
|
|
||||||
|
// Extract external songs from matched tracks
|
||||||
|
var externalSongs = matchedTracks
|
||||||
|
.Where(t => t.MatchedSong != null && !t.MatchedSong.IsLocal)
|
||||||
|
.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.LogWarning(ex, "Failed to enrich genres for {Playlist}, continuing without genres", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to Redis cache with same expiration as matched tracks (until next cron run)
|
||||||
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
||||||
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
|
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
||||||
|
|
||||||
// Save to file cache for persistence
|
// Save to file cache for persistence
|
||||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||||
@@ -1210,8 +1371,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo}",
|
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
|
||||||
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo);
|
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
private readonly ILogger<SquidWTFMetadataService> _logger;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
|
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||||
|
|
||||||
public SquidWTFMetadataService(
|
public SquidWTFMetadataService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
@@ -63,13 +64,15 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
IOptions<SquidWTFSettings> squidwtfSettings,
|
IOptions<SquidWTFSettings> squidwtfSettings,
|
||||||
ILogger<SquidWTFMetadataService> logger,
|
ILogger<SquidWTFMetadataService> logger,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
List<string> apiUrls)
|
List<string> apiUrls,
|
||||||
|
GenreEnrichmentService? genreEnrichment = null)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||||
|
_genreEnrichment = genreEnrichment;
|
||||||
|
|
||||||
// Set up default headers
|
// Set up default headers
|
||||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||||
@@ -83,19 +86,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
// Race all endpoints for fastest search results
|
// Use round-robin to distribute load across endpoints (allows parallel processing of multiple tracks)
|
||||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Use 's' parameter for track search as per hifi-api spec
|
// Use 's' parameter for track search as per hifi-api spec
|
||||||
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url, ct);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(ct);
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
// Check for error in response body
|
// Check for error in response body
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
@@ -129,19 +132,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
// Race all endpoints for fastest search results
|
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
||||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
||||||
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url, ct);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(ct);
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var albums = new List<Album>();
|
var albums = new List<Album>();
|
||||||
@@ -166,14 +169,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
// Race all endpoints for fastest search results
|
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
||||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Per hifi-api spec: use 'a' parameter for artist search
|
// Per hifi-api spec: use 'a' parameter for artist search
|
||||||
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||||
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url, ct);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -181,7 +184,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(ct);
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var artists = new List<Artist>();
|
var artists = new List<Artist>();
|
||||||
@@ -286,6 +289,23 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
var song = ParseTidalTrackFull(track);
|
var song = ParseTidalTrackFull(track);
|
||||||
|
|
||||||
|
// Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres)
|
||||||
|
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
|
||||||
|
{
|
||||||
|
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _genreEnrichment.EnrichSongGenreAsync(song);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
||||||
// This avoids redundant conversions and ensures it's done in parallel with the download
|
// This avoids redundant conversions and ensures it's done in parallel with the download
|
||||||
|
|
||||||
@@ -595,6 +615,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
|
// Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
|
||||||
var allArtists = new List<string>();
|
var allArtists = new List<string>();
|
||||||
|
var allArtistIds = new List<string>();
|
||||||
string artistName = "";
|
string artistName = "";
|
||||||
string? artistId = null;
|
string? artistId = null;
|
||||||
|
|
||||||
@@ -604,9 +625,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
foreach (var artistEl in artists.EnumerateArray())
|
foreach (var artistEl in artists.EnumerateArray())
|
||||||
{
|
{
|
||||||
var name = artistEl.GetProperty("name").GetString();
|
var name = artistEl.GetProperty("name").GetString();
|
||||||
|
var id = artistEl.GetProperty("id").GetInt64();
|
||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
{
|
{
|
||||||
allArtists.Add(name);
|
allArtists.Add(name);
|
||||||
|
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,7 +637,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
if (allArtists.Count > 0)
|
if (allArtists.Count > 0)
|
||||||
{
|
{
|
||||||
artistName = allArtists[0];
|
artistName = allArtists[0];
|
||||||
artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}";
|
artistId = allArtistIds[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback to singular "artist" field
|
// Fallback to singular "artist" field
|
||||||
@@ -623,6 +646,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||||
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
||||||
allArtists.Add(artistName);
|
allArtists.Add(artistName);
|
||||||
|
allArtistIds.Add(artistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get album info
|
// Get album info
|
||||||
@@ -649,6 +673,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
Artist = artistName,
|
Artist = artistName,
|
||||||
ArtistId = artistId,
|
ArtistId = artistId,
|
||||||
Artists = allArtists,
|
Artists = allArtists,
|
||||||
|
ArtistIds = allArtistIds,
|
||||||
Album = albumTitle,
|
Album = albumTitle,
|
||||||
AlbumId = albumId,
|
AlbumId = albumId,
|
||||||
Duration = track.TryGetProperty("duration", out var duration)
|
Duration = track.TryGetProperty("duration", out var duration)
|
||||||
@@ -711,6 +736,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
// Get all artists - prefer "artists" array for collaborations
|
// Get all artists - prefer "artists" array for collaborations
|
||||||
var allArtists = new List<string>();
|
var allArtists = new List<string>();
|
||||||
|
var allArtistIds = new List<string>();
|
||||||
string artistName = "";
|
string artistName = "";
|
||||||
long artistIdNum = 0;
|
long artistIdNum = 0;
|
||||||
|
|
||||||
@@ -719,9 +745,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
foreach (var artistEl in artists.EnumerateArray())
|
foreach (var artistEl in artists.EnumerateArray())
|
||||||
{
|
{
|
||||||
var name = artistEl.GetProperty("name").GetString();
|
var name = artistEl.GetProperty("name").GetString();
|
||||||
|
var id = artistEl.GetProperty("id").GetInt64();
|
||||||
if (!string.IsNullOrEmpty(name))
|
if (!string.IsNullOrEmpty(name))
|
||||||
{
|
{
|
||||||
allArtists.Add(name);
|
allArtists.Add(name);
|
||||||
|
allArtistIds.Add($"ext-squidwtf-artist-{id}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,6 +764,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||||
artistIdNum = artist.GetProperty("id").GetInt64();
|
artistIdNum = artist.GetProperty("id").GetInt64();
|
||||||
allArtists.Add(artistName);
|
allArtists.Add(artistName);
|
||||||
|
allArtistIds.Add($"ext-squidwtf-artist-{artistIdNum}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album artist - same as main artist for Tidal tracks
|
// Album artist - same as main artist for Tidal tracks
|
||||||
@@ -771,6 +800,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
Artist = artistName,
|
Artist = artistName,
|
||||||
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
|
ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
|
||||||
Artists = allArtists,
|
Artists = allArtists,
|
||||||
|
ArtistIds = allArtistIds,
|
||||||
Album = albumTitle,
|
Album = albumTitle,
|
||||||
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
|
AlbumId = $"ext-squidwtf-album-{albumIdNum}",
|
||||||
AlbumArtist = albumArtist,
|
AlbumArtist = albumArtist,
|
||||||
|
|||||||
@@ -73,7 +73,11 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await _httpClient.GetAsync(endpoint, ct);
|
// 5 second timeout per ping - mark slow endpoints as failed
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
|
||||||
return response.IsSuccessStatusCode;
|
return response.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>allstarr</RootNamespace>
|
<RootNamespace>allstarr</RootNamespace>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.2.2</Version>
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
<AssemblyVersion>1.2.2.0</AssemblyVersion>
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
<FileVersion>1.2.2.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||||
|
<PackageReference Include="Cronos" Version="0.11.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" />
|
||||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||||
|
|||||||
@@ -32,8 +32,7 @@
|
|||||||
"EnableExternalPlaylists": true
|
"EnableExternalPlaylists": true
|
||||||
},
|
},
|
||||||
"Library": {
|
"Library": {
|
||||||
"DownloadPath": "./downloads",
|
"DownloadPath": "./downloads"
|
||||||
"KeptPath": "/app/kept"
|
|
||||||
},
|
},
|
||||||
"Qobuz": {
|
"Qobuz": {
|
||||||
"UserAuthToken": "your-qobuz-token",
|
"UserAuthToken": "your-qobuz-token",
|
||||||
@@ -62,8 +61,6 @@
|
|||||||
},
|
},
|
||||||
"SpotifyApi": {
|
"SpotifyApi": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"ClientId": "",
|
|
||||||
"ClientSecret": "",
|
|
||||||
"SessionCookie": "",
|
"SessionCookie": "",
|
||||||
"CacheDurationMinutes": 60,
|
"CacheDurationMinutes": 60,
|
||||||
"RateLimitDelayMs": 100,
|
"RateLimitDelayMs": 100,
|
||||||
|
|||||||
@@ -537,7 +537,7 @@
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||||
<div class="tab" data-tab="playlists">Active Playlists</div>
|
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||||
<div class="tab" data-tab="config">Configuration</div>
|
<div class="tab" data-tab="config">Configuration</div>
|
||||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -652,7 +652,7 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>
|
<h2>
|
||||||
Active Spotify Playlists
|
Injected Spotify Playlists
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
|
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
|
||||||
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
|
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
|
||||||
@@ -660,13 +660,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||||
These are the Spotify playlists currently being monitored and filled with tracks from your music service.
|
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music service.
|
||||||
</p>
|
</p>
|
||||||
<table class="playlist-table">
|
<table class="playlist-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Spotify ID</th>
|
<th>Spotify ID</th>
|
||||||
|
<th>Sync Schedule</th>
|
||||||
<th>Tracks</th>
|
<th>Tracks</th>
|
||||||
<th>Completion</th>
|
<th>Completion</th>
|
||||||
<th>Cache Age</th>
|
<th>Cache Age</th>
|
||||||
@@ -675,7 +676,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="playlist-table-body">
|
<tbody id="playlist-table-body">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="loading">
|
<td colspan="7" class="loading">
|
||||||
<span class="spinner"></span> Loading playlists...
|
<span class="spinner"></span> Loading playlists...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -806,8 +807,62 @@
|
|||||||
|
|
||||||
<!-- Configuration Tab -->
|
<!-- Configuration Tab -->
|
||||||
<div class="tab-content" id="tab-config">
|
<div class="tab-content" id="tab-config">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Core Settings</h2>
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Backend Type <span style="color: var(--error);">*</span></span>
|
||||||
|
<span class="value" id="config-backend-type">-</span>
|
||||||
|
<button onclick="openEditSetting('BACKEND_TYPE', 'Backend Type', 'select', 'Choose your media server backend', ['Jellyfin', 'Subsonic'])">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Music Service <span style="color: var(--error);">*</span></span>
|
||||||
|
<span class="value" id="config-music-service">-</span>
|
||||||
|
<button onclick="openEditSetting('MUSIC_SERVICE', 'Music Service', 'select', 'Choose your music download provider', ['SquidWTF', 'Deezer', 'Qobuz'])">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Storage Mode</span>
|
||||||
|
<span class="value" id="config-storage-mode">-</span>
|
||||||
|
<button onclick="openEditSetting('STORAGE_MODE', 'Storage Mode', 'select', 'Permanent keeps files forever, Cache auto-deletes after duration', ['Permanent', 'Cache'])">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item" id="cache-duration-row" style="display: none;">
|
||||||
|
<span class="label">Cache Duration (hours)</span>
|
||||||
|
<span class="value" id="config-cache-duration-hours">-</span>
|
||||||
|
<button onclick="openEditSetting('CACHE_DURATION_HOURS', 'Cache Duration (hours)', 'number', 'How long to keep cached files before deletion')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Download Mode</span>
|
||||||
|
<span class="value" id="config-download-mode">-</span>
|
||||||
|
<button onclick="openEditSetting('DOWNLOAD_MODE', 'Download Mode', 'select', 'Download individual tracks or full albums', ['Track', 'Album'])">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Explicit Filter</span>
|
||||||
|
<span class="value" id="config-explicit-filter">-</span>
|
||||||
|
<button onclick="openEditSetting('EXPLICIT_FILTER', 'Explicit Filter', 'select', 'Filter explicit content', ['All', 'Explicit', 'Clean'])">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Enable External Playlists</span>
|
||||||
|
<span class="value" id="config-enable-external-playlists">-</span>
|
||||||
|
<button onclick="openEditSetting('ENABLE_EXTERNAL_PLAYLISTS', 'Enable External Playlists', 'toggle')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Playlists Directory</span>
|
||||||
|
<span class="value" id="config-playlists-directory">-</span>
|
||||||
|
<button onclick="openEditSetting('PLAYLISTS_DIRECTORY', 'Playlists Directory', 'text', 'Directory path for external playlists')">Edit</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Redis Enabled</span>
|
||||||
|
<span class="value" id="config-redis-enabled">-</span>
|
||||||
|
<button onclick="openEditSetting('REDIS_ENABLED', 'Redis Enabled', 'toggle')">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Spotify API Settings</h2>
|
<h2>Spotify API Settings</h2>
|
||||||
|
<div style="background: rgba(248, 81, 73, 0.15); border: 1px solid var(--error); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
|
||||||
|
⚠️ For active playlists and link functionality to work, sp_dc session cookie must be set!
|
||||||
|
</div>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">API Enabled</span>
|
<span class="label">API Enabled</span>
|
||||||
@@ -815,7 +870,7 @@
|
|||||||
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
<button onclick="openEditSetting('SPOTIFY_API_ENABLED', 'Spotify API Enabled', 'toggle')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">Session Cookie (sp_dc)</span>
|
<span class="label">Session Cookie (sp_dc) <span style="color: var(--error);">*</span></span>
|
||||||
<span class="value" id="config-spotify-cookie">-</span>
|
<span class="value" id="config-spotify-cookie">-</span>
|
||||||
<button onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
|
<button onclick="openEditSetting('SPOTIFY_API_SESSION_COOKIE', 'Spotify Session Cookie', 'password', 'Get from browser dev tools while logged into Spotify. Cookie typically lasts ~1 year.')">Update</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -904,17 +959,17 @@
|
|||||||
<h2>Jellyfin Settings</h2>
|
<h2>Jellyfin Settings</h2>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">URL</span>
|
<span class="label">URL <span style="color: var(--error);">*</span></span>
|
||||||
<span class="value" id="config-jellyfin-url">-</span>
|
<span class="value" id="config-jellyfin-url">-</span>
|
||||||
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
|
<button onclick="openEditSetting('JELLYFIN_URL', 'Jellyfin URL', 'text')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">API Key</span>
|
<span class="label">API Key <span style="color: var(--error);">*</span></span>
|
||||||
<span class="value" id="config-jellyfin-api-key">-</span>
|
<span class="value" id="config-jellyfin-api-key">-</span>
|
||||||
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
|
<button onclick="openEditSetting('JELLYFIN_API_KEY', 'Jellyfin API Key', 'password')">Update</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">User ID</span>
|
<span class="label">User ID <span style="color: var(--error);">*</span></span>
|
||||||
<span class="value" id="config-jellyfin-user-id">-</span>
|
<span class="value" id="config-jellyfin-user-id">-</span>
|
||||||
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
|
<button onclick="openEditSetting('JELLYFIN_USER_ID', 'Jellyfin User ID', 'text', 'Required for playlist operations. Get from Jellyfin user profile URL: userId=...')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -943,17 +998,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Sync Schedule</h2>
|
<h2>Spotify Import Settings</h2>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">Sync Start Time</span>
|
<span class="label">Spotify Import Enabled</span>
|
||||||
<span class="value" id="config-sync-time">-</span>
|
<span class="value" id="config-spotify-import-enabled">-</span>
|
||||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_START_HOUR', 'Sync Start Hour (0-23)', 'number')">Edit</button>
|
<button onclick="openEditSetting('SPOTIFY_IMPORT_ENABLED', 'Spotify Import Enabled', 'toggle')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-item">
|
<div class="config-item">
|
||||||
<span class="label">Sync Window</span>
|
<span class="label">Matching Interval (hours)</span>
|
||||||
<span class="value" id="config-sync-window">-</span>
|
<span class="value" id="config-matching-interval">-</span>
|
||||||
<button onclick="openEditSetting('SPOTIFY_IMPORT_SYNC_WINDOW_HOURS', 'Sync Window (hours)', 'number')">Edit</button>
|
<button onclick="openEditSetting('SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS', 'Matching Interval (hours)', 'number', 'How often to check for playlist updates')">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1119,7 +1174,7 @@
|
|||||||
<div class="modal-content" style="max-width: 600px;">
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
<h3>Map Track to External Provider</h3>
|
<h3>Map Track to External Provider</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Jellyfin mapping modal instead.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Track Info -->
|
<!-- Track Info -->
|
||||||
@@ -1161,25 +1216,94 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Local Jellyfin Track Mapping Modal -->
|
||||||
|
<div class="modal" id="local-map-modal">
|
||||||
|
<div class="modal-content" style="max-width: 700px;">
|
||||||
|
<h3>Map Track to Local Jellyfin Track</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
|
Search your Jellyfin library and select a local track to map to this Spotify track.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Track Info -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Spotify Track (Position <span id="local-map-position"></span>)</label>
|
||||||
|
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
||||||
|
<strong id="local-map-spotify-title"></strong><br>
|
||||||
|
<span style="color: var(--text-secondary);" id="local-map-spotify-artist"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Section -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Search Jellyfin Library</label>
|
||||||
|
<input type="text" id="local-map-search" placeholder="Search for track name or artist...">
|
||||||
|
<button onclick="searchJellyfinTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Results -->
|
||||||
|
<div id="local-map-results" style="max-height: 300px; overflow-y: auto; margin-top: 16px;"></div>
|
||||||
|
|
||||||
|
<input type="hidden" id="local-map-playlist-name">
|
||||||
|
<input type="hidden" id="local-map-spotify-id">
|
||||||
|
<input type="hidden" id="local-map-jellyfin-id">
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button onclick="closeModal('local-map-modal')">Cancel</button>
|
||||||
|
<button class="primary" onclick="saveLocalMapping()" id="local-map-save-btn" disabled>Save Mapping</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Link Playlist Modal -->
|
<!-- Link Playlist Modal -->
|
||||||
<div class="modal" id="link-playlist-modal">
|
<div class="modal" id="link-playlist-modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h3>Link to Spotify Playlist</h3>
|
<h3>Link to Spotify Playlist</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
Enter the Spotify playlist ID or URL. Allstarr will automatically download missing tracks from your configured music service.
|
Select a playlist from your Spotify library or enter a playlist ID/URL manually. Allstarr will automatically download missing tracks from your configured music service.
|
||||||
</p>
|
</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Jellyfin Playlist</label>
|
<label>Jellyfin Playlist</label>
|
||||||
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
|
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
|
||||||
<input type="hidden" id="link-jellyfin-id">
|
<input type="hidden" id="link-jellyfin-id">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
|
<!-- Toggle between select and manual input -->
|
||||||
|
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||||
|
<button type="button" id="select-mode-btn" class="primary" onclick="switchLinkMode('select')" style="flex: 1;">Select from My Playlists</button>
|
||||||
|
<button type="button" id="manual-mode-btn" onclick="switchLinkMode('manual')" style="flex: 1;">Enter Manually</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Select from user playlists -->
|
||||||
|
<div class="form-group" id="link-select-group">
|
||||||
|
<label>Your Spotify Playlists</label>
|
||||||
|
<select id="link-spotify-select" style="width: 100%;">
|
||||||
|
<option value="">Loading playlists...</option>
|
||||||
|
</select>
|
||||||
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||||
|
Select a playlist from your Spotify library
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual input -->
|
||||||
|
<div class="form-group" id="link-manual-group" style="display: none;">
|
||||||
<label>Spotify Playlist ID or URL</label>
|
<label>Spotify Playlist ID or URL</label>
|
||||||
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
|
<input type="text" id="link-spotify-id" placeholder="37i9dQZF1DXcBWIGoYBM5M or spotify:playlist:... or full URL">
|
||||||
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||||
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
|
Accepts: <code>37i9dQZF1DXcBWIGoYBM5M</code>, <code>spotify:playlist:37i9dQZF1DXcBWIGoYBM5M</code>, or full Spotify URL
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Schedule -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Sync Schedule (Cron)</label>
|
||||||
|
<input type="text" id="link-sync-schedule" placeholder="0 8 * * 1" value="0 8 * * 1" style="font-family: monospace;">
|
||||||
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||||
|
Cron format: <code>minute hour day month dayofweek</code><br>
|
||||||
|
Default: <code>0 8 * * 1</code> = 8 AM every Monday<br>
|
||||||
|
Examples: <code>0 6 * * *</code> = daily at 6 AM, <code>0 20 * * 5</code> = Fridays at 8 PM<br>
|
||||||
|
<a href="https://crontab.guru/" target="_blank" style="color: var(--primary);">Use crontab.guru to build your schedule</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
|
||||||
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
|
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
|
||||||
@@ -1460,7 +1584,7 @@
|
|||||||
|
|
||||||
if (data.playlists.length === 0) {
|
if (data.playlists.length === 0) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1514,10 +1638,16 @@
|
|||||||
// Debug logging
|
// Debug logging
|
||||||
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
|
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
|
||||||
|
|
||||||
|
const syncSchedule = p.syncSchedule || '0 8 * * 1';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${escapeHtml(p.name)}</strong></td>
|
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||||
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
|
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
|
||||||
|
<td style="font-family:monospace;font-size:0.85rem;">
|
||||||
|
${escapeHtml(syncSchedule)}
|
||||||
|
<button onclick="editPlaylistSchedule('${escapeJs(p.name)}', '${escapeJs(syncSchedule)}')" style="margin-left:4px;font-size:0.75rem;padding:2px 6px;">Edit</button>
|
||||||
|
</td>
|
||||||
<td>${statsHtml}${breakdown}</td>
|
<td>${statsHtml}${breakdown}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
@@ -1776,6 +1906,23 @@
|
|||||||
const res = await fetch('/api/admin/config');
|
const res = await fetch('/api/admin/config');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Core settings
|
||||||
|
document.getElementById('config-backend-type').textContent = data.backendType || 'Jellyfin';
|
||||||
|
document.getElementById('config-music-service').textContent = data.musicService || 'SquidWTF';
|
||||||
|
document.getElementById('config-storage-mode').textContent = data.library?.storageMode || 'Cache';
|
||||||
|
document.getElementById('config-cache-duration-hours').textContent = data.library?.cacheDurationHours || '24';
|
||||||
|
document.getElementById('config-download-mode').textContent = data.library?.downloadMode || 'Track';
|
||||||
|
document.getElementById('config-explicit-filter').textContent = data.explicitFilter || 'All';
|
||||||
|
document.getElementById('config-enable-external-playlists').textContent = data.enableExternalPlaylists ? 'Yes' : 'No';
|
||||||
|
document.getElementById('config-playlists-directory').textContent = data.playlistsDirectory || '(not set)';
|
||||||
|
document.getElementById('config-redis-enabled').textContent = data.redisEnabled ? 'Yes' : 'No';
|
||||||
|
|
||||||
|
// Show/hide cache duration based on storage mode
|
||||||
|
const cacheDurationRow = document.getElementById('cache-duration-row');
|
||||||
|
if (cacheDurationRow) {
|
||||||
|
cacheDurationRow.style.display = data.library?.storageMode === 'Cache' ? 'grid' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Spotify API settings
|
// Spotify API settings
|
||||||
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
|
||||||
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
|
||||||
@@ -1817,10 +1964,8 @@
|
|||||||
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
|
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
|
||||||
|
|
||||||
// Sync settings
|
// Sync settings
|
||||||
const syncHour = data.spotifyImport.syncStartHour;
|
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
|
||||||
const syncMin = data.spotifyImport.syncStartMinute;
|
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' hours';
|
||||||
document.getElementById('config-sync-time').textContent = `${String(syncHour).padStart(2, '0')}:${String(syncMin).padStart(2, '0')}`;
|
|
||||||
document.getElementById('config-sync-window').textContent = data.spotifyImport.syncWindowHours + ' hours';
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch config:', error);
|
console.error('Failed to fetch config:', error);
|
||||||
}
|
}
|
||||||
@@ -1896,23 +2041,138 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLinkPlaylist(jellyfinId, name) {
|
let currentLinkMode = 'select'; // 'select' or 'manual'
|
||||||
|
let spotifyUserPlaylists = []; // Cache of user playlists
|
||||||
|
|
||||||
|
function switchLinkMode(mode) {
|
||||||
|
currentLinkMode = mode;
|
||||||
|
|
||||||
|
const selectGroup = document.getElementById('link-select-group');
|
||||||
|
const manualGroup = document.getElementById('link-manual-group');
|
||||||
|
const selectBtn = document.getElementById('select-mode-btn');
|
||||||
|
const manualBtn = document.getElementById('manual-mode-btn');
|
||||||
|
|
||||||
|
if (mode === 'select') {
|
||||||
|
selectGroup.style.display = 'block';
|
||||||
|
manualGroup.style.display = 'none';
|
||||||
|
selectBtn.classList.add('primary');
|
||||||
|
manualBtn.classList.remove('primary');
|
||||||
|
} else {
|
||||||
|
selectGroup.style.display = 'none';
|
||||||
|
manualGroup.style.display = 'block';
|
||||||
|
selectBtn.classList.remove('primary');
|
||||||
|
manualBtn.classList.add('primary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSpotifyUserPlaylists() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/spotify/user-playlists');
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
console.error('Failed to fetch Spotify playlists:', res.status, error);
|
||||||
|
|
||||||
|
// Show user-friendly error message
|
||||||
|
if (res.status === 429) {
|
||||||
|
showToast('Spotify rate limit reached. Please wait a moment and try again.', 'warning', 5000);
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
showToast('Spotify authentication failed. Check your sp_dc cookie.', 'error', 5000);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return data.playlists || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch Spotify playlists:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLinkPlaylist(jellyfinId, name) {
|
||||||
document.getElementById('link-jellyfin-id').value = jellyfinId;
|
document.getElementById('link-jellyfin-id').value = jellyfinId;
|
||||||
document.getElementById('link-jellyfin-name').value = name;
|
document.getElementById('link-jellyfin-name').value = name;
|
||||||
document.getElementById('link-spotify-id').value = '';
|
document.getElementById('link-spotify-id').value = '';
|
||||||
|
|
||||||
|
// Reset to select mode
|
||||||
|
switchLinkMode('select');
|
||||||
|
|
||||||
|
// Fetch user playlists if not already cached
|
||||||
|
if (spotifyUserPlaylists.length === 0) {
|
||||||
|
const select = document.getElementById('link-spotify-select');
|
||||||
|
select.innerHTML = '<option value="">Loading playlists...</option>';
|
||||||
|
|
||||||
|
spotifyUserPlaylists = await fetchSpotifyUserPlaylists();
|
||||||
|
|
||||||
|
// Filter out already-linked playlists
|
||||||
|
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
|
||||||
|
|
||||||
|
if (availablePlaylists.length === 0) {
|
||||||
|
if (spotifyUserPlaylists.length > 0) {
|
||||||
|
select.innerHTML = '<option value="">All your playlists are already linked</option>';
|
||||||
|
} else {
|
||||||
|
select.innerHTML = '<option value="">No playlists found or Spotify not configured</option>';
|
||||||
|
}
|
||||||
|
// Switch to manual mode if no available playlists
|
||||||
|
switchLinkMode('manual');
|
||||||
|
} else {
|
||||||
|
// Populate dropdown with only unlinked playlists
|
||||||
|
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
|
||||||
|
availablePlaylists.map(p =>
|
||||||
|
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Re-filter in case playlists were linked since last fetch
|
||||||
|
const select = document.getElementById('link-spotify-select');
|
||||||
|
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
|
||||||
|
|
||||||
|
if (availablePlaylists.length === 0) {
|
||||||
|
select.innerHTML = '<option value="">All your playlists are already linked</option>';
|
||||||
|
switchLinkMode('manual');
|
||||||
|
} else {
|
||||||
|
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
|
||||||
|
availablePlaylists.map(p =>
|
||||||
|
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
openModal('link-playlist-modal');
|
openModal('link-playlist-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function linkPlaylist() {
|
async function linkPlaylist() {
|
||||||
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
const jellyfinId = document.getElementById('link-jellyfin-id').value;
|
||||||
const name = document.getElementById('link-jellyfin-name').value;
|
const name = document.getElementById('link-jellyfin-name').value;
|
||||||
const spotifyId = document.getElementById('link-spotify-id').value.trim();
|
const syncSchedule = document.getElementById('link-sync-schedule').value.trim();
|
||||||
|
|
||||||
if (!spotifyId) {
|
// Validate sync schedule (basic cron format check)
|
||||||
showToast('Spotify Playlist ID is required', 'error');
|
if (!syncSchedule) {
|
||||||
|
showToast('Sync schedule is required', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cronParts = syncSchedule.split(/\s+/);
|
||||||
|
if (cronParts.length !== 5) {
|
||||||
|
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Spotify ID based on current mode
|
||||||
|
let spotifyId = '';
|
||||||
|
if (currentLinkMode === 'select') {
|
||||||
|
spotifyId = document.getElementById('link-spotify-select').value;
|
||||||
|
if (!spotifyId) {
|
||||||
|
showToast('Please select a Spotify playlist', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
spotifyId = document.getElementById('link-spotify-id').value.trim();
|
||||||
|
if (!spotifyId) {
|
||||||
|
showToast('Spotify Playlist ID is required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extract ID from various Spotify formats:
|
// Extract ID from various Spotify formats:
|
||||||
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
// - spotify:playlist:37i9dQZF1DXcBWIGoYBM5M
|
||||||
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
|
// - https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M
|
||||||
@@ -1935,7 +2195,11 @@
|
|||||||
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
spotifyPlaylistId: cleanSpotifyId,
|
||||||
|
syncSchedule: syncSchedule
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -1945,6 +2209,9 @@
|
|||||||
showRestartBanner();
|
showRestartBanner();
|
||||||
closeModal('link-playlist-modal');
|
closeModal('link-playlist-modal');
|
||||||
|
|
||||||
|
// Clear the Spotify playlists cache so it refreshes next time
|
||||||
|
spotifyUserPlaylists = [];
|
||||||
|
|
||||||
// Update UI state without refetching all playlists
|
// Update UI state without refetching all playlists
|
||||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||||
if (playlistsTable) {
|
if (playlistsTable) {
|
||||||
@@ -1982,6 +2249,9 @@
|
|||||||
showToast('Playlist unlinked.', 'success');
|
showToast('Playlist unlinked.', 'success');
|
||||||
showRestartBanner();
|
showRestartBanner();
|
||||||
|
|
||||||
|
// Clear the Spotify playlists cache so it refreshes next time
|
||||||
|
spotifyUserPlaylists = [];
|
||||||
|
|
||||||
// Update UI state without refetching all playlists
|
// Update UI state without refetching all playlists
|
||||||
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
const playlistsTable = document.getElementById('jellyfinPlaylistsTable');
|
||||||
if (playlistsTable) {
|
if (playlistsTable) {
|
||||||
@@ -2345,6 +2615,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function editPlaylistSchedule(playlistName, currentSchedule) {
|
||||||
|
const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM\n\nUse https://crontab.guru/ to build your schedule`, currentSchedule);
|
||||||
|
|
||||||
|
if (!newSchedule || newSchedule === currentSchedule) return;
|
||||||
|
|
||||||
|
// Validate cron format
|
||||||
|
const cronParts = newSchedule.trim().split(/\s+/);
|
||||||
|
if (cronParts.length !== 5) {
|
||||||
|
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/schedule`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ syncSchedule: newSchedule.trim() })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Sync schedule updated!', 'success');
|
||||||
|
showRestartBanner();
|
||||||
|
fetchPlaylists();
|
||||||
|
} else {
|
||||||
|
const error = await res.json();
|
||||||
|
showToast(error.error || 'Failed to update schedule', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update schedule:', error);
|
||||||
|
showToast('Failed to update schedule', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removePlaylist(name) {
|
async function removePlaylist(name) {
|
||||||
if (!confirm(`Remove playlist "${name}"?`)) return;
|
if (!confirm(`Remove playlist "${name}"?`)) return;
|
||||||
|
|
||||||
@@ -2374,8 +2677,23 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(name) + '/tracks');
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Failed to fetch tracks:', res.status, res.statusText);
|
||||||
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + res.status + ' ' + res.statusText + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
console.log('Tracks data received:', data);
|
||||||
|
|
||||||
|
if (!data || !data.tracks) {
|
||||||
|
console.error('Invalid data structure:', data);
|
||||||
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Invalid data received from server</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.tracks.length === 0) {
|
if (data.tracks.length === 0) {
|
||||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
|
||||||
return;
|
return;
|
||||||
@@ -2490,7 +2808,8 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks</p>';
|
console.error('Error in viewTracks:', error);
|
||||||
|
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + error.message + '</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2715,8 +3034,27 @@
|
|||||||
saveBtn.disabled = !externalId;
|
saveBtn.disabled = !externalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open manual mapping modal (external only)
|
// Open local Jellyfin mapping modal
|
||||||
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||||
|
document.getElementById('local-map-playlist-name').value = playlistName;
|
||||||
|
document.getElementById('local-map-position').textContent = position + 1;
|
||||||
|
document.getElementById('local-map-spotify-title').textContent = title;
|
||||||
|
document.getElementById('local-map-spotify-artist').textContent = artist;
|
||||||
|
document.getElementById('local-map-spotify-id').value = spotifyId;
|
||||||
|
|
||||||
|
// Pre-fill search with track info
|
||||||
|
document.getElementById('local-map-search').value = `${title} ${artist}`;
|
||||||
|
|
||||||
|
// Reset fields
|
||||||
|
document.getElementById('local-map-results').innerHTML = '';
|
||||||
|
document.getElementById('local-map-jellyfin-id').value = '';
|
||||||
|
document.getElementById('local-map-save-btn').disabled = true;
|
||||||
|
|
||||||
|
openModal('local-map-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open external mapping modal
|
||||||
|
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||||
document.getElementById('map-playlist-name').value = playlistName;
|
document.getElementById('map-playlist-name').value = playlistName;
|
||||||
document.getElementById('map-position').textContent = position + 1;
|
document.getElementById('map-position').textContent = position + 1;
|
||||||
document.getElementById('map-spotify-title').textContent = title;
|
document.getElementById('map-spotify-title').textContent = title;
|
||||||
@@ -2731,12 +3069,123 @@
|
|||||||
openModal('manual-map-modal');
|
openModal('manual-map-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alias for backward compatibility
|
// Search Jellyfin tracks for local mapping
|
||||||
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
async function searchJellyfinTracks() {
|
||||||
openManualMap(playlistName, position, title, artist, spotifyId);
|
const query = document.getElementById('local-map-search').value.trim();
|
||||||
|
if (!query) {
|
||||||
|
showToast('Please enter a search query', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultsDiv = document.getElementById('local-map-results');
|
||||||
|
resultsDiv.innerHTML = '<p style="text-align:center;padding:20px;">Searching...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.tracks || data.tracks.length === 0) {
|
||||||
|
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">No tracks found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = data.tracks.map(track => `
|
||||||
|
<div style="padding: 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: background 0.2s;"
|
||||||
|
onclick="selectJellyfinTrack('${escapeJs(track.id)}', '${escapeJs(track.name)}', '${escapeJs(track.artist)}')"
|
||||||
|
onmouseover="this.style.background='var(--bg-primary)'"
|
||||||
|
onmouseout="this.style.background='transparent'">
|
||||||
|
<strong>${escapeHtml(track.name)}</strong><br>
|
||||||
|
<span style="color: var(--text-secondary); font-size: 0.9em;">${escapeHtml(track.artist)}</span>
|
||||||
|
${track.album ? '<br><span style="color: var(--text-secondary); font-size: 0.85em;">' + escapeHtml(track.album) + '</span>' : ''}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save manual mapping (external only)
|
// Select a Jellyfin track for mapping
|
||||||
|
function selectJellyfinTrack(jellyfinId, name, artist) {
|
||||||
|
document.getElementById('local-map-jellyfin-id').value = jellyfinId;
|
||||||
|
document.getElementById('local-map-save-btn').disabled = false;
|
||||||
|
|
||||||
|
// Highlight selected track
|
||||||
|
document.querySelectorAll('#local-map-results > div').forEach(div => {
|
||||||
|
div.style.background = 'transparent';
|
||||||
|
div.style.border = '1px solid var(--border)';
|
||||||
|
});
|
||||||
|
event.target.closest('div').style.background = 'var(--primary)';
|
||||||
|
event.target.closest('div').style.border = '1px solid var(--primary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save local Jellyfin mapping
|
||||||
|
async function saveLocalMapping() {
|
||||||
|
const playlistName = document.getElementById('local-map-playlist-name').value;
|
||||||
|
const spotifyId = document.getElementById('local-map-spotify-id').value;
|
||||||
|
const jellyfinId = document.getElementById('local-map-jellyfin-id').value;
|
||||||
|
|
||||||
|
if (!jellyfinId) {
|
||||||
|
showToast('Please select a Jellyfin track', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
spotifyId,
|
||||||
|
jellyfinId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const saveBtn = document.getElementById('local-map-save-btn');
|
||||||
|
const originalText = saveBtn.textContent;
|
||||||
|
saveBtn.textContent = 'Saving...';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/map`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Track mapped successfully!', 'success');
|
||||||
|
closeModal('local-map-modal');
|
||||||
|
|
||||||
|
// Refresh the tracks view if it's open
|
||||||
|
const tracksModal = document.getElementById('tracks-modal');
|
||||||
|
if (tracksModal.style.display === 'flex') {
|
||||||
|
await viewTracks(playlistName);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(data.error || 'Failed to save mapping', 'error');
|
||||||
|
saveBtn.textContent = originalText;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
showToast('Request timed out. The mapping may still be processing.', 'warning');
|
||||||
|
} else {
|
||||||
|
showToast('Failed to save mapping', 'error');
|
||||||
|
}
|
||||||
|
saveBtn.textContent = originalText;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save manual mapping (external only) - kept for backward compatibility
|
||||||
async function saveManualMapping() {
|
async function saveManualMapping() {
|
||||||
const playlistName = document.getElementById('map-playlist-name').value;
|
const playlistName = document.getElementById('map-playlist-name').value;
|
||||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- allstarr-network
|
- allstarr-network
|
||||||
|
|
||||||
|
# Spotify Lyrics API sidecar service
|
||||||
|
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
|
||||||
spotify-lyrics:
|
spotify-lyrics:
|
||||||
image: akashrchandran/spotify-lyrics-api:latest
|
image: akashrchandran/spotify-lyrics-api:latest
|
||||||
|
platform: linux/amd64
|
||||||
container_name: allstarr-spotify-lyrics
|
container_name: allstarr-spotify-lyrics
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -104,8 +107,6 @@ services:
|
|||||||
|
|
||||||
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
|
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
|
||||||
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
|
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
|
||||||
- SpotifyApi__ClientId=${SPOTIFY_API_CLIENT_ID:-}
|
|
||||||
- SpotifyApi__ClientSecret=${SPOTIFY_API_CLIENT_SECRET:-}
|
|
||||||
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
|
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
|
||||||
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
|
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
|
||||||
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
||||||
|
|||||||
Reference in New Issue
Block a user