12 Commits

Author SHA1 Message Date
0a9e528418 v1.3.0: Bump version to 1.3.0
Some checks are pending
Docker Build & Push / build-and-test (push) Waiting to run
Docker Build & Push / docker (push) Blocked by required conditions
2026-02-11 00:01:06 -05:00
f74728fc73 fix: use MBID lookup for MusicBrainz genre enrichment
Search API doesn't return genres even with inc=genres parameter.
Now doing search to get MBID, then lookup by MBID to get genres.
2026-02-10 23:52:14 -05:00
87467be61b feat: add LyricsPlus API with modular orchestrator architecture
Add multi-source lyrics support with clean, modular architecture for easier debugging and maintenance.

New Features:
- LyricsPlusService: Multi-source lyrics API (Apple Music, Spotify, Musixmatch)
- LyricsOrchestrator: Priority-based coordinator for all lyrics sources
- Modular service architecture with independent error handling
- Word-level and line-level timing support with LRC conversion

Architecture:
- Priority chain: Spotify → LyricsPlus → LRCLib
- Each service logs independently (→ Trying, ✓ Found,  Not found)
- Fallback continues even if one service fails
- Easy to add new sources or modify priority

Benefits:
- Easier debugging with clear service-level logs
- Better maintainability with separated concerns
- More reliable with graceful fallback handling
- Extensible for future lyrics sources
2026-02-10 23:02:17 -05:00
713ecd4ec8 v1.2.6: fix search result ordering to prioritize local tracks
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-10 13:36:06 -05:00
0ff1e3a428 v1.2.5: fix genre enrichment blocking cover art loading 2026-02-10 12:56:43 -05:00
cef18b9482 v1.2.5: prioritize local tracks and optimize genre enrichment
Local tracks now appear first in search results with +10 score boost. Genre enrichment is non-blocking for faster cover art and playback.
2026-02-10 12:50:52 -05:00
1bfe30b216 v1.2.4: stop racing SquidWTF endpoints for better throughput
Use round-robin instead of racing to enable parallel processing of 12 tracks simultaneously (one per endpoint) instead of racing all endpoints for each track.
2026-02-10 12:14:38 -05:00
c9c82a650d v1.2.3: fix Spotify playlist metadata fields
Complete Jellyfin item structure for external tracks with all requested fields including PlaylistItemId, DateCreated, ParentId, Tags, People, and SortName.
2026-02-10 11:56:12 -05:00
d0a7dbcc96 v1.2.2: fix metadata loss in Spotify playlists
Spotify playlist tracks were missing genres, composers, and other metadata because the proxy only requested MediaSources field instead of passing through all client-requested fields.
2026-02-10 11:01:38 -05:00
9c9a827a91 v1.2.1: MusicBrainz genre enrichment + cleanup
## Features
- Implement automatic MusicBrainz genre enrichment for all external sources
  - Deezer: Enriches when genre missing
  - Qobuz: Enriches when genre missing
  - SquidWTF/Tidal: Always enriches (Tidal doesn't provide genres)
- Use ISRC codes for exact matching, fallback to title/artist search
- Cache results in Redis (30 days) + file cache for performance
- Respect MusicBrainz rate limits (1 req/sec)

## Cleanup
- Remove unused Spotify API ClientId and ClientSecret settings
- Simplify Spotify API configuration

## Fixes
- Make GenreEnrichmentService optional to fix test failures
- All 225 tests passing

This ensures all external tracks have genre metadata for better
organization and filtering in music clients.
2026-02-10 10:29:49 -05:00
96889738df v1.2.1: MusicBrainz genre enrichment + cleanup
## Features
- Implement automatic MusicBrainz genre enrichment for all external sources
  - Deezer: Enriches when genre missing
  - Qobuz: Enriches when genre missing
  - SquidWTF/Tidal: Always enriches (Tidal doesn't provide genres)
- Use ISRC codes for exact matching, fallback to title/artist search
- Cache results in Redis (30 days) + file cache for performance
- Respect MusicBrainz rate limits (1 req/sec)

## Cleanup
- Remove unused Spotify API ClientId and ClientSecret settings
- Simplify Spotify API configuration

This ensures all external tracks have genre metadata for better
organization and filtering in music clients.
2026-02-10 10:25:41 -05:00
f3c791496e v1.2.0: Spotify playlist improvements and admin UI fixes
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
Enhanced Spotify playlist integration with GraphQL API, fixed track counts and folder filtering, improved session IP tracking with X-Forwarded-For support, and added per-playlist cron scheduling.
2026-02-09 18:17:15 -05:00
38 changed files with 3211 additions and 358 deletions

View File

@@ -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.

View File

@@ -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

View 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}");
}
}

View 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);
}
}

View 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();
}
}

View 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);
}
}

View 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);
}
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -139,7 +139,7 @@ public class WebSocketProxyMiddleware
} }
// Set user agent // Set user agent
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0"); serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.3.0");
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted); await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket"); _logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket");

View File

@@ -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

View File

@@ -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.

View File

@@ -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>

View File

@@ -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

View File

@@ -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();
}
} }
} }

View File

@@ -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
{ {

View File

@@ -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,

View 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");
}
}
}

View File

@@ -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.

View File

@@ -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() ?? ""
: "", : "",

View File

@@ -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[]
{ {

View File

@@ -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()

View File

@@ -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;

View File

@@ -18,7 +18,7 @@ public class LrclibService
ILogger<LrclibService> logger) ILogger<LrclibService> logger)
{ {
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)"); _httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.3.0 (https://github.com/SoPat712/allstarr)");
_cache = cache; _cache = cache;
_logger = logger; _logger = logger;
} }

View 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
}

View 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.3.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;
}
}

View File

@@ -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)

View File

@@ -25,7 +25,7 @@ public class MusicBrainzService
ILogger<MusicBrainzService> logger) ILogger<MusicBrainzService> logger)
{ {
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)"); _httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.3.0 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_settings = settings.Value; _settings = settings.Value;
@@ -92,6 +92,7 @@ public class MusicBrainzService
/// <summary> /// <summary>
/// Searches for recordings by title and artist. /// Searches for recordings by title and artist.
/// Note: Search API doesn't return genres, only MBIDs. Use LookupByMbidAsync to get genres.
/// </summary> /// </summary>
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5) public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
{ {
@@ -107,7 +108,8 @@ public class MusicBrainzService
// Build Lucene query // Build Lucene query
var query = $"recording:\"{title}\" AND artist:\"{artist}\""; var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
var encodedQuery = Uri.EscapeDataString(query); var encodedQuery = Uri.EscapeDataString(query);
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}&inc=genres+tags"; // Note: Search API doesn't support inc=genres, only returns basic info + MBIDs
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}";
_logger.LogDebug("MusicBrainz search: {Url}", url); _logger.LogDebug("MusicBrainz search: {Url}", url);
@@ -140,9 +142,56 @@ public class MusicBrainzService
} }
} }
/// <summary>
/// Looks up a recording by MBID to get full details including genres.
/// </summary>
public async Task<MusicBrainzRecording?> LookupByMbidAsync(string mbid)
{
if (!_settings.Enabled)
{
return null;
}
await RateLimitAsync();
try
{
var url = $"{_settings.BaseUrl}/recording/{mbid}?fmt=json&inc=artists+releases+release-groups+genres+tags";
_logger.LogDebug("MusicBrainz MBID lookup: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("MusicBrainz MBID lookup failed: {StatusCode}", response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync();
var recording = JsonSerializer.Deserialize<MusicBrainzRecording>(json, JsonOptions);
if (recording == null)
{
_logger.LogDebug("No MusicBrainz recording found for MBID: {Mbid}", mbid);
return null;
}
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string?>();
_logger.LogInformation("✓ Found MusicBrainz recording for MBID {Mbid}: {Title} by {Artist} (Genres: {Genres})",
mbid, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
return recording;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error looking up MBID {Mbid} in MusicBrainz", mbid);
return null;
}
}
/// <summary> /// <summary>
/// Enriches a song with genre information from MusicBrainz. /// Enriches a song with genre information from MusicBrainz.
/// First tries ISRC lookup, then falls back to title/artist search. /// First tries ISRC lookup, then falls back to title/artist search + MBID lookup.
/// </summary> /// </summary>
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null) public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
{ {
@@ -153,17 +202,23 @@ public class MusicBrainzService
MusicBrainzRecording? recording = null; MusicBrainzRecording? recording = null;
// Try ISRC lookup first (most accurate) // Try ISRC lookup first (most accurate and includes genres)
if (!string.IsNullOrEmpty(isrc)) if (!string.IsNullOrEmpty(isrc))
{ {
recording = await LookupByIsrcAsync(isrc); recording = await LookupByIsrcAsync(isrc);
} }
// Fall back to search if ISRC lookup failed or no ISRC provided // Fall back to search + MBID lookup if ISRC lookup failed or no ISRC provided
if (recording == null) if (recording == null)
{ {
var recordings = await SearchRecordingsAsync(title, artist, limit: 1); var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
recording = recordings.FirstOrDefault(); var searchResult = recordings.FirstOrDefault();
// If we found a recording from search, do a full lookup by MBID to get genres
if (searchResult != null && !string.IsNullOrEmpty(searchResult.Id))
{
recording = await LookupByMbidAsync(searchResult.Id);
}
} }
if (recording == null) if (recording == null)

View File

@@ -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)
{ {

View File

@@ -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>();
} }
} }

View File

@@ -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);
} }
} }
} }

View File

@@ -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
{ {

View File

@@ -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,

View File

@@ -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

View File

@@ -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.3.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion> <AssemblyVersion>1.3.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion> <FileVersion>1.3.0.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" />

View File

@@ -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,

View File

@@ -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;

View File

@@ -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}