Compare commits

..

33 Commits

Author SHA1 Message Date
8fad6d8c4e Fix manual mapping detection in Active Playlists tab
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-04 19:35:34 -05:00
d11b656b23 Add loading state to save mapping button and timeout handling 2026-02-04 19:24:02 -05:00
cf1428d678 Fix manual mapping race condition and add log gitignore 2026-02-04 19:17:48 -05:00
030937b196 Add error handling and better logging for playlist cache deserialization 2026-02-04 19:10:04 -05:00
f77281fd3d Fix GetJellyfinTrack: add UserId and verify Audio type for URL-based mapping 2026-02-04 19:04:12 -05:00
791a8b3fdb Fix Jellyfin search: add UserId and verify Audio type 2026-02-04 19:03:21 -05:00
7311bbc04a Add debug logging to GetPlaylists cache reading 2026-02-04 18:59:00 -05:00
696a2d56f2 Fix manual mappings: preserve on rematch + fix local/external count detection 2026-02-04 18:53:09 -05:00
5680b9c7c9 Fix GetPlaylists to use pre-built cache with manual mappings for accurate counts 2026-02-04 18:49:12 -05:00
1d31784ff8 Fix manual mapping: add immediate playlist rebuild and manual mapping priority in cache builder 2026-02-04 18:38:25 -05:00
10e58eced9 fix: add authentication to playlist cache pre-building
- PreBuildPlaylistItemsCacheAsync was failing with HTTP 401
- Background services don't have client headers for authentication
- Now manually creates X-Emby-Authorization header with API key
- Fixes 'Failed to fetch Jellyfin playlist items: HTTP 401' warning
- Playlist items cache now builds successfully after track matching

All 225 tests pass.
2026-02-04 18:23:11 -05:00
0937fcf163 fix: accurate playlist counting and three-color progress bars
- Fix playlist counting logic to use fuzzy matching (same as track view)
- Count local tracks by matching Jellyfin tracks to Spotify tracks
- Count external matched tracks from cache
- Count missing tracks (not found locally or externally)
- Progress bars now show three colors:
  * Green: Local tracks in Jellyfin
  * Orange: External matched tracks (SquidWTF/Deezer/Qobuz)
  * Grey: Missing tracks (not found anywhere)
- Add 'Missing' badge to tracks that couldn't be found
- Missing tracks can still be manually mapped
- Fixes incorrect counts like '28 matched • 1 missing' showing 29 external tracks

All 225 tests pass.
2026-02-04 17:49:10 -05:00
506f39d606 feat: instant UI update after manual track mapping
- Backend now returns mapped track details after saving
- Frontend updates track in-place without requiring page refresh
- Track status changes from External to Local immediately
- Map button is removed after successful mapping
- Playlist counts refresh in background
- Improved UX: no more 'refresh the playlist' message

All 225 tests pass.
2026-02-04 17:44:57 -05:00
7bb7c6a40e fix: manual mapping UI and [S] tag consistency
- Fix manual mapping track selection visual feedback (use accent color + background)
- Clear all playlist caches after manual mapping (matched, ordered, items)
- Strip [S] suffix from titles/artists/albums when searching for lyrics
- Add [S] suffix to artist and album names when song has [S] for consistency
- Ensures external tracks are clearly marked across all metadata fields

All 225 tests pass.
2026-02-04 17:31:56 -05:00
3403f7a8c9 fix: remove orphaned code causing JavaScript syntax error
Removed duplicate/orphaned lines after searchProvider() function that were
causing 'expected expression, got }' syntax error in admin UI.
2026-02-04 17:06:24 -05:00
3e5c57766b feat: pre-build playlist cache and make matching interval configurable
- Pre-build playlist items cache during track matching for instant serving
- Add PreBuildPlaylistItemsCacheAsync() to SpotifyTrackMatchingService
- Combines local Jellyfin tracks + external matched tracks in correct Spotify order
- Saves to both Redis and file cache for persistence across restarts
- Change matching interval from hardcoded 30 minutes to configurable (default: 24 hours)
- Add SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS environment variable
- Set to 0 to only run once on startup (manual trigger still works)
- Add endpoint usage files to .gitignore
- Update documentation in README and .env.example

Rationale: Spotify playlists like Discover Weekly update once per week,
so running every 24 hours is more than sufficient. Pre-building the cache
eliminates slow 'on the fly' playlist building.

All 225 tests pass.
2026-02-04 17:03:50 -05:00
24c6219189 Fix external track counting by checking matched tracks cache
- External tracks are injected on-the-fly, not stored in Jellyfin DB
- Check spotify:matched:ordered cache to get accurate external count
- Calculate external tracks as: total matched - local tracks
- This will properly show the two-color progress bar (green local + orange external)
- All 225 tests passing
2026-02-04 16:54:56 -05:00
ea21d5aa77 Add clickable search links and enhanced debug logging
- Made search query clickable - opens provider-specific search
- Added searchProvider() function with URLs for Deezer, Qobuz, SquidWTF
- Enhanced console logging to debug progress bar data
- Logs raw playlist data and calculated percentages
- Search link opens in new tab
2026-02-04 16:51:46 -05:00
ee84770397 Improve progress bar visibility and add debug logging
- Increased progress bar height from 8px to 12px for better visibility
- Changed colors to more vibrant shades (green #10b981, orange #f59e0b)
- Added console debug logging for playlist stats
- Shows local (green) and external (orange) track percentages side-by-side
2026-02-04 16:50:20 -05:00
7ccb660299 Add startup cache warming service
- Proactively loads all file caches into Redis on container startup
- Warms genre cache (30-day expiration)
- Warms playlist items cache (24-hour expiration)
- Logs warming progress and duration
- Ensures fast access immediately after restart
- Cleans up expired genre cache files automatically
- All 225 tests passing
2026-02-04 16:46:27 -05:00
0793c4614b Add file-based caching for MusicBrainz genres
- Dual-layer caching: Redis (fast) + file system (persistent)
- File cache survives container restarts
- 30-day cache expiration for both layers
- Negative result caching to avoid repeated failed lookups
- Safe file names using base64 encoding
- Automatic cache restoration to Redis on startup
- Cache directory: /app/cache/genres
2026-02-04 16:44:35 -05:00
bf02dc5a57 Add MusicBrainz genre enrichment and improve track counting
- Fixed external track detection (check for provider prefix in ID)
- Added genre support to MusicBrainz service (inc=genres+tags)
- Created GenreEnrichmentService for async genre lookup with caching
- Show provider name and search query for external tracks in admin UI
- Display search query that will be used for external track streaming
- Aggregate playlist genres from track genres
- All 225 tests passing
2026-02-04 16:43:17 -05:00
7938871556 Release 1.0.0 - Production ready
- Fixed AdminController export/import .env endpoints (moved from ConfigUpdateRequest class)
- Added ArtistId and AlbumId to integration test fixtures
- All 225 tests passing
- Version set to 1.0.0 (semantic versioning)
- MusicBrainz service ready for future ISRC-based matching (1.1.0)
- Import/export handles full .env configuration with timestamped backups
2026-02-04 16:33:58 -05:00
39f6893741 Add MusicBrainz API integration for metadata enrichment
- Added MusicBrainzSettings model with username/password authentication
- Created MusicBrainzService with ISRC lookup and recording search
- Implements proper rate limiting (1 req/sec) per MusicBrainz rules
- Added meaningful User-Agent header as required
- Registered service in Program.cs with configuration
- Added MusicBrainz section to appsettings.json
- Credentials stored in .env (MUSICBRAINZ_USERNAME/PASSWORD)

Next: Add to admin UI and implement import/export for .env
2026-02-04 16:23:16 -05:00
cd4fd702fc Match Jellyfin response structure exactly based on real API responses
Verified against real Jellyfin responses for tracks, albums, artists, and playlists:
- Reordered fields to match Jellyfin's exact field order
- Added missing fields: PremiereDate, HasLyrics, Container, ETag, etc.
- Fixed MediaType to 'Unknown' for albums/artists (not null)
- Fixed UserData.Key format to match Jellyfin patterns
- Added ParentLogoItemId, ParentBackdropItemId for proper hierarchy
- Fixed Genres/GenreItems to always be arrays (never null)
- Added complete MediaStream structure with all Jellyfin fields
- Playlists now have MediaType='Audio' to match real playlists
- All responses now perfectly mimic real Jellyfin structure
2026-02-04 16:17:45 -05:00
038c3a9614 Fix playlist count caching and make external tracks perfectly mimic Jellyfin responses
- Fixed UpdateSpotifyPlaylistCounts to properly handle file cache without skipping items
- Added Genres and GenreItems fields to all tracks (empty array if no genre)
- Added complete MediaStreams with audio codec info for external tracks
- Added missing MediaSource fields: IgnoreDts, IgnoreIndex, GenPtsInput, HasSegments
- Ensured Artists array never contains null values
- All external tracks now have proper genre arrays to match Jellyfin structure
2026-02-04 16:12:41 -05:00
6e966f9e0d Fix nullability warnings in SpotifyTrackMatchingService 2026-02-04 16:10:16 -05:00
b778b3d31e Fix MediaSources null array fields and add logging for artist albums
- Added MediaStreams, MediaAttachments, Formats as empty arrays instead of null
- Added RunTimeTicks field to MediaSources
- Added detailed logging to GetExternalChildItems to debug artist album issues
- This should fix 'type Null is not a subtype of type List<dynamic>' error
2026-02-04 16:04:04 -05:00
526a079368 Fix compilation errors and nullability warnings
- Fixed LrclibService.GetLyricsAsync call to use empty string and 0 for duration
- Fixed nullability warnings in SpotifyTrackMatchingService by explicitly casting to nullable tuple
2026-02-04 15:40:52 -05:00
7a7b884af2 Update playlist progress bar to show stacked blue/yellow segments
- Blue segment shows local tracks percentage
- Yellow segment shows external matched tracks percentage
- Bar fills to 100% when all tracks are matched
- Added tooltips showing track counts on hover
2026-02-04 15:37:07 -05:00
6ab5e44112 Fix apostrophe normalization syntax error - use Unicode escape sequences 2026-02-04 15:33:59 -05:00
7c92515723 Fix null boolean error and playlist count showing 0 after restart
- Added all required boolean fields to MediaSources (IsRemote, IsInfiniteStream, RequiresOpening, etc)
- UpdateSpotifyPlaylistCounts now loads from file cache if Redis is empty
- This fixes 'type Null is not a subtype of type bool' error in Finamp
- Playlist counts now show correctly even after container restart
2026-02-04 15:32:18 -05:00
8091d30602 Add parallel provider racing for searches and lyrics pre-fetching
- Created ParallelMetadataService to race all providers and return fastest result
- Search now uses parallel service when available for lower latency
- Pre-fetch LRCLib lyrics for top 3 search results in background
- FuzzyMatcher already handles apostrophe normalization (applied everywhere)
2026-02-04 15:29:56 -05:00
18 changed files with 2576 additions and 215 deletions

View File

@@ -126,6 +126,13 @@ SPOTIFY_IMPORT_SYNC_START_MINUTE=15
# Example: If plugin runs at 4:15 PM and window is 2 hours, checks from 4:00 PM to 6:00 PM
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
# Matching interval: How often to run track matching (in hours)
# Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly
# Most playlists don't change frequently, so running once per day is reasonable
# Set to 0 to only run once on startup (manual trigger via admin UI still works)
# Default: 24 hours
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=24
# Playlists configuration (JSON ARRAY FORMAT - managed by web UI)
# Format: [["PlaylistName","SpotifyPlaylistId","first|last"],...]
# - PlaylistName: Name as it appears in Jellyfin

7
.gitignore vendored
View File

@@ -88,6 +88,13 @@ apis/*.md
apis/*.json
!apis/jellyfin-openapi-stable.json
# Log files for debugging
apis/*.log
# Endpoint usage tracking
apis/endpoint-usage.json
/app/cache/endpoint-usage/
# Original source code for reference
originals/

View File

@@ -401,11 +401,14 @@ SPOTIFY_IMPORT_PLAYLIST_NAMES=Release Radar,Discover Weekly
- Caches the list of missing tracks in Redis + file cache
- Runs automatically on startup (if needed) and every 5 minutes during the sync window
3. **Allstarr Matches Tracks** (2 minutes after startup, then every 30 minutes)
3. **Allstarr Matches Tracks** (2 minutes after startup, then configurable interval)
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
- Uses fuzzy matching to find the best match (title + artist similarity)
- Rate-limited to avoid overwhelming the service (150ms delay between searches)
- Caches matched results for 1 hour
- **Pre-builds playlist items cache** for instant serving (no "on the fly" building)
- Default interval: 24 hours (configurable via `SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS`)
- Set to 0 to only run once on startup (manual trigger via admin UI still works)
4. **You Open the Playlist in Jellyfin**
- Allstarr intercepts the request

View File

@@ -0,0 +1,334 @@
using System.Text.Json;
using Xunit;
using allstarr.Models.Domain;
using allstarr.Services.Jellyfin;
namespace allstarr.Tests;
/// <summary>
/// Integration tests to verify Jellyfin response structure matches real API responses.
/// </summary>
public class JellyfinResponseStructureTests
{
private readonly JellyfinResponseBuilder _builder;
public JellyfinResponseStructureTests()
{
_builder = new JellyfinResponseBuilder();
}
[Fact]
public void Track_Response_Should_Have_All_Required_Fields()
{
// Arrange
var song = new Song
{
Id = "test-id",
Title = "Test Song",
Artist = "Test Artist",
ArtistId = "artist-id",
Album = "Test Album",
AlbumId = "album-id",
Duration = 180,
Year = 2024,
Track = 1,
Genre = "Pop",
IsLocal = false,
ExternalProvider = "Deezer",
ExternalId = "123456"
};
// Act
var result = _builder.ConvertSongToJellyfinItem(song);
// Assert - Required top-level fields
Assert.NotNull(result["Name"]);
Assert.NotNull(result["ServerId"]);
Assert.NotNull(result["Id"]);
Assert.NotNull(result["Type"]);
Assert.Equal("Audio", result["Type"]);
Assert.NotNull(result["MediaType"]);
Assert.Equal("Audio", result["MediaType"]);
// Assert - Metadata fields
Assert.NotNull(result["Container"]);
Assert.Equal("flac", result["Container"]);
Assert.NotNull(result["HasLyrics"]);
Assert.False((bool)result["HasLyrics"]!);
// Assert - Genres (must be array, never null)
Assert.NotNull(result["Genres"]);
Assert.IsType<string[]>(result["Genres"]);
Assert.NotNull(result["GenreItems"]);
Assert.IsAssignableFrom<System.Collections.IEnumerable>(result["GenreItems"]);
// Assert - UserData
Assert.NotNull(result["UserData"]);
var userData = result["UserData"] as Dictionary<string, object>;
Assert.NotNull(userData);
Assert.Contains("ItemId", userData.Keys);
Assert.Contains("Key", userData.Keys);
// Assert - Image fields
Assert.NotNull(result["ImageTags"]);
Assert.NotNull(result["BackdropImageTags"]);
Assert.NotNull(result["ImageBlurHashes"]);
// Assert - Location
Assert.NotNull(result["LocationType"]);
Assert.Equal("FileSystem", result["LocationType"]);
// Assert - Parent references
Assert.NotNull(result["ParentLogoItemId"]);
Assert.NotNull(result["ParentBackdropItemId"]);
Assert.NotNull(result["ParentBackdropImageTags"]);
}
[Fact]
public void Track_MediaSources_Should_Have_Complete_Structure()
{
// Arrange
var song = new Song
{
Id = "test-id",
Title = "Test Song",
Artist = "Test Artist",
Album = "Test Album",
Duration = 180,
IsLocal = false,
ExternalProvider = "Deezer",
ExternalId = "123456"
};
// Act
var result = _builder.ConvertSongToJellyfinItem(song);
// Assert - MediaSources exists
Assert.NotNull(result["MediaSources"]);
var mediaSources = result["MediaSources"] as object[];
Assert.NotNull(mediaSources);
Assert.Single(mediaSources);
var mediaSource = mediaSources[0] as Dictionary<string, object?>;
Assert.NotNull(mediaSource);
// Assert - Required MediaSource fields
Assert.Contains("Protocol", mediaSource.Keys);
Assert.Contains("Id", mediaSource.Keys);
Assert.Contains("Path", mediaSource.Keys);
Assert.Contains("Type", mediaSource.Keys);
Assert.Contains("Container", mediaSource.Keys);
Assert.Contains("Bitrate", mediaSource.Keys);
Assert.Contains("ETag", mediaSource.Keys);
Assert.Contains("RunTimeTicks", mediaSource.Keys);
// Assert - Boolean flags
Assert.Contains("IsRemote", mediaSource.Keys);
Assert.Contains("IsInfiniteStream", mediaSource.Keys);
Assert.Contains("RequiresOpening", mediaSource.Keys);
Assert.Contains("RequiresClosing", mediaSource.Keys);
Assert.Contains("RequiresLooping", mediaSource.Keys);
Assert.Contains("SupportsProbing", mediaSource.Keys);
Assert.Contains("SupportsTranscoding", mediaSource.Keys);
Assert.Contains("SupportsDirectStream", mediaSource.Keys);
Assert.Contains("SupportsDirectPlay", mediaSource.Keys);
Assert.Contains("ReadAtNativeFramerate", mediaSource.Keys);
Assert.Contains("IgnoreDts", mediaSource.Keys);
Assert.Contains("IgnoreIndex", mediaSource.Keys);
Assert.Contains("GenPtsInput", mediaSource.Keys);
Assert.Contains("UseMostCompatibleTranscodingProfile", mediaSource.Keys);
Assert.Contains("HasSegments", mediaSource.Keys);
// Assert - Arrays (must not be null)
Assert.Contains("MediaStreams", mediaSource.Keys);
Assert.NotNull(mediaSource["MediaStreams"]);
Assert.Contains("MediaAttachments", mediaSource.Keys);
Assert.NotNull(mediaSource["MediaAttachments"]);
Assert.Contains("Formats", mediaSource.Keys);
Assert.NotNull(mediaSource["Formats"]);
Assert.Contains("RequiredHttpHeaders", mediaSource.Keys);
Assert.NotNull(mediaSource["RequiredHttpHeaders"]);
// Assert - Other fields
Assert.Contains("TranscodingSubProtocol", mediaSource.Keys);
Assert.Contains("DefaultAudioStreamIndex", mediaSource.Keys);
}
[Fact]
public void Track_MediaStreams_Should_Have_Complete_Audio_Stream()
{
// Arrange
var song = new Song
{
Id = "test-id",
Title = "Test Song",
Artist = "Test Artist",
IsLocal = false,
ExternalProvider = "Deezer"
};
// Act
var result = _builder.ConvertSongToJellyfinItem(song);
var mediaSources = result["MediaSources"] as object[];
var mediaSource = mediaSources![0] as Dictionary<string, object?>;
var mediaStreams = mediaSource!["MediaStreams"] as object[];
// Assert
Assert.NotNull(mediaStreams);
Assert.Single(mediaStreams);
var audioStream = mediaStreams[0] as Dictionary<string, object?>;
Assert.NotNull(audioStream);
// Assert - Required audio stream fields
Assert.Contains("Codec", audioStream.Keys);
Assert.Equal("flac", audioStream["Codec"]);
Assert.Contains("Type", audioStream.Keys);
Assert.Equal("Audio", audioStream["Type"]);
Assert.Contains("BitRate", audioStream.Keys);
Assert.Contains("Channels", audioStream.Keys);
Assert.Contains("SampleRate", audioStream.Keys);
Assert.Contains("BitDepth", audioStream.Keys);
Assert.Contains("ChannelLayout", audioStream.Keys);
Assert.Contains("TimeBase", audioStream.Keys);
Assert.Contains("DisplayTitle", audioStream.Keys);
// Assert - Video-related fields (required even for audio)
Assert.Contains("VideoRange", audioStream.Keys);
Assert.Contains("VideoRangeType", audioStream.Keys);
Assert.Contains("AudioSpatialFormat", audioStream.Keys);
// Assert - Localization
Assert.Contains("LocalizedDefault", audioStream.Keys);
Assert.Contains("LocalizedExternal", audioStream.Keys);
// Assert - Boolean flags
Assert.Contains("IsInterlaced", audioStream.Keys);
Assert.Contains("IsAVC", audioStream.Keys);
Assert.Contains("IsDefault", audioStream.Keys);
Assert.Contains("IsForced", audioStream.Keys);
Assert.Contains("IsHearingImpaired", audioStream.Keys);
Assert.Contains("IsExternal", audioStream.Keys);
Assert.Contains("IsTextSubtitleStream", audioStream.Keys);
Assert.Contains("SupportsExternalStream", audioStream.Keys);
// Assert - Index and Level
Assert.Contains("Index", audioStream.Keys);
Assert.Contains("Level", audioStream.Keys);
}
[Fact]
public void Album_Response_Should_Have_All_Required_Fields()
{
// Arrange
var album = new Album
{
Id = "album-id",
Title = "Test Album",
Artist = "Test Artist",
Year = 2024,
Genre = "Rock",
IsLocal = false,
ExternalProvider = "Deezer"
};
// Act
var result = _builder.ConvertAlbumToJellyfinItem(album);
// Assert
Assert.NotNull(result["Name"]);
Assert.NotNull(result["ServerId"]);
Assert.NotNull(result["Id"]);
Assert.NotNull(result["Type"]);
Assert.Equal("MusicAlbum", result["Type"]);
Assert.True((bool)result["IsFolder"]!);
Assert.NotNull(result["MediaType"]);
Assert.Equal("Unknown", result["MediaType"]);
// Assert - Genres
Assert.NotNull(result["Genres"]);
Assert.IsType<string[]>(result["Genres"]);
Assert.NotNull(result["GenreItems"]);
// Assert - Artists
Assert.NotNull(result["Artists"]);
Assert.NotNull(result["ArtistItems"]);
Assert.NotNull(result["AlbumArtist"]);
Assert.NotNull(result["AlbumArtists"]);
// Assert - Parent references
Assert.NotNull(result["ParentLogoItemId"]);
Assert.NotNull(result["ParentBackdropItemId"]);
Assert.NotNull(result["ParentLogoImageTag"]);
}
[Fact]
public void Artist_Response_Should_Have_All_Required_Fields()
{
// Arrange
var artist = new Artist
{
Id = "artist-id",
Name = "Test Artist",
AlbumCount = 5,
IsLocal = false,
ExternalProvider = "Deezer"
};
// Act
var result = _builder.ConvertArtistToJellyfinItem(artist);
// Assert
Assert.NotNull(result["Name"]);
Assert.NotNull(result["ServerId"]);
Assert.NotNull(result["Id"]);
Assert.NotNull(result["Type"]);
Assert.Equal("MusicArtist", result["Type"]);
Assert.True((bool)result["IsFolder"]!);
Assert.NotNull(result["MediaType"]);
Assert.Equal("Unknown", result["MediaType"]);
// Assert - Genres (empty array for artists)
Assert.NotNull(result["Genres"]);
Assert.IsType<string[]>(result["Genres"]);
Assert.NotNull(result["GenreItems"]);
// Assert - Album count
Assert.NotNull(result["AlbumCount"]);
Assert.Equal(5, result["AlbumCount"]);
// Assert - RunTimeTicks
Assert.NotNull(result["RunTimeTicks"]);
Assert.Equal(0, result["RunTimeTicks"]);
}
[Fact]
public void All_Entities_Should_Have_UserData_With_ItemId()
{
// Arrange
var song = new Song { Id = "song-id", Title = "Test", Artist = "Test" };
var album = new Album { Id = "album-id", Title = "Test", Artist = "Test" };
var artist = new Artist { Id = "artist-id", Name = "Test" };
// Act
var songResult = _builder.ConvertSongToJellyfinItem(song);
var albumResult = _builder.ConvertAlbumToJellyfinItem(album);
var artistResult = _builder.ConvertArtistToJellyfinItem(artist);
// Assert
var songUserData = songResult["UserData"] as Dictionary<string, object>;
Assert.NotNull(songUserData);
Assert.Contains("ItemId", songUserData.Keys);
Assert.Equal("song-id", songUserData["ItemId"]);
var albumUserData = albumResult["UserData"] as Dictionary<string, object>;
Assert.NotNull(albumUserData);
Assert.Contains("ItemId", albumUserData.Keys);
Assert.Equal("album-id", albumUserData["ItemId"]);
var artistUserData = artistResult["UserData"] as Dictionary<string, object>;
Assert.NotNull(artistUserData);
Assert.Contains("ItemId", artistUserData.Keys);
Assert.Equal("artist-id", artistUserData["ItemId"]);
}
}

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Spotify;
using allstarr.Services.Jellyfin;
using allstarr.Services.Common;
@@ -28,6 +29,7 @@ public class AdminController : ControllerBase
private readonly DeezerSettings _deezerSettings;
private readonly QobuzSettings _qobuzSettings;
private readonly SquidWTFSettings _squidWtfSettings;
private readonly MusicBrainzSettings _musicBrainzSettings;
private readonly SpotifyApiClient _spotifyClient;
private readonly SpotifyPlaylistFetcher _playlistFetcher;
private readonly SpotifyTrackMatchingService? _matchingService;
@@ -47,6 +49,7 @@ public class AdminController : ControllerBase
IOptions<DeezerSettings> deezerSettings,
IOptions<QobuzSettings> qobuzSettings,
IOptions<SquidWTFSettings> squidWtfSettings,
IOptions<MusicBrainzSettings> musicBrainzSettings,
SpotifyApiClient spotifyClient,
SpotifyPlaylistFetcher playlistFetcher,
RedisCacheService cache,
@@ -62,6 +65,7 @@ public class AdminController : ControllerBase
_deezerSettings = deezerSettings.Value;
_qobuzSettings = qobuzSettings.Value;
_squidWtfSettings = squidWtfSettings.Value;
_musicBrainzSettings = musicBrainzSettings.Value;
_spotifyClient = spotifyClient;
_playlistFetcher = playlistFetcher;
_matchingService = matchingService;
@@ -243,49 +247,156 @@ public class AdminController : ControllerBase
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
{
var localCount = 0;
var externalMatchedCount = 0;
// Get Spotify tracks to match against
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
// Count local vs external tracks
foreach (var item in items.EnumerateArray())
// Try to use the pre-built playlist cache first (includes manual mappings!)
var playlistItemsCacheKey = $"spotify:playlist:items:{config.Name}";
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
{
// Check if track has a real file path (local) or is external
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
pathProp.ValueKind == JsonValueKind.String &&
!string.IsNullOrEmpty(pathProp.GetString());
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
}
catch (Exception cacheEx)
{
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name);
}
_logger.LogInformation("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}",
config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
{
// Use the pre-built cache which respects manual mappings
var localCount = 0;
var externalCount = 0;
if (hasPath)
foreach (var item in cachedPlaylistItems)
{
var pathStr = pathProp.GetString()!;
// Local tracks have filesystem paths starting with / or containing :\
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
// Check if it's external by looking for ProviderIds (external songs have this)
var isExternal = false;
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
{
// Has ProviderIds = external track
isExternal = true;
}
if (isExternal)
{
externalCount++;
}
else
{
localCount++;
}
}
var externalMissingCount = spotifyTracks.Count - cachedPlaylistItems.Count;
if (externalMissingCount < 0) externalMissingCount = 0;
playlistInfo["localTracks"] = localCount;
playlistInfo["externalMatched"] = externalCount;
playlistInfo["externalMissing"] = externalMissingCount;
playlistInfo["externalTotal"] = externalCount + externalMissingCount;
playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count;
_logger.LogInformation("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing",
config.Name, spotifyTracks.Count, localCount, externalCount, externalMissingCount);
}
else
{
// Fallback: Build list of local tracks from Jellyfin (match by name only)
var localTracks = new List<(string Title, string Artist)>();
foreach (var item in items.EnumerateArray())
{
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
if (!string.IsNullOrEmpty(title))
{
localTracks.Add((title, artist));
}
}
// Get matched external tracks cache once
var matchedTracksKey = $"spotify:matched:ordered:{config.Name}";
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
var matchedSpotifyIds = new HashSet<string>(
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
);
var localCount = 0;
var externalMatchedCount = 0;
var externalMissingCount = 0;
// Match each Spotify track to determine if it's local, external, or missing
foreach (var track in spotifyTracks)
{
var isLocal = false;
if (localTracks.Count > 0)
{
var bestMatch = localTracks
.Select(local => new
{
Local = local,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
})
.Select(x => new
{
x.Local,
x.TitleScore,
x.ArtistScore,
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
})
.OrderByDescending(x => x.TotalScore)
.FirstOrDefault();
// Use 70% threshold (same as playback matching)
if (bestMatch != null && bestMatch.TotalScore >= 70)
{
isLocal = true;
}
}
if (isLocal)
{
localCount++;
}
else
{
// External track (downloaded from Deezer/Qobuz/etc)
externalMatchedCount++;
// Check if external track is matched
if (matchedSpotifyIds.Contains(track.SpotifyId))
{
externalMatchedCount++;
}
else
{
externalMissingCount++;
}
}
}
else
{
// No path means external
externalMatchedCount++;
}
playlistInfo["localTracks"] = localCount;
playlistInfo["externalMatched"] = externalMatchedCount;
playlistInfo["externalMissing"] = externalMissingCount;
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
_logger.LogDebug("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing",
config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount);
}
var totalInJellyfin = localCount + externalMatchedCount;
var externalMissingCount = Math.Max(0, spotifyTrackCount - totalInJellyfin);
playlistInfo["localTracks"] = localCount;
playlistInfo["externalMatched"] = externalMatchedCount;
playlistInfo["externalMissing"] = externalMissingCount;
playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount;
playlistInfo["totalInJellyfin"] = totalInJellyfin;
_logger.LogDebug("Playlist {Name}: {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing",
config.Name, spotifyTrackCount, localCount, externalMatchedCount, externalMissingCount);
}
else
{
@@ -383,8 +494,20 @@ public class AdminController : ControllerBase
{
var isLocal = false;
if (localTracks.Count > 0)
// FIRST: Check for manual mapping (same as SpotifyTrackMatchingService)
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
{
// Manual mapping exists - this track is definitely local
isLocal = true;
_logger.LogDebug("✓ Manual mapping found for {Title}: Jellyfin ID {Id}",
track.Title, manualJellyfinId);
}
else if (localTracks.Count > 0)
{
// SECOND: No manual mapping, try fuzzy matching
var bestMatch = localTracks
.Select(local => new
{
@@ -419,7 +542,10 @@ public class AdminController : ControllerBase
spotifyId = track.SpotifyId,
durationMs = track.DurationMs,
albumArtUrl = track.AlbumArtUrl,
isLocal = isLocal
isLocal = isLocal,
// For external tracks, show what will be searched
externalProvider = isLocal ? null : _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
searchQuery = isLocal ? null : $"{track.Title} {track.PrimaryArtist}"
});
}
@@ -453,7 +579,9 @@ public class AdminController : ControllerBase
spotifyId = t.SpotifyId,
durationMs = t.DurationMs,
albumArtUrl = t.AlbumArtUrl,
isLocal = (bool?)null // Unknown
isLocal = (bool?)null, // Unknown
externalProvider = _configuration.GetValue<string>("Subsonic:MusicService") ?? _configuration.GetValue<string>("Jellyfin:MusicService") ?? "Deezer",
searchQuery = $"{t.Title} {t.PrimaryArtist}"
})
});
}
@@ -508,13 +636,25 @@ public class AdminController : ControllerBase
try
{
var userId = _jellyfinSettings.UserId;
// Build URL with UserId if available
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
if (!string.IsNullOrEmpty(userId))
{
url += $"&UserId={userId}";
}
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
_logger.LogDebug("Searching Jellyfin: {Url}", url);
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" });
}
@@ -526,6 +666,14 @@ public class AdminController : ControllerBase
{
foreach (var item in items.EnumerateArray())
{
// Verify it's actually an Audio item
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
if (type != "Audio")
{
_logger.LogDebug("Skipping non-audio item: {Type}", type);
continue;
}
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
@@ -566,20 +714,41 @@ public class AdminController : ControllerBase
try
{
var userId = _jellyfinSettings.UserId;
var url = $"{_jellyfinSettings.Url}/Items/{id}";
if (!string.IsNullOrEmpty(userId))
{
url += $"?UserId={userId}";
}
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
_logger.LogDebug("Fetching Jellyfin track {Id} from {Url}", id, url);
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
return StatusCode((int)response.StatusCode, new { error = "Track not found" });
var errorBody = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}",
id, response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" });
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var item = doc.RootElement;
// Verify it's an Audio item
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
if (type != "Audio")
{
_logger.LogWarning("Item {Id} is not an Audio track, it's a {Type}", id, type);
return BadRequest(new { error = $"Item is not an audio track (it's a {type})" });
}
var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
@@ -594,6 +763,8 @@ public class AdminController : ControllerBase
artist = albumArtistEl.GetString() ?? "";
}
_logger.LogInformation("Found Jellyfin track: {Title} by {Artist}", title, artist);
return Ok(new { id = trackId, title, artist, album });
}
catch (Exception ex)
@@ -625,11 +796,103 @@ public class AdminController : ControllerBase
_logger.LogInformation("Manual mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
decodedName, request.SpotifyId, request.JellyfinId);
// Clear the matched tracks cache to force re-matching
var cacheKey = $"spotify:matched:{decodedName}";
await _cache.DeleteAsync(cacheKey);
// Clear all related caches to force rebuild
var matchedCacheKey = $"spotify:matched:{decodedName}";
var orderedCacheKey = $"spotify:matched:ordered:{decodedName}";
var playlistItemsKey = $"spotify:playlist:items:{decodedName}";
return Ok(new { message = "Mapping saved successfully" });
await _cache.DeleteAsync(matchedCacheKey);
await _cache.DeleteAsync(orderedCacheKey);
await _cache.DeleteAsync(playlistItemsKey);
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
// Fetch the mapped Jellyfin track details to return to the UI
string? trackTitle = null;
string? trackArtist = null;
string? trackAlbum = null;
try
{
var userId = _jellyfinSettings.UserId;
var trackUrl = $"{_jellyfinSettings.Url}/Items/{request.JellyfinId}";
if (!string.IsNullOrEmpty(userId))
{
trackUrl += $"?UserId={userId}";
}
var trackRequest = new HttpRequestMessage(HttpMethod.Get, trackUrl);
trackRequest.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
var response = await _jellyfinHttpClient.SendAsync(trackRequest);
if (response.IsSuccessStatusCode)
{
var trackData = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(trackData);
var track = doc.RootElement;
trackTitle = track.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : null;
trackArtist = track.TryGetProperty("AlbumArtist", out var artistEl) ? artistEl.GetString() :
(track.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0
? artistsEl[0].GetString() : null);
trackAlbum = track.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : null;
}
else
{
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode}", request.JellyfinId, response.StatusCode);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch mapped track details, but mapping was saved");
}
// Trigger immediate playlist rebuild with the new mapping
if (_matchingService != null)
{
_logger.LogInformation("Triggering immediate playlist rebuild for {Playlist} with new manual mapping", decodedName);
// Run rebuild in background with timeout to avoid blocking the response
_ = Task.Run(async () =>
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); // 2 minute timeout
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
_logger.LogInformation("✓ Playlist {Playlist} rebuilt successfully with manual mapping", decodedName);
}
catch (OperationCanceledException)
{
_logger.LogWarning("Playlist rebuild for {Playlist} timed out after 2 minutes", decodedName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to rebuild playlist {Playlist} after manual mapping", decodedName);
}
});
}
else
{
_logger.LogWarning("Matching service not available - playlist will rebuild on next scheduled run");
}
// Return success with track details if available
var mappedTrack = new
{
id = request.JellyfinId,
title = trackTitle ?? "Unknown",
artist = trackArtist ?? "Unknown",
album = trackAlbum ?? "Unknown",
isLocal = true
};
return Ok(new
{
message = "Mapping saved and playlist rebuild triggered",
track = mappedTrack,
rebuildTriggered = _matchingService != null
});
}
catch (Exception ex)
{
@@ -721,6 +984,14 @@ public class AdminController : ControllerBase
squidWtf = new
{
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
},
musicBrainz = new
{
enabled = _musicBrainzSettings.Enabled,
username = _musicBrainzSettings.Username ?? "(not set)",
password = MaskValue(_musicBrainzSettings.Password),
baseUrl = _musicBrainzSettings.BaseUrl,
rateLimitMs = _musicBrainzSettings.RateLimitMs
}
});
}
@@ -1551,6 +1822,85 @@ public class AdminController : ControllerBase
// Only allow alphanumeric, underscore, and must start with letter/underscore
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
}
/// <summary>
/// Export .env file for backup/transfer
/// </summary>
[HttpGet("export-env")]
public IActionResult ExportEnv()
{
try
{
if (!System.IO.File.Exists(_envFilePath))
{
return NotFound(new { error = ".env file not found" });
}
var envContent = System.IO.File.ReadAllText(_envFilePath);
var bytes = System.Text.Encoding.UTF8.GetBytes(envContent);
return File(bytes, "text/plain", ".env");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export .env file");
return StatusCode(500, new { error = "Failed to export .env file", details = ex.Message });
}
}
/// <summary>
/// Import .env file from upload
/// </summary>
[HttpPost("import-env")]
public async Task<IActionResult> ImportEnv([FromForm] IFormFile file)
{
if (file == null || file.Length == 0)
{
return BadRequest(new { error = "No file provided" });
}
if (!file.FileName.EndsWith(".env"))
{
return BadRequest(new { error = "File must be a .env file" });
}
try
{
// Read uploaded file
using var reader = new StreamReader(file.OpenReadStream());
var content = await reader.ReadToEndAsync();
// Validate it's a valid .env file (basic check)
if (string.IsNullOrWhiteSpace(content))
{
return BadRequest(new { error = ".env file is empty" });
}
// Backup existing .env
if (System.IO.File.Exists(_envFilePath))
{
var backupPath = $"{_envFilePath}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}";
System.IO.File.Copy(_envFilePath, backupPath, true);
_logger.LogInformation("Backed up existing .env to {BackupPath}", backupPath);
}
// Write new .env file
await System.IO.File.WriteAllTextAsync(_envFilePath, content);
_logger.LogInformation(".env file imported successfully");
return Ok(new
{
success = true,
message = ".env file imported successfully. Restart the application for changes to take effect."
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to import .env file");
return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message });
}
}
}
public class ConfigUpdateRequest

View File

@@ -29,6 +29,7 @@ public class JellyfinController : ControllerBase
private readonly SpotifyImportSettings _spotifySettings;
private readonly SpotifyApiSettings _spotifyApiSettings;
private readonly IMusicMetadataService _metadataService;
private readonly ParallelMetadataService? _parallelMetadataService;
private readonly ILocalLibraryService _localLibraryService;
private readonly IDownloadService _downloadService;
private readonly JellyfinResponseBuilder _responseBuilder;
@@ -38,6 +39,7 @@ public class JellyfinController : ControllerBase
private readonly PlaylistSyncService? _playlistSyncService;
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
private readonly SpotifyLyricsService? _spotifyLyricsService;
private readonly LrclibService? _lrclibService;
private readonly RedisCacheService _cache;
private readonly ILogger<JellyfinController> _logger;
@@ -54,14 +56,17 @@ public class JellyfinController : ControllerBase
JellyfinSessionManager sessionManager,
RedisCacheService cache,
ILogger<JellyfinController> logger,
ParallelMetadataService? parallelMetadataService = null,
PlaylistSyncService? playlistSyncService = null,
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
SpotifyLyricsService? spotifyLyricsService = null)
SpotifyLyricsService? spotifyLyricsService = null,
LrclibService? lrclibService = null)
{
_settings = settings.Value;
_spotifySettings = spotifySettings.Value;
_spotifyApiSettings = spotifyApiSettings.Value;
_metadataService = metadataService;
_parallelMetadataService = parallelMetadataService;
_localLibraryService = localLibraryService;
_downloadService = downloadService;
_responseBuilder = responseBuilder;
@@ -71,6 +76,7 @@ public class JellyfinController : ControllerBase
_playlistSyncService = playlistSyncService;
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
_spotifyLyricsService = spotifyLyricsService;
_lrclibService = lrclibService;
_cache = cache;
_logger = logger;
@@ -241,7 +247,11 @@ public class JellyfinController : ControllerBase
// Run local and external searches in parallel
var itemTypes = ParseItemTypes(includeItemTypes);
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers);
var externalTask = _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
// Use parallel metadata service if available (races providers), otherwise use primary
var externalTask = _parallelMetadataService != null
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit)
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit);
var playlistTask = _settings.EnableExternalPlaylists
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit)
@@ -312,6 +322,39 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}",
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
// Pre-fetch lyrics for top 3 songs in background (don't await)
if (_lrclibService != null && mergedSongs.Count > 0)
{
_ = Task.Run(async () =>
{
try
{
var top3 = mergedSongs.Take(3).ToList();
_logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} search results", top3.Count);
foreach (var songItem in top3)
{
if (songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl &&
songItem.TryGetValue("Artists", out var artistsObj) && artistsObj is JsonElement artistsEl &&
artistsEl.GetArrayLength() > 0)
{
var title = nameEl.GetString() ?? "";
var artist = artistsEl[0].GetString() ?? "";
if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist))
{
await _lrclibService.GetLyricsAsync(title, artist, "", 0);
}
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to pre-fetch lyrics for search results");
}
});
}
// Filter by item types if specified
var items = new List<Dictionary<string, object?>>();
@@ -555,9 +598,13 @@ public class JellyfinController : ControllerBase
{
var itemTypes = ParseItemTypes(includeItemTypes);
_logger.LogInformation("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}",
provider, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
// Check if asking for audio (album tracks)
if (itemTypes?.Contains("Audio") == true)
{
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
var album = await _metadataService.GetAlbumAsync(provider, externalId);
if (album == null)
{
@@ -568,9 +615,12 @@ public class JellyfinController : ControllerBase
}
// Otherwise assume it's artist albums
_logger.LogDebug("Fetching artist albums for {Provider}/{ExternalId}", provider, externalId);
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
var artist = await _metadataService.GetArtistAsync(provider, externalId);
_logger.LogInformation("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
// Fill artist info
if (artist != null)
{
@@ -1133,12 +1183,24 @@ public class JellyfinController : ControllerBase
return NotFound(new { error = "Song not found" });
}
// Strip [S] suffix from title, artist, and album for lyrics search
// The [S] tag is added to external tracks but shouldn't be used in lyrics queries
var searchTitle = song.Title.Replace(" [S]", "").Trim();
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
{
searchArtists.Add(searchArtist);
}
LyricsInfo? lyrics = null;
// Try Spotify lyrics first (better synced lyrics quality)
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled)
{
_logger.LogInformation("Trying Spotify lyrics for: {Artist} - {Title}", song.Artist, song.Title);
_logger.LogInformation("Trying Spotify lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
SpotifyLyricsResult? spotifyLyrics = null;
@@ -1149,18 +1211,18 @@ public class JellyfinController : ControllerBase
}
else
{
// Search by metadata
// Search by metadata (without [S] tags)
spotifyLyrics = await _spotifyLyricsService.SearchAndGetLyricsAsync(
song.Title,
song.Artists.Count > 0 ? song.Artists[0] : song.Artist ?? "",
song.Album,
searchTitle,
searchArtists.Count > 0 ? searchArtists[0] : searchArtist,
searchAlbum,
song.Duration.HasValue ? song.Duration.Value * 1000 : null);
}
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
song.Artist, song.Title, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
}
}
@@ -1169,15 +1231,15 @@ public class JellyfinController : ControllerBase
if (lyrics == null)
{
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
song.Artists.Count > 0 ? string.Join(", ", song.Artists) : song.Artist,
song.Title);
string.Join(", ", searchArtists),
searchTitle);
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
if (lrclibService != null)
{
lyrics = await lrclibService.GetLyricsAsync(
song.Title,
song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist ?? "" },
song.Album ?? "",
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
}
}
@@ -2701,7 +2763,7 @@ public class JellyfinController : ControllerBase
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
_logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks",
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
matchedTracksKey, matchedTracks?.Count ?? 0);
// Fallback to legacy cache format
@@ -2716,54 +2778,71 @@ public class JellyfinController : ControllerBase
Position = i,
MatchedSong = s
}).ToList();
_logger.LogInformation("Loaded {Count} tracks from legacy cache", matchedTracks.Count);
_logger.LogDebug("Loaded {Count} tracks from legacy cache", matchedTracks.Count);
}
}
// Get local tracks count from Jellyfin
var localTracksCount = 0;
try
// Try loading from file cache if Redis is empty
if (matchedTracks == null || matchedTracks.Count == 0)
{
var (localTracksResponse, _) = await _proxyService.GetJsonAsync(
$"Playlists/{playlistId}/Items",
null,
Request.Headers);
if (localTracksResponse != null &&
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
if (fileItems != null && fileItems.Count > 0)
{
localTracksCount = localItems.GetArrayLength();
_logger.LogInformation("Found {Count} total items in Jellyfin playlist {Name}",
localTracksCount, playlistName);
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count);
// Use file cache count directly
itemDict["ChildCount"] = fileItems.Count;
modified = true;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get local tracks count for {Name}", playlistName);
}
// Count external matched tracks (not local)
var externalMatchedCount = 0;
if (matchedTracks != null)
// Only fetch from Jellyfin if we didn't get count from file cache
if (!itemDict.ContainsKey("ChildCount") || (int)itemDict["ChildCount"]! == 0)
{
externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
}
// Total available tracks = what's actually in Jellyfin (local + external matched)
// This is what clients should see as the track count
var totalAvailableCount = localTracksCount;
if (totalAvailableCount > 0)
{
// Update ChildCount to show actual available tracks
itemDict["ChildCount"] = totalAvailableCount;
modified = true;
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} (actual tracks in Jellyfin)",
playlistName, totalAvailableCount);
}
else
{
_logger.LogWarning("No tracks found in Jellyfin for {Name}", playlistName);
// Get local tracks count from Jellyfin
var localTracksCount = 0;
try
{
var (localTracksResponse, _) = await _proxyService.GetJsonAsync(
$"Playlists/{playlistId}/Items",
null,
Request.Headers);
if (localTracksResponse != null &&
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
{
localTracksCount = localItems.GetArrayLength();
_logger.LogInformation("Found {Count} total items in Jellyfin playlist {Name}",
localTracksCount, playlistName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get local tracks count for {Name}", playlistName);
}
// Count external matched tracks (not local)
var externalMatchedCount = 0;
if (matchedTracks != null)
{
externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
}
// Total available tracks = what's actually in Jellyfin (local + external matched)
// This is what clients should see as the track count
var totalAvailableCount = localTracksCount;
if (totalAvailableCount > 0)
{
// Update ChildCount to show actual available tracks
itemDict["ChildCount"] = totalAvailableCount;
modified = true;
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} (actual tracks in Jellyfin)",
playlistName, totalAvailableCount);
}
else
{
_logger.LogWarning("No tracks found in Jellyfin for {Name}", playlistName);
}
}
}
}

View File

@@ -0,0 +1,21 @@
namespace allstarr.Models.Settings;
/// <summary>
/// Settings for MusicBrainz API integration.
/// </summary>
public class MusicBrainzSettings
{
public bool Enabled { get; set; } = true;
public string? Username { get; set; }
public string? Password { get; set; }
/// <summary>
/// Base URL for MusicBrainz API.
/// </summary>
public string BaseUrl { get; set; } = "https://musicbrainz.org/ws/2";
/// <summary>
/// Rate limit: 1 request per second for unauthenticated, 1 per second for authenticated.
/// </summary>
public int RateLimitMs { get; set; } = 1000;
}

View File

@@ -80,6 +80,15 @@ public class SpotifyImportSettings
/// </summary>
public int SyncWindowHours { get; set; } = 2;
/// <summary>
/// How often to run track matching in hours.
/// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly.
/// Most playlists don't change frequently, so running every 24 hours is reasonable.
/// Set to 0 to only run once on startup (manual trigger via admin UI still works).
/// Default: 24 hours
/// </summary>
public int MatchingIntervalHours { get; set; } = 24;
/// <summary>
/// Combined playlist configuration as JSON array.
/// Format: [["Name","Id","first|last"],...]

View File

@@ -455,6 +455,9 @@ else if (musicService == MusicService.SquidWTF)
squidWtfApiUrls));
}
// Register ParallelMetadataService to race all registered providers for faster searches
builder.Services.AddSingleton<ParallelMetadataService>();
// Startup validation - register validators based on backend
if (backendType == BackendType.Jellyfin)
{
@@ -479,6 +482,9 @@ builder.Services.AddHostedService<StartupValidationOrchestrator>();
// Register cache cleanup service (only runs when StorageMode is Cache)
builder.Services.AddHostedService<CacheCleanupService>();
// Register cache warming service (loads file caches into Redis on startup)
builder.Services.AddHostedService<CacheWarmingService>();
// Register Spotify API client, lyrics service, and settings for direct API access
// Configure from environment variables with SPOTIFY_API_ prefix
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
@@ -553,6 +559,35 @@ builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracks
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
// Register MusicBrainz service for metadata enrichment
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
{
builder.Configuration.GetSection("MusicBrainz").Bind(options);
// Override from environment variables
var enabled = builder.Configuration.GetValue<string>("MusicBrainz:Enabled");
if (!string.IsNullOrEmpty(enabled))
{
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
}
var username = builder.Configuration.GetValue<string>("MusicBrainz:Username");
if (!string.IsNullOrEmpty(username))
{
options.Username = username;
}
var password = builder.Configuration.GetValue<string>("MusicBrainz:Password");
if (!string.IsNullOrEmpty(password))
{
options.Password = password;
}
});
builder.Services.AddSingleton<allstarr.Services.MusicBrainz.MusicBrainzService>();
// Register genre enrichment service
builder.Services.AddSingleton<allstarr.Services.Common.GenreEnrichmentService>();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>

View File

@@ -0,0 +1,172 @@
using System.Text.Json;
namespace allstarr.Services.Common;
/// <summary>
/// Background service that warms up Redis cache from file system on startup.
/// Ensures fast access to cached data after container restarts.
/// </summary>
public class CacheWarmingService : IHostedService
{
private readonly RedisCacheService _cache;
private readonly ILogger<CacheWarmingService> _logger;
private const string GenreCacheDirectory = "/app/cache/genres";
private const string PlaylistCacheDirectory = "/app/cache/spotify";
public CacheWarmingService(
RedisCacheService cache,
ILogger<CacheWarmingService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("🔥 Starting cache warming from file system...");
var startTime = DateTime.UtcNow;
var genresWarmed = 0;
var playlistsWarmed = 0;
try
{
// Warm genre cache
genresWarmed = await WarmGenreCacheAsync(cancellationToken);
// Warm playlist cache
playlistsWarmed = await WarmPlaylistCacheAsync(cancellationToken);
var duration = DateTime.UtcNow - startTime;
_logger.LogInformation(
"✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists",
duration.TotalSeconds, genresWarmed, playlistsWarmed);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to warm cache from file system");
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <summary>
/// Warms genre cache from file system.
/// </summary>
private async Task<int> WarmGenreCacheAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(GenreCacheDirectory))
{
return 0;
}
var files = Directory.GetFiles(GenreCacheDirectory, "*.json");
var warmedCount = 0;
foreach (var file in files)
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
// Check if cache is expired (30 days)
var fileInfo = new FileInfo(file);
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromDays(30))
{
File.Delete(file);
continue;
}
var json = await File.ReadAllTextAsync(file, cancellationToken);
var cacheEntry = JsonSerializer.Deserialize<GenreCacheEntry>(json);
if (cacheEntry != null && !string.IsNullOrEmpty(cacheEntry.CacheKey))
{
var redisKey = $"genre:{cacheEntry.CacheKey}";
await _cache.SetAsync(redisKey, cacheEntry.Genre, TimeSpan.FromDays(30));
warmedCount++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm genre cache from file: {File}", file);
}
}
if (warmedCount > 0)
{
_logger.LogInformation("🔥 Warmed {Count} genre entries from file cache", warmedCount);
}
return warmedCount;
}
/// <summary>
/// Warms playlist cache from file system.
/// </summary>
private async Task<int> WarmPlaylistCacheAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(PlaylistCacheDirectory))
{
return 0;
}
var files = Directory.GetFiles(PlaylistCacheDirectory, "*_items.json");
var warmedCount = 0;
foreach (var file in files)
{
if (cancellationToken.IsCancellationRequested)
break;
try
{
// Check if cache is expired (24 hours)
var fileInfo = new FileInfo(file);
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > TimeSpan.FromHours(24))
{
continue; // Don't delete, let the normal flow handle it
}
var json = await File.ReadAllTextAsync(file, cancellationToken);
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
if (items != null && items.Count > 0)
{
// Extract playlist name from filename
var fileName = Path.GetFileNameWithoutExtension(file);
var playlistName = fileName.Replace("_items", "");
var redisKey = $"spotify:playlist:items:{playlistName}";
await _cache.SetAsync(redisKey, items, TimeSpan.FromHours(24));
warmedCount++;
_logger.LogDebug("🔥 Warmed playlist cache for {Playlist} ({Count} items)",
playlistName, items.Count);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm playlist cache from file: {File}", file);
}
}
if (warmedCount > 0)
{
_logger.LogInformation("🔥 Warmed {Count} playlist caches from file system", warmedCount);
}
return warmedCount;
}
private class GenreCacheEntry
{
public string CacheKey { get; set; } = "";
public string Genre { get; set; } = "";
public DateTime CachedAt { get; set; }
}
}

View File

@@ -76,10 +76,10 @@ public static class FuzzyMatcher
// Normalize different apostrophe types to standard apostrophe
normalized = normalized
.Replace(''', '\'') // Right single quotation mark
.Replace(''', '\'') // Left single quotation mark
.Replace('`', '\'') // Grave accent
.Replace('´', '\''); // Acute accent
.Replace("\u2019", "'") // Right single quotation mark (')
.Replace("\u2018", "'") // Left single quotation mark (')
.Replace("`", "'") // Grave accent
.Replace("\u00B4", "'"); // Acute accent (´)
// Normalize whitespace
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");

View File

@@ -0,0 +1,228 @@
using allstarr.Models.Domain;
using allstarr.Services.MusicBrainz;
using allstarr.Services.Common;
using System.Text.Json;
namespace allstarr.Services.Common;
/// <summary>
/// Service for enriching songs and playlists with genre information from MusicBrainz.
/// </summary>
public class GenreEnrichmentService
{
private readonly MusicBrainzService _musicBrainz;
private readonly RedisCacheService _cache;
private readonly ILogger<GenreEnrichmentService> _logger;
private const string GenreCachePrefix = "genre:";
private const string GenreCacheDirectory = "/app/cache/genres";
private static readonly TimeSpan GenreCacheDuration = TimeSpan.FromDays(30);
public GenreEnrichmentService(
MusicBrainzService musicBrainz,
RedisCacheService cache,
ILogger<GenreEnrichmentService> logger)
{
_musicBrainz = musicBrainz;
_cache = cache;
_logger = logger;
// Ensure cache directory exists
Directory.CreateDirectory(GenreCacheDirectory);
}
/// <summary>
/// Enriches a song with genre information from MusicBrainz (with caching).
/// Updates the song's Genre property with the top genre.
/// </summary>
public async Task EnrichSongGenreAsync(Song song)
{
// Skip if song already has a genre
if (!string.IsNullOrEmpty(song.Genre))
{
return;
}
var cacheKey = $"{song.Title}:{song.Artist}";
// Check Redis cache first
var redisCacheKey = $"{GenreCachePrefix}{cacheKey}";
var cachedGenre = await _cache.GetAsync<string>(redisCacheKey);
if (cachedGenre != null)
{
song.Genre = cachedGenre;
_logger.LogDebug("Using Redis cached genre for {Title} - {Artist}: {Genre}",
song.Title, song.Artist, cachedGenre);
return;
}
// Check file cache
var fileCachedGenre = await GetFromFileCacheAsync(cacheKey);
if (fileCachedGenre != null)
{
song.Genre = fileCachedGenre;
// Restore to Redis cache
await _cache.SetAsync(redisCacheKey, fileCachedGenre, GenreCacheDuration);
_logger.LogDebug("Using file cached genre for {Title} - {Artist}: {Genre}",
song.Title, song.Artist, fileCachedGenre);
return;
}
// Fetch from MusicBrainz
try
{
var genres = await _musicBrainz.GetGenresForSongAsync(song.Title, song.Artist, song.Isrc);
if (genres.Count > 0)
{
// Use the top genre
song.Genre = genres[0];
// Cache in both Redis and file
await _cache.SetAsync(redisCacheKey, song.Genre, GenreCacheDuration);
await SaveToFileCacheAsync(cacheKey, song.Genre);
_logger.LogInformation("Enriched {Title} - {Artist} with genre: {Genre}",
song.Title, song.Artist, song.Genre);
}
else
{
// Cache negative result to avoid repeated lookups
await SaveToFileCacheAsync(cacheKey, "");
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enrich genre for {Title} - {Artist}",
song.Title, song.Artist);
}
}
/// <summary>
/// Enriches multiple songs with genre information (batch operation).
/// </summary>
public async Task EnrichSongsGenresAsync(List<Song> songs)
{
var tasks = songs
.Where(s => string.IsNullOrEmpty(s.Genre))
.Select(s => EnrichSongGenreAsync(s));
await Task.WhenAll(tasks);
}
/// <summary>
/// Aggregates genres from a list of songs to determine playlist genres.
/// Returns the top 5 most common genres.
/// </summary>
public List<string> AggregatePlaylistGenres(List<Song> songs)
{
var genreCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var song in songs)
{
if (!string.IsNullOrEmpty(song.Genre))
{
if (genreCounts.ContainsKey(song.Genre))
{
genreCounts[song.Genre]++;
}
else
{
genreCounts[song.Genre] = 1;
}
}
}
return genreCounts
.OrderByDescending(kvp => kvp.Value)
.Take(5)
.Select(kvp => kvp.Key)
.ToList();
}
/// <summary>
/// Gets genre from file cache.
/// </summary>
private async Task<string?> GetFromFileCacheAsync(string cacheKey)
{
try
{
var fileName = GetCacheFileName(cacheKey);
var filePath = Path.Combine(GenreCacheDirectory, fileName);
if (!File.Exists(filePath))
{
return null;
}
// Check if cache is expired (30 days)
var fileInfo = new FileInfo(filePath);
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc > GenreCacheDuration)
{
File.Delete(filePath);
return null;
}
var json = await File.ReadAllTextAsync(filePath);
var cacheEntry = JsonSerializer.Deserialize<GenreCacheEntry>(json);
return cacheEntry?.Genre;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read genre from file cache for {Key}", cacheKey);
return null;
}
}
/// <summary>
/// Saves genre to file cache.
/// </summary>
private async Task SaveToFileCacheAsync(string cacheKey, string genre)
{
try
{
var fileName = GetCacheFileName(cacheKey);
var filePath = Path.Combine(GenreCacheDirectory, fileName);
var cacheEntry = new GenreCacheEntry
{
CacheKey = cacheKey,
Genre = genre,
CachedAt = DateTime.UtcNow
};
var json = JsonSerializer.Serialize(cacheEntry, new JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(filePath, json);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save genre to file cache for {Key}", cacheKey);
}
}
/// <summary>
/// Generates a safe file name from cache key.
/// </summary>
private static string GetCacheFileName(string cacheKey)
{
// Use base64 encoding to create safe file names
var bytes = System.Text.Encoding.UTF8.GetBytes(cacheKey);
var base64 = Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.Replace("=", "");
return $"{base64}.json";
}
private class GenreCacheEntry
{
public string CacheKey { get; set; } = "";
public string Genre { get; set; } = "";
public DateTime CachedAt { get; set; }
}
}

View File

@@ -0,0 +1,135 @@
using allstarr.Models.Domain;
using allstarr.Models.Search;
namespace allstarr.Services.Common;
/// <summary>
/// Races multiple metadata providers in parallel and returns the fastest result.
/// Used for search operations to minimize latency.
/// </summary>
public class ParallelMetadataService
{
private readonly IEnumerable<IMusicMetadataService> _providers;
private readonly ILogger<ParallelMetadataService> _logger;
public ParallelMetadataService(
IEnumerable<IMusicMetadataService> providers,
ILogger<ParallelMetadataService> logger)
{
_providers = providers;
_logger = logger;
}
/// <summary>
/// Races all providers and returns the first successful result.
/// Falls back to next provider if first one fails.
/// </summary>
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
{
if (!_providers.Any())
{
_logger.LogWarning("No metadata providers available for parallel search");
return new SearchResult();
}
_logger.LogDebug("🏁 Racing {Count} providers for search: {Query}", _providers.Count(), query);
// Create tasks for all providers
var tasks = _providers.Select(async provider =>
{
var providerName = provider.GetType().Name;
try
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = await provider.SearchAllAsync(query, songLimit, albumLimit, artistLimit);
sw.Stop();
_logger.LogInformation("✅ {Provider} completed search in {Ms}ms ({Songs} songs, {Albums} albums, {Artists} artists)",
providerName, sw.ElapsedMilliseconds, result.Songs.Count, result.Albums.Count, result.Artists.Count);
return (Success: true, Result: result, Provider: providerName, ElapsedMs: sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "❌ {Provider} search failed", providerName);
return (Success: false, Result: new SearchResult(), Provider: providerName, ElapsedMs: 0L);
}
}).ToList();
// Wait for first successful result
while (tasks.Any())
{
var completedTask = await Task.WhenAny(tasks);
var result = await completedTask;
if (result.Success && (result.Result.Songs.Any() || result.Result.Albums.Any() || result.Result.Artists.Any()))
{
_logger.LogInformation("🏆 Using results from {Provider} ({Ms}ms) - fastest with results",
result.Provider, result.ElapsedMs);
return result.Result;
}
// Remove completed task and try next
tasks.Remove(completedTask);
}
// All providers failed or returned empty
_logger.LogWarning("⚠️ All providers failed or returned empty results for: {Query}", query);
return new SearchResult();
}
/// <summary>
/// Searches for a specific song by title and artist across all providers in parallel.
/// Returns the first successful match.
/// </summary>
public async Task<Song?> SearchSongAsync(string title, string artist, int limit = 5)
{
if (!_providers.Any())
{
return null;
}
_logger.LogDebug("🏁 Racing {Count} providers for song: {Title} - {Artist}", _providers.Count(), title, artist);
var tasks = _providers.Select(async provider =>
{
var providerName = provider.GetType().Name;
try
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var songs = await provider.SearchSongsAsync($"{title} {artist}", limit);
sw.Stop();
var bestMatch = songs.FirstOrDefault();
if (bestMatch != null)
{
_logger.LogInformation("✅ {Provider} found song in {Ms}ms", providerName, sw.ElapsedMilliseconds);
}
return (Success: bestMatch != null, Song: bestMatch, Provider: providerName, ElapsedMs: sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "❌ {Provider} song search failed", providerName);
return (Success: false, Song: (Song?)null, Provider: providerName, ElapsedMs: 0L);
}
}).ToList();
// Wait for first successful result
while (tasks.Any())
{
var completedTask = await Task.WhenAny(tasks);
var result = await completedTask;
if (result.Success && result.Song != null)
{
_logger.LogInformation("🏆 Using song from {Provider} ({Ms}ms)", result.Provider, result.ElapsedMs);
return result.Song;
}
tasks.Remove(completedTask);
}
return null;
}
}

View File

@@ -233,25 +233,74 @@ public class JellyfinResponseBuilder
{
// Add " [S]" suffix to external song titles (S = streaming source)
var songTitle = song.Title;
var artistName = song.Artist;
var albumName = song.Album;
var artistNames = song.Artists.ToList();
if (!song.IsLocal)
{
songTitle = $"{song.Title} [S]";
// Also add [S] to artist and album names for consistency
if (!string.IsNullOrEmpty(artistName) && !artistName.EndsWith(" [S]"))
{
artistName = $"{artistName} [S]";
}
if (!string.IsNullOrEmpty(albumName) && !albumName.EndsWith(" [S]"))
{
albumName = $"{albumName} [S]";
}
// Add [S] to all artist names in the list
artistNames = artistNames.Select(a =>
!string.IsNullOrEmpty(a) && !a.EndsWith(" [S]") ? $"{a} [S]" : a
).ToList();
}
var item = new Dictionary<string, object?>
{
["Id"] = song.Id,
["Name"] = songTitle,
["ServerId"] = "allstarr",
["Type"] = "Audio",
["MediaType"] = "Audio",
["Id"] = song.Id,
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
["Container"] = "flac",
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
["ProductionYear"] = song.Year,
["IndexNumber"] = song.Track,
["ParentIndexNumber"] = song.DiscNumber ?? 1,
["IsFolder"] = false,
["Album"] = song.Album,
["AlbumId"] = song.AlbumId ?? song.Id,
["AlbumArtist"] = song.AlbumArtist ?? song.Artist,
["Artists"] = song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist },
["ArtistItems"] = song.Artists.Count > 0
? song.Artists.Select((name, index) => new Dictionary<string, object?>
["Type"] = "Audio",
["ChannelId"] = (object?)null,
["Genres"] = !string.IsNullOrEmpty(song.Genre)
? new[] { song.Genre }
: new string[0],
["GenreItems"] = !string.IsNullOrEmpty(song.Genre)
? new[]
{
new Dictionary<string, object?>
{
["Name"] = song.Genre,
["Id"] = $"genre-{song.Genre?.ToLowerInvariant()}"
}
}
: new Dictionary<string, object?>[0],
["ParentLogoItemId"] = song.AlbumId,
["ParentBackdropItemId"] = song.AlbumId,
["ParentBackdropImageTags"] = new string[0],
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = $"Audio-{song.Id}",
["ItemId"] = song.Id
},
["Artists"] = artistNames.Count > 0 ? artistNames.ToArray() : new[] { artistName ?? "" },
["ArtistItems"] = artistNames.Count > 0
? artistNames.Select((name, index) => new Dictionary<string, object?>
{
["Name"] = name,
["Id"] = index == 0 && song.ArtistId != null
@@ -263,30 +312,32 @@ public class JellyfinResponseBuilder
new Dictionary<string, object?>
{
["Id"] = song.ArtistId ?? song.Id,
["Name"] = song.Artist
["Name"] = artistName ?? ""
}
},
["IndexNumber"] = song.Track,
["ParentIndexNumber"] = song.DiscNumber ?? 1,
["ProductionYear"] = song.Year,
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
["Album"] = albumName,
["AlbumId"] = song.AlbumId ?? song.Id,
["AlbumPrimaryImageTag"] = song.AlbumId ?? song.Id,
["AlbumArtist"] = song.AlbumArtist ?? artistName,
["AlbumArtists"] = new[]
{
new Dictionary<string, object?>
{
["Name"] = song.AlbumArtist ?? artistName ?? "",
["Id"] = song.ArtistId ?? song.Id
}
},
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = song.Id
},
["BackdropImageTags"] = new string[0],
["ParentLogoImageTag"] = song.AlbumId ?? song.Id,
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem", // External content appears as local files to clients
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac", // Fake path for client compatibility
["ChannelId"] = (object?)null, // Match Jellyfin structure
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = $"Audio-{song.Id}"
},
["LocationType"] = "FileSystem",
["MediaType"] = "Audio",
["NormalizationGain"] = 0.0,
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
["CanDownload"] = true,
["SupportsSync"] = true
};
@@ -305,21 +356,71 @@ public class JellyfinResponseBuilder
providerIds["ISRC"] = song.Isrc;
}
// Add MediaSources with bitrate for external tracks
// Add MediaSources with complete structure matching real Jellyfin
item["MediaSources"] = new[]
{
new Dictionary<string, object?>
{
["Protocol"] = "File",
["Id"] = song.Id,
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
["Type"] = "Default",
["Container"] = "flac",
["Size"] = (song.Duration ?? 180) * 1337 * 128, // Approximate file size
["Bitrate"] = 1337000, // 1337 kbps in bps
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
["Protocol"] = "File",
["SupportsDirectStream"] = true,
["Size"] = (song.Duration ?? 180) * 1337 * 128,
["Name"] = song.Title,
["IsRemote"] = false,
["ETag"] = song.Id, // Use song ID as ETag
["RunTimeTicks"] = (song.Duration ?? 180) * 10000000L,
["ReadAtNativeFramerate"] = false,
["IgnoreDts"] = false,
["IgnoreIndex"] = false,
["GenPtsInput"] = false,
["SupportsTranscoding"] = true,
["SupportsDirectPlay"] = true
["SupportsDirectStream"] = true,
["SupportsDirectPlay"] = true,
["IsInfiniteStream"] = false,
["UseMostCompatibleTranscodingProfile"] = false,
["RequiresOpening"] = false,
["RequiresClosing"] = false,
["RequiresLooping"] = false,
["SupportsProbing"] = true,
["MediaStreams"] = new[]
{
new Dictionary<string, object?>
{
["Codec"] = "flac",
["TimeBase"] = "1/44100",
["VideoRange"] = "Unknown",
["VideoRangeType"] = "Unknown",
["AudioSpatialFormat"] = "None",
["LocalizedDefault"] = "Default",
["LocalizedExternal"] = "External",
["DisplayTitle"] = "FLAC - Stereo",
["IsInterlaced"] = false,
["IsAVC"] = false,
["ChannelLayout"] = "stereo",
["BitRate"] = 1337000,
["BitDepth"] = 16,
["Channels"] = 2,
["SampleRate"] = 44100,
["IsDefault"] = false,
["IsForced"] = false,
["IsHearingImpaired"] = false,
["Type"] = "Audio",
["Index"] = 0,
["IsExternal"] = false,
["IsTextSubtitleStream"] = false,
["SupportsExternalStream"] = false,
["Level"] = 0
}
},
["MediaAttachments"] = new List<object>(),
["Formats"] = new List<string>(),
["Bitrate"] = 1337000,
["RequiredHttpHeaders"] = new Dictionary<string, string>(),
["TranscodingSubProtocol"] = "http",
["DefaultAudioStreamIndex"] = 0,
["HasSegments"] = false
}
};
}
@@ -329,11 +430,6 @@ public class JellyfinResponseBuilder
item["MediaSources"] = song.JellyfinMetadata["MediaSources"];
}
if (!string.IsNullOrEmpty(song.Genre))
{
item["Genres"] = new[] { song.Genre };
}
return item;
}
@@ -351,40 +447,68 @@ public class JellyfinResponseBuilder
var item = new Dictionary<string, object?>
{
["Id"] = album.Id,
["Name"] = albumName,
["ServerId"] = "allstarr",
["Type"] = "MusicAlbum",
["IsFolder"] = true,
["AlbumArtist"] = album.Artist,
["AlbumArtists"] = new[]
{
new Dictionary<string, object?>
{
["Id"] = album.ArtistId ?? album.Id,
["Name"] = album.Artist
}
},
["ProductionYear"] = album.Year,
["ChildCount"] = album.SongCount ?? album.Songs.Count,
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = album.Id
},
["BackdropImageTags"] = new string[0],
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem",
["MediaType"] = (object?)null,
["Id"] = album.Id,
["PremiereDate"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : null,
["ChannelId"] = (object?)null,
["CollectionType"] = (object?)null,
["Genres"] = !string.IsNullOrEmpty(album.Genre)
? new[] { album.Genre }
: new string[0],
["RunTimeTicks"] = 0, // Could calculate from songs
["ProductionYear"] = album.Year,
["IsFolder"] = true,
["Type"] = "MusicAlbum",
["GenreItems"] = !string.IsNullOrEmpty(album.Genre)
? new[]
{
new Dictionary<string, object?>
{
["Name"] = album.Genre,
["Id"] = $"genre-{album.Genre?.ToLowerInvariant()}"
}
}
: new Dictionary<string, object?>[0],
["ParentLogoItemId"] = album.ArtistId ?? album.Id,
["ParentBackdropItemId"] = album.ArtistId ?? album.Id,
["ParentBackdropImageTags"] = new string[0],
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = album.Id
}
["Key"] = $"{album.Artist}-{album.Title}",
["ItemId"] = album.Id
},
["Artists"] = new[] { album.Artist },
["ArtistItems"] = new[]
{
new Dictionary<string, object?>
{
["Name"] = album.Artist,
["Id"] = album.ArtistId ?? album.Id
}
},
["AlbumArtist"] = album.Artist,
["AlbumArtists"] = new[]
{
new Dictionary<string, object?>
{
["Name"] = album.Artist,
["Id"] = album.ArtistId ?? album.Id
}
},
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = album.Id
},
["BackdropImageTags"] = new string[0],
["ParentLogoImageTag"] = album.ArtistId ?? album.Id,
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem",
["MediaType"] = "Unknown",
["ChildCount"] = album.SongCount ?? album.Songs.Count
};
// Add provider IDs for external content
@@ -396,11 +520,6 @@ public class JellyfinResponseBuilder
};
}
if (!string.IsNullOrEmpty(album.Genre))
{
item["Genres"] = new[] { album.Genre };
}
return item;
}
@@ -418,30 +537,33 @@ public class JellyfinResponseBuilder
var item = new Dictionary<string, object?>
{
["Id"] = artist.Id,
["Name"] = artistName,
["ServerId"] = "allstarr",
["Type"] = "MusicArtist",
["Id"] = artist.Id,
["ChannelId"] = (object?)null,
["Genres"] = new string[0], // Artists aggregate genres from albums/tracks
["RunTimeTicks"] = 0,
["IsFolder"] = true,
["AlbumCount"] = artist.AlbumCount ?? 0,
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = artist.Id
},
["BackdropImageTags"] = new string[0],
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem", // External content appears as local files to clients
["MediaType"] = (object?)null, // Match Jellyfin structure
["ChannelId"] = (object?)null, // Match Jellyfin structure
["CollectionType"] = (object?)null, // Match Jellyfin structure
["Type"] = "MusicArtist",
["GenreItems"] = new Dictionary<string, object?>[0],
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = artist.Id
}
["Key"] = $"Artist-{artist.Name}",
["ItemId"] = artist.Id
},
["ImageTags"] = new Dictionary<string, string>
{
["Primary"] = artist.Id
},
["BackdropImageTags"] = new string[0],
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem",
["MediaType"] = "Unknown",
["AlbumCount"] = artist.AlbumCount ?? 0
};
// Add provider IDs for external content
@@ -478,7 +600,7 @@ public class JellyfinResponseBuilder
}
/// <summary>
/// Converts an ExternalPlaylist to a Jellyfin album item.
/// Converts an ExternalPlaylist to a Jellyfin playlist item.
/// </summary>
public Dictionary<string, object?> ConvertPlaylistToJellyfinItem(ExternalPlaylist playlist)
{
@@ -488,13 +610,24 @@ public class JellyfinResponseBuilder
var item = new Dictionary<string, object?>
{
["Id"] = playlist.Id,
["Name"] = playlist.Name,
["ServerId"] = "allstarr",
["Type"] = "Playlist",
["Id"] = playlist.Id,
["ChannelId"] = (object?)null,
["Genres"] = new string[0], // Playlists aggregate genres from tracks
["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond,
["IsFolder"] = true,
["AlbumArtist"] = curatorName,
["Genres"] = new[] { "Playlist" },
["Type"] = "Playlist",
["GenreItems"] = new Dictionary<string, object?>[0],
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = playlist.Id,
["ItemId"] = playlist.Id
},
["ChildCount"] = playlist.TrackCount,
["ImageTags"] = new Dictionary<string, string>
{
@@ -503,20 +636,10 @@ public class JellyfinResponseBuilder
["BackdropImageTags"] = new string[0],
["ImageBlurHashes"] = new Dictionary<string, object>(),
["LocationType"] = "FileSystem",
["MediaType"] = (object?)null,
["ChannelId"] = (object?)null,
["CollectionType"] = (object?)null,
["MediaType"] = "Audio",
["ProviderIds"] = new Dictionary<string, string>
{
[playlist.Provider] = playlist.ExternalId
},
["UserData"] = new Dictionary<string, object>
{
["PlaybackPositionTicks"] = 0,
["PlayCount"] = 0,
["IsFavorite"] = false,
["Played"] = false,
["Key"] = playlist.Id
}
};

View File

@@ -0,0 +1,342 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using Microsoft.Extensions.Options;
namespace allstarr.Services.MusicBrainz;
/// <summary>
/// Service for querying MusicBrainz API for metadata enrichment.
/// </summary>
public class MusicBrainzService
{
private readonly HttpClient _httpClient;
private readonly MusicBrainzSettings _settings;
private readonly ILogger<MusicBrainzService> _logger;
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly SemaphoreSlim _rateLimitSemaphore = new(1, 1);
public MusicBrainzService(
IHttpClientFactory httpClientFactory,
IOptions<MusicBrainzSettings> settings,
ILogger<MusicBrainzService> logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_settings = settings.Value;
_logger = logger;
// Set up digest authentication if credentials provided
if (!string.IsNullOrEmpty(_settings.Username) && !string.IsNullOrEmpty(_settings.Password))
{
var credentials = Convert.ToBase64String(
Encoding.ASCII.GetBytes($"{_settings.Username}:{_settings.Password}"));
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", credentials);
}
}
/// <summary>
/// Looks up a recording by ISRC code.
/// </summary>
public async Task<MusicBrainzRecording?> LookupByIsrcAsync(string isrc)
{
if (!_settings.Enabled)
{
return null;
}
await RateLimitAsync();
try
{
var url = $"{_settings.BaseUrl}/isrc/{isrc}?fmt=json&inc=artists+releases+release-groups+genres+tags";
_logger.LogDebug("MusicBrainz ISRC lookup: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("MusicBrainz ISRC lookup failed: {StatusCode}", response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<MusicBrainzIsrcResponse>(json, JsonOptions);
if (result?.Recordings == null || result.Recordings.Count == 0)
{
_logger.LogDebug("No MusicBrainz recordings found for ISRC: {Isrc}", isrc);
return null;
}
// Return the first recording (ISRCs should be unique)
var recording = result.Recordings[0];
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string>();
_logger.LogInformation("✓ Found MusicBrainz recording for ISRC {Isrc}: {Title} by {Artist} (Genres: {Genres})",
isrc, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
return recording;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error looking up ISRC {Isrc} in MusicBrainz", isrc);
return null;
}
}
/// <summary>
/// Searches for recordings by title and artist.
/// </summary>
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
{
if (!_settings.Enabled)
{
return new List<MusicBrainzRecording>();
}
await RateLimitAsync();
try
{
// Build Lucene query
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
var encodedQuery = Uri.EscapeDataString(query);
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}&inc=genres+tags";
_logger.LogDebug("MusicBrainz search: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("MusicBrainz search failed: {StatusCode}", response.StatusCode);
return new List<MusicBrainzRecording>();
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<MusicBrainzSearchResponse>(json, JsonOptions);
if (result?.Recordings == null || result.Recordings.Count == 0)
{
_logger.LogDebug("No MusicBrainz recordings found for: {Title} - {Artist}", title, artist);
return new List<MusicBrainzRecording>();
}
_logger.LogInformation("Found {Count} MusicBrainz recordings for: {Title} - {Artist}",
result.Recordings.Count, title, artist);
return result.Recordings;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching MusicBrainz for: {Title} - {Artist}", title, artist);
return new List<MusicBrainzRecording>();
}
}
/// <summary>
/// Enriches a song with genre information from MusicBrainz.
/// First tries ISRC lookup, then falls back to title/artist search.
/// </summary>
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
{
if (!_settings.Enabled)
{
return new List<string>();
}
MusicBrainzRecording? recording = null;
// Try ISRC lookup first (most accurate)
if (!string.IsNullOrEmpty(isrc))
{
recording = await LookupByIsrcAsync(isrc);
}
// Fall back to search if ISRC lookup failed or no ISRC provided
if (recording == null)
{
var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
recording = recordings.FirstOrDefault();
}
if (recording == null)
{
return new List<string>();
}
// Extract genres (prioritize official genres over tags)
var genres = new List<string>();
if (recording.Genres != null && recording.Genres.Count > 0)
{
// Get top genres by vote count
genres.AddRange(recording.Genres
.OrderByDescending(g => g.Count)
.Take(5)
.Select(g => g.Name)
.Where(n => !string.IsNullOrEmpty(n))
.Select(n => n!)
.ToList());
}
_logger.LogInformation("Found {Count} genres for {Title} - {Artist}: {Genres}",
genres.Count, title, artist, string.Join(", ", genres));
return genres;
}
/// <summary>
/// Rate limiting to comply with MusicBrainz API rules (1 request per second).
/// </summary>
private async Task RateLimitAsync()
{
await _rateLimitSemaphore.WaitAsync();
try
{
var timeSinceLastRequest = DateTime.UtcNow - _lastRequestTime;
var minInterval = TimeSpan.FromMilliseconds(_settings.RateLimitMs);
if (timeSinceLastRequest < minInterval)
{
var delay = minInterval - timeSinceLastRequest;
await Task.Delay(delay);
}
_lastRequestTime = DateTime.UtcNow;
}
finally
{
_rateLimitSemaphore.Release();
}
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
/// <summary>
/// MusicBrainz ISRC lookup response.
/// </summary>
public class MusicBrainzIsrcResponse
{
[JsonPropertyName("recordings")]
public List<MusicBrainzRecording>? Recordings { get; set; }
}
/// <summary>
/// MusicBrainz search response.
/// </summary>
public class MusicBrainzSearchResponse
{
[JsonPropertyName("recordings")]
public List<MusicBrainzRecording>? Recordings { get; set; }
[JsonPropertyName("count")]
public int Count { get; set; }
}
/// <summary>
/// MusicBrainz recording.
/// </summary>
public class MusicBrainzRecording
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("length")]
public int? Length { get; set; } // in milliseconds
[JsonPropertyName("artist-credit")]
public List<MusicBrainzArtistCredit>? ArtistCredit { get; set; }
[JsonPropertyName("releases")]
public List<MusicBrainzRelease>? Releases { get; set; }
[JsonPropertyName("isrcs")]
public List<string>? Isrcs { get; set; }
[JsonPropertyName("genres")]
public List<MusicBrainzGenre>? Genres { get; set; }
[JsonPropertyName("tags")]
public List<MusicBrainzTag>? Tags { get; set; }
}
/// <summary>
/// MusicBrainz artist credit.
/// </summary>
public class MusicBrainzArtistCredit
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("artist")]
public MusicBrainzArtist? Artist { get; set; }
}
/// <summary>
/// MusicBrainz artist.
/// </summary>
public class MusicBrainzArtist
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
}
/// <summary>
/// MusicBrainz release.
/// </summary>
public class MusicBrainzRelease
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("date")]
public string? Date { get; set; }
}
/// <summary>
/// MusicBrainz genre.
/// </summary>
public class MusicBrainzGenre
{
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("count")]
public int Count { get; set; }
}
/// <summary>
/// MusicBrainz tag (folksonomy).
/// </summary>
public class MusicBrainzTag
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("count")]
public int Count { get; set; }
}

View File

@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
using allstarr.Models.Spotify;
using allstarr.Services.Common;
using allstarr.Services.Jellyfin;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using System.Text.Json;
@@ -72,8 +73,15 @@ public class SpotifyTrackMatchingService : BackgroundService
// Now start the periodic matching loop
while (!stoppingToken.IsCancellationRequested)
{
// Wait 30 minutes before next run
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
// 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
{
@@ -294,12 +302,32 @@ public class SpotifyTrackMatchingService : BackgroundService
// Check cache - use snapshot/timestamp to detect changes
var existingMatched = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
if (existingMatched != null && existingMatched.Count >= tracksToMatch.Count)
// Check if we have manual mappings that need to be preserved
var hasManualMappings = false;
foreach (var track in tracksToMatch)
{
var manualMappingKey = $"spotify:manual-map:{playlistName}:{track.SpotifyId}";
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualMapping))
{
hasManualMappings = true;
break;
}
}
// Skip if cache exists AND no manual mappings need to be applied
if (existingMatched != null && existingMatched.Count >= tracksToMatch.Count && !hasManualMappings)
{
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
playlistName, existingMatched.Count);
return;
}
if (hasManualMappings)
{
_logger.LogInformation("Manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
}
var matchedTracks = new List<MatchedTrack>();
var isrcMatches = 0;
@@ -365,20 +393,20 @@ public class SpotifyTrackMatchingService : BackgroundService
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
matchType, matchedSong.Title);
return (matched, matchType);
return ((MatchedTrack?)matched, matchType);
}
else
{
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
return (null, "none");
return ((MatchedTrack?)null, "none");
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
return (null, "none");
return ((MatchedTrack?)null, "none");
}
}).ToList();
@@ -386,8 +414,9 @@ public class SpotifyTrackMatchingService : BackgroundService
var batchResults = await Task.WhenAll(batchTasks);
// Collect results
foreach (var (matched, matchType) in batchResults)
foreach (var result in batchResults)
{
var (matched, matchType) = result;
if (matched != null)
{
matchedTracks.Add(matched);
@@ -420,6 +449,9 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogInformation(
"✓ Cached {Matched}/{Total} tracks for {Playlist} (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch})",
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
// Pre-build playlist items cache for instant serving
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cancellationToken);
}
else
{
@@ -649,4 +681,236 @@ public class SpotifyTrackMatchingService : BackgroundService
return avgScore;
}
/// <summary>
/// Pre-builds the playlist items cache for instant serving.
/// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order.
/// </summary>
private async Task PreBuildPlaylistItemsCacheAsync(
string playlistName,
string? jellyfinPlaylistId,
List<SpotifyPlaylistTrack> spotifyTracks,
List<MatchedTrack> matchedTracks,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
if (string.IsNullOrEmpty(jellyfinPlaylistId))
{
_logger.LogWarning("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
return;
}
// Get existing tracks from Jellyfin playlist
using var scope = _serviceProvider.CreateScope();
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
var responseBuilder = scope.ServiceProvider.GetService<JellyfinResponseBuilder>();
var jellyfinSettings = scope.ServiceProvider.GetService<IOptions<JellyfinSettings>>()?.Value;
if (proxyService == null || responseBuilder == null || jellyfinSettings == null)
{
_logger.LogWarning("Required services not available for pre-building cache");
return;
}
var userId = jellyfinSettings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogWarning("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
return;
}
// Create authentication headers for background service call
var headers = new HeaderDictionary();
if (!string.IsNullOrEmpty(jellyfinSettings.ApiKey))
{
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
}
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
if (statusCode != 200 || existingTracksResponse == null)
{
_logger.LogWarning("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
return;
}
// Index Jellyfin items by title+artist for matching
var jellyfinItemsByName = new Dictionary<string, JsonElement>();
if (existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
{
foreach (var item in items.EnumerateArray())
{
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
{
artist = albumArtistEl.GetString() ?? "";
}
var key = $"{title}|{artist}".ToLowerInvariant();
if (!jellyfinItemsByName.ContainsKey(key))
{
jellyfinItemsByName[key] = item;
}
}
}
// Build the final track list in correct Spotify order
var finalItems = new List<Dictionary<string, object?>>();
var usedJellyfinItems = new HashSet<string>();
var localUsedCount = 0;
var externalUsedCount = 0;
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{
if (cancellationToken.IsCancellationRequested) break;
// FIRST: Check for manual mapping
var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
JsonElement? matchedJellyfinItem = null;
string? matchedKey = null;
if (!string.IsNullOrEmpty(manualJellyfinId))
{
// Manual mapping exists - fetch the Jellyfin item by ID
try
{
var itemUrl = $"Items/{manualJellyfinId}?UserId={userId}";
var (itemResponse, itemStatusCode) = await proxyService.GetJsonAsync(itemUrl, null, headers);
if (itemStatusCode == 200 && itemResponse != null)
{
matchedJellyfinItem = itemResponse.RootElement;
_logger.LogDebug("✓ Using manual mapping for {Title}: Jellyfin ID {Id}",
spotifyTrack.Title, manualJellyfinId);
}
else
{
_logger.LogWarning("Manual mapping points to invalid Jellyfin ID {Id} for {Title}",
manualJellyfinId, spotifyTrack.Title);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch manually mapped Jellyfin item {Id}", manualJellyfinId);
}
}
// SECOND: If no manual mapping, try fuzzy matching
if (!matchedJellyfinItem.HasValue)
{
double bestScore = 0;
foreach (var kvp in jellyfinItemsByName)
{
if (usedJellyfinItems.Contains(kvp.Key)) continue;
var item = kvp.Value;
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
var artist = "";
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
var titleScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.Title, title);
var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist);
var totalScore = (titleScore * 0.7) + (artistScore * 0.3);
if (totalScore > bestScore && totalScore >= 70)
{
bestScore = totalScore;
matchedJellyfinItem = item;
matchedKey = kvp.Key;
}
}
}
if (matchedJellyfinItem.HasValue)
{
// Use the raw Jellyfin item (preserves ALL metadata)
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
if (itemDict != null)
{
finalItems.Add(itemDict);
if (matchedKey != null)
{
usedJellyfinItems.Add(matchedKey);
}
localUsedCount++;
}
}
else
{
// No local match - try to find external track
var matched = matchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
if (matched != null && matched.MatchedSong != null)
{
// Convert external song to Jellyfin item format
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
finalItems.Add(externalItem);
externalUsedCount++;
}
}
}
if (finalItems.Count > 0)
{
// Save to Redis cache
var cacheKey = $"spotify:playlist:items:{playlistName}";
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
// Save to file cache for persistence
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
_logger.LogInformation(
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
playlistName, finalItems.Count, localUsedCount, externalUsedCount);
}
else
{
_logger.LogWarning("No items to cache for {Playlist}", playlistName);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to pre-build playlist items cache for {Playlist}", playlistName);
}
}
/// <summary>
/// Saves playlist items to file cache for persistence across restarts.
/// </summary>
private async Task SavePlaylistItemsToFileAsync(string playlistName, List<Dictionary<string, object?>> items)
{
try
{
var cacheDir = "/app/cache/spotify";
Directory.CreateDirectory(cacheDir);
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, json);
_logger.LogDebug("💾 Saved {Count} playlist items to file cache: {Path}", items.Count, filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName);
}
}
}

View File

@@ -48,6 +48,7 @@
"SyncStartHour": 16,
"SyncStartMinute": 15,
"SyncWindowHours": 2,
"MatchingIntervalHours": 24,
"Playlists": []
},
"SpotifyApi": {
@@ -58,5 +59,12 @@
"CacheDurationMinutes": 60,
"RateLimitDelayMs": 100,
"PreferIsrcMatching": true
},
"MusicBrainz": {
"Enabled": true,
"Username": "",
"Password": "",
"BaseUrl": "https://musicbrainz.org/ws/2",
"RateLimitMs": 1000
}
}

View File

@@ -286,6 +286,8 @@
.toast.success { border-color: var(--success); }
.toast.error { border-color: var(--error); }
.toast.warning { border-color: var(--warning); }
.toast.info { border-color: var(--accent); }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
@@ -734,6 +736,27 @@
</div>
</div>
<div class="card">
<h2>MusicBrainz Settings</h2>
<div class="config-section">
<div class="config-item">
<span class="label">Enabled</span>
<span class="value" id="config-musicbrainz-enabled">-</span>
<button onclick="openEditSetting('MUSICBRAINZ_ENABLED', 'MusicBrainz Enabled', 'select', '', ['true', 'false'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Username</span>
<span class="value" id="config-musicbrainz-username">-</span>
<button onclick="openEditSetting('MUSICBRAINZ_USERNAME', 'MusicBrainz Username', 'text', 'Your MusicBrainz username')">Update</button>
</div>
<div class="config-item">
<span class="label">Password</span>
<span class="value" id="config-musicbrainz-password">-</span>
<button onclick="openEditSetting('MUSICBRAINZ_PASSWORD', 'MusicBrainz Password', 'password', 'Your MusicBrainz password')">Update</button>
</div>
</div>
</div>
<div class="card">
<h2>Qobuz Settings</h2>
<div class="config-section">
@@ -792,6 +815,18 @@
</div>
</div>
<div class="card">
<h2>Configuration Backup</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Export your .env configuration for backup or import a previously saved configuration.
</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button onclick="exportEnv()">📥 Export .env</button>
<button onclick="document.getElementById('import-env-input').click()">📤 Import .env</button>
<input type="file" id="import-env-input" accept=".env" style="display:none" onchange="importEnv(event)">
</div>
</div>
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
<h2 style="color: var(--error);">Danger Zone</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
@@ -985,12 +1020,12 @@
});
// Toast notification
function showToast(message, type = 'success') {
function showToast(message, type = 'success', duration = 3000) {
const toast = document.createElement('div');
toast.className = 'toast ' + type;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
setTimeout(() => toast.remove(), duration);
}
// Modal helpers
@@ -1143,6 +1178,16 @@
const externalMissing = p.externalMissing || 0;
const totalInJellyfin = p.totalInJellyfin || 0;
// Debug: Log the raw data
console.log(`Playlist ${p.name}:`, {
spotifyTotal,
localCount,
externalMatched,
externalMissing,
totalInJellyfin,
rawData: p
});
// Build detailed stats string
let statsHtml = `<span class="track-count">${spotifyTotal}</span>`;
@@ -1164,8 +1209,14 @@
// Calculate completion percentage
const completionPct = spotifyTotal > 0 ? Math.round((totalInJellyfin / spotifyTotal) * 100) : 0;
const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0;
const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0;
const missingPct = spotifyTotal > 0 ? Math.round((externalMissing / spotifyTotal) * 100) : 0;
const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)';
// Debug logging
console.log(`Progress bar for ${p.name}: local=${localPct}%, external=${externalPct}%, missing=${missingPct}%, total=${completionPct}%`);
return `
<tr>
<td><strong>${escapeHtml(p.name)}</strong></td>
@@ -1173,8 +1224,10 @@
<td>${statsHtml}${breakdown}</td>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;background:var(--bg-tertiary);height:6px;border-radius:3px;overflow:hidden;">
<div style="width:${completionPct}%;height:100%;background:${completionColor};transition:width 0.3s;"></div>
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;">
<div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div>
<div style="width:${externalPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMatched} external matched tracks"></div>
<div style="width:${missingPct}%;height:100%;background:#6b7280;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
</div>
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
</div>
@@ -1220,6 +1273,11 @@
// SquidWTF settings
document.getElementById('config-squid-quality').textContent = data.squidWtf.quality;
// MusicBrainz settings
document.getElementById('config-musicbrainz-enabled').textContent = data.musicBrainz.enabled ? 'Yes' : 'No';
document.getElementById('config-musicbrainz-username').textContent = data.musicBrainz.username || '(not set)';
document.getElementById('config-musicbrainz-password').textContent = data.musicBrainz.password || '(not set)';
// Qobuz settings
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
@@ -1462,7 +1520,7 @@
if (res.ok) {
showToast(`${data.message}`, 'success');
// Refresh the playlists table after a delay to show updated counts
setTimeout(fetchPlaylists, 3000);
setTimeout(fetchPlaylists, 2000);
} else {
showToast(data.error || 'Failed to match tracks', 'error');
}
@@ -1471,6 +1529,19 @@
}
}
function searchProvider(query, provider) {
// Provider-specific search URLs
const searchUrls = {
'Deezer': `https://www.deezer.com/search/${encodeURIComponent(query)}`,
'Qobuz': `https://www.qobuz.com/us-en/search?q=${encodeURIComponent(query)}`,
'SquidWTF': `https://triton.squid.wtf/search/?s=${encodeURIComponent(query)}`,
'default': `https://www.google.com/search?q=${encodeURIComponent(query + ' music')}`
};
const url = searchUrls[provider] || searchUrls['default'];
window.open(url, '_blank');
}
async function clearCache() {
if (!confirm('Clear all cached playlist data?')) return;
@@ -1484,6 +1555,61 @@
}
}
async function exportEnv() {
try {
const res = await fetch('/api/admin/export-env');
if (!res.ok) {
throw new Error('Export failed');
}
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `.env.backup.${new Date().toISOString().split('T')[0]}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast('.env file exported successfully', 'success');
} catch (error) {
showToast('Failed to export .env file', 'error');
}
}
async function importEnv(event) {
const file = event.target.files[0];
if (!file) return;
if (!confirm('Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.')) {
event.target.value = '';
return;
}
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/admin/import-env', {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok) {
showToast(data.message, 'success');
} else {
showToast(data.error || 'Failed to import .env file', 'error');
}
} catch (error) {
showToast('Failed to import .env file', 'error');
}
event.target.value = '';
}
async function restartContainer() {
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
return;
@@ -1624,7 +1750,8 @@
if (t.isLocal === true) {
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
} else if (t.isLocal === false) {
statusBadge = '<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>External</span>';
const provider = t.externalProvider || 'External';
statusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
// Add manual map button for external tracks using data attributes
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
mapButton = `<button class="small map-track-btn"
@@ -1634,10 +1761,22 @@
data-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
} else {
// isLocal is null/undefined - track is missing (not found locally or externally)
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:var(--bg-tertiary);color:var(--text-secondary);"><span class="status-dot" style="background:var(--text-secondary);"></span>Missing</span>';
// Add manual map button for missing tracks too
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
mapButton = `<button class="small map-track-btn"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
data-title="${escapeHtml(t.title || '')}"
data-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
}
return `
<div class="track-item">
<div class="track-item" data-position="${t.position}">
<span class="track-position">${t.position + 1}</span>
<div class="track-info">
<h4>${escapeHtml(t.title)}${statusBadge}${mapButton}</h4>
@@ -1646,6 +1785,7 @@
<div class="track-meta">
${t.album ? escapeHtml(t.album) : ''}
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
${t.isLocal === false && t.searchQuery && t.externalProvider ? '<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'' + escapeJs(t.externalProvider) + '\'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ' + escapeHtml(t.searchQuery) + '</a></small>' : ''}
</div>
</div>
`;
@@ -1881,10 +2021,12 @@
// Remove selection from all tracks
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
el.style.border = '2px solid transparent';
el.style.background = '';
});
// Highlight selected track
element.style.border = '2px solid var(--primary)';
element.style.border = '2px solid var(--accent)';
element.style.background = 'var(--bg-tertiary)';
// Store selected ID and enable save button
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
@@ -1895,31 +2037,133 @@
const playlistName = document.getElementById('map-playlist-name').value;
const spotifyId = document.getElementById('map-spotify-id').value;
const jellyfinId = document.getElementById('map-selected-jellyfin-id').value;
const position = parseInt(document.getElementById('map-position').textContent) - 1; // Convert back to 0-indexed
if (!jellyfinId) {
showToast('Please select a track', 'error');
return;
}
// Show loading state
const saveBtn = document.getElementById('map-save-btn');
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotifyId, jellyfinId })
body: JSON.stringify({ spotifyId, jellyfinId }),
signal: controller.signal
});
clearTimeout(timeoutId);
const data = await res.json();
if (res.ok) {
showToast('Track mapped successfully! Refresh the playlist to see changes.', 'success');
showToast('Track mapped successfully - rebuilding playlist...', 'success');
closeModal('manual-map-modal');
// Refresh the tracks view
viewTracks(playlistName);
// Show rebuilding indicator
showPlaylistRebuildingIndicator(playlistName);
// Show detailed info toast after a moment
setTimeout(() => {
showToast('🔄 Searching external providers to rebuild playlist with your manual mapping...', 'info', 8000);
}, 1000);
// Update the track in the UI without refreshing
if (data.track) {
const trackItem = document.querySelector(`.track-item[data-position="${position}"]`);
if (trackItem) {
// Update the track info
const titleEl = trackItem.querySelector('.track-info h4');
const artistEl = trackItem.querySelector('.track-info .artists');
const statusBadge = trackItem.querySelector('.status-badge');
const mapButton = trackItem.querySelector('.map-track-btn');
const searchLink = trackItem.querySelector('.track-meta a');
if (titleEl) {
// Remove the old status badge and map button, add new content
const titleText = data.track.title;
const newStatusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
titleEl.innerHTML = escapeHtml(titleText) + newStatusBadge;
}
if (artistEl) artistEl.textContent = data.track.artist;
// Remove the search link since it's now local
if (searchLink) {
const metaEl = trackItem.querySelector('.track-meta');
if (metaEl) {
// Keep album and ISRC, remove search link
const albumText = data.track.album ? escapeHtml(data.track.album) : '';
metaEl.innerHTML = albumText;
}
}
}
}
// Also refresh the playlist counts in the background
fetchPlaylists();
} else {
showToast(data.error || 'Failed to save mapping', 'error');
}
} catch (error) {
showToast('Failed to save mapping', 'error');
if (error.name === 'AbortError') {
showToast('Request timed out - mapping may still be processing', 'warning');
} else {
showToast('Failed to save mapping', 'error');
}
} finally {
// Reset button state
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
function showPlaylistRebuildingIndicator(playlistName) {
// Find the playlist in the UI and show rebuilding state
const playlistCards = document.querySelectorAll('.playlist-card');
for (const card of playlistCards) {
const nameEl = card.querySelector('h3');
if (nameEl && nameEl.textContent.trim() === playlistName) {
// Add rebuilding indicator
const existingIndicator = card.querySelector('.rebuilding-indicator');
if (!existingIndicator) {
const indicator = document.createElement('div');
indicator.className = 'rebuilding-indicator';
indicator.style.cssText = `
position: absolute;
top: 8px;
right: 8px;
background: var(--warning);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
z-index: 10;
`;
indicator.innerHTML = '<span class="spinner" style="width: 10px; height: 10px;"></span>Rebuilding...';
card.style.position = 'relative';
card.appendChild(indicator);
// Auto-remove after 30 seconds and refresh
setTimeout(() => {
indicator.remove();
fetchPlaylists(); // Refresh to get updated counts
}, 30000);
}
break;
}
}
}