mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
33 Commits
e7ff330625
...
8fad6d8c4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
8fad6d8c4e
|
|||
|
d11b656b23
|
|||
|
cf1428d678
|
|||
|
030937b196
|
|||
|
f77281fd3d
|
|||
|
791a8b3fdb
|
|||
|
7311bbc04a
|
|||
|
696a2d56f2
|
|||
|
5680b9c7c9
|
|||
|
1d31784ff8
|
|||
|
10e58eced9
|
|||
|
0937fcf163
|
|||
|
506f39d606
|
|||
|
7bb7c6a40e
|
|||
|
3403f7a8c9
|
|||
|
3e5c57766b
|
|||
|
24c6219189
|
|||
|
ea21d5aa77
|
|||
|
ee84770397
|
|||
|
7ccb660299
|
|||
|
0793c4614b
|
|||
|
bf02dc5a57
|
|||
|
7938871556
|
|||
|
39f6893741
|
|||
|
cd4fd702fc
|
|||
|
038c3a9614
|
|||
|
6e966f9e0d
|
|||
|
b778b3d31e
|
|||
|
526a079368
|
|||
|
7a7b884af2
|
|||
|
6ab5e44112
|
|||
|
7c92515723
|
|||
|
8091d30602
|
@@ -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
|
# 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
|
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)
|
# Playlists configuration (JSON ARRAY FORMAT - managed by web UI)
|
||||||
# Format: [["PlaylistName","SpotifyPlaylistId","first|last"],...]
|
# Format: [["PlaylistName","SpotifyPlaylistId","first|last"],...]
|
||||||
# - PlaylistName: Name as it appears in Jellyfin
|
# - PlaylistName: Name as it appears in Jellyfin
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -88,6 +88,13 @@ apis/*.md
|
|||||||
apis/*.json
|
apis/*.json
|
||||||
!apis/jellyfin-openapi-stable.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
|
# Original source code for reference
|
||||||
originals/
|
originals/
|
||||||
|
|
||||||
|
|||||||
@@ -401,11 +401,14 @@ SPOTIFY_IMPORT_PLAYLIST_NAMES=Release Radar,Discover Weekly
|
|||||||
- Caches the list of missing tracks in Redis + file cache
|
- Caches the list of missing tracks in Redis + file cache
|
||||||
- Runs automatically on startup (if needed) and every 5 minutes during the sync window
|
- 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)
|
- For each missing track, searches your streaming provider (SquidWTF, Deezer, or Qobuz)
|
||||||
- Uses fuzzy matching to find the best match (title + artist similarity)
|
- Uses fuzzy matching to find the best match (title + artist similarity)
|
||||||
- Rate-limited to avoid overwhelming the service (150ms delay between searches)
|
- Rate-limited to avoid overwhelming the service (150ms delay between searches)
|
||||||
- Caches matched results for 1 hour
|
- 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**
|
4. **You Open the Playlist in Jellyfin**
|
||||||
- Allstarr intercepts the request
|
- Allstarr intercepts the request
|
||||||
|
|||||||
334
allstarr.Tests/JellyfinResponseStructureTests.cs
Normal file
334
allstarr.Tests/JellyfinResponseStructureTests.cs
Normal 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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Models.Spotify;
|
||||||
using allstarr.Services.Spotify;
|
using allstarr.Services.Spotify;
|
||||||
using allstarr.Services.Jellyfin;
|
using allstarr.Services.Jellyfin;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
@@ -28,6 +29,7 @@ public class AdminController : ControllerBase
|
|||||||
private readonly DeezerSettings _deezerSettings;
|
private readonly DeezerSettings _deezerSettings;
|
||||||
private readonly QobuzSettings _qobuzSettings;
|
private readonly QobuzSettings _qobuzSettings;
|
||||||
private readonly SquidWTFSettings _squidWtfSettings;
|
private readonly SquidWTFSettings _squidWtfSettings;
|
||||||
|
private readonly MusicBrainzSettings _musicBrainzSettings;
|
||||||
private readonly SpotifyApiClient _spotifyClient;
|
private readonly SpotifyApiClient _spotifyClient;
|
||||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||||
private readonly SpotifyTrackMatchingService? _matchingService;
|
private readonly SpotifyTrackMatchingService? _matchingService;
|
||||||
@@ -47,6 +49,7 @@ public class AdminController : ControllerBase
|
|||||||
IOptions<DeezerSettings> deezerSettings,
|
IOptions<DeezerSettings> deezerSettings,
|
||||||
IOptions<QobuzSettings> qobuzSettings,
|
IOptions<QobuzSettings> qobuzSettings,
|
||||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||||
|
IOptions<MusicBrainzSettings> musicBrainzSettings,
|
||||||
SpotifyApiClient spotifyClient,
|
SpotifyApiClient spotifyClient,
|
||||||
SpotifyPlaylistFetcher playlistFetcher,
|
SpotifyPlaylistFetcher playlistFetcher,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
@@ -62,6 +65,7 @@ public class AdminController : ControllerBase
|
|||||||
_deezerSettings = deezerSettings.Value;
|
_deezerSettings = deezerSettings.Value;
|
||||||
_qobuzSettings = qobuzSettings.Value;
|
_qobuzSettings = qobuzSettings.Value;
|
||||||
_squidWtfSettings = squidWtfSettings.Value;
|
_squidWtfSettings = squidWtfSettings.Value;
|
||||||
|
_musicBrainzSettings = musicBrainzSettings.Value;
|
||||||
_spotifyClient = spotifyClient;
|
_spotifyClient = spotifyClient;
|
||||||
_playlistFetcher = playlistFetcher;
|
_playlistFetcher = playlistFetcher;
|
||||||
_matchingService = matchingService;
|
_matchingService = matchingService;
|
||||||
@@ -243,49 +247,156 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
|
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
|
||||||
{
|
{
|
||||||
var localCount = 0;
|
// Get Spotify tracks to match against
|
||||||
var externalMatchedCount = 0;
|
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||||
|
|
||||||
// Count local vs external tracks
|
// Try to use the pre-built playlist cache first (includes manual mappings!)
|
||||||
foreach (var item in items.EnumerateArray())
|
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
|
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||||
var hasPath = item.TryGetProperty("Path", out var pathProp) &&
|
}
|
||||||
pathProp.ValueKind == JsonValueKind.String &&
|
catch (Exception cacheEx)
|
||||||
!string.IsNullOrEmpty(pathProp.GetString());
|
{
|
||||||
|
_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()!;
|
// Check if it's external by looking for ProviderIds (external songs have this)
|
||||||
// Local tracks have filesystem paths starting with / or containing :\
|
var isExternal = false;
|
||||||
if (pathStr.StartsWith("/") || pathStr.Contains(":\\"))
|
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++;
|
localCount++;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// External track (downloaded from Deezer/Qobuz/etc)
|
// Check if external track is matched
|
||||||
externalMatchedCount++;
|
if (matchedSpotifyIds.Contains(track.SpotifyId))
|
||||||
|
{
|
||||||
|
externalMatchedCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
externalMissingCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
playlistInfo["localTracks"] = localCount;
|
||||||
// No path means external
|
playlistInfo["externalMatched"] = externalMatchedCount;
|
||||||
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
|
else
|
||||||
{
|
{
|
||||||
@@ -383,8 +494,20 @@ public class AdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
var isLocal = false;
|
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
|
var bestMatch = localTracks
|
||||||
.Select(local => new
|
.Select(local => new
|
||||||
{
|
{
|
||||||
@@ -419,7 +542,10 @@ public class AdminController : ControllerBase
|
|||||||
spotifyId = track.SpotifyId,
|
spotifyId = track.SpotifyId,
|
||||||
durationMs = track.DurationMs,
|
durationMs = track.DurationMs,
|
||||||
albumArtUrl = track.AlbumArtUrl,
|
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,
|
spotifyId = t.SpotifyId,
|
||||||
durationMs = t.DurationMs,
|
durationMs = t.DurationMs,
|
||||||
albumArtUrl = t.AlbumArtUrl,
|
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
|
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";
|
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);
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||||
|
|
||||||
|
_logger.LogDebug("Searching Jellyfin: {Url}", url);
|
||||||
|
|
||||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||||
if (!response.IsSuccessStatusCode)
|
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" });
|
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())
|
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 id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||||
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
||||||
@@ -566,20 +714,41 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var userId = _jellyfinSettings.UserId;
|
||||||
|
|
||||||
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
||||||
|
if (!string.IsNullOrEmpty(userId))
|
||||||
|
{
|
||||||
|
url += $"?UserId={userId}";
|
||||||
|
}
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
||||||
|
|
||||||
|
_logger.LogDebug("Fetching Jellyfin track {Id} from {Url}", id, url);
|
||||||
|
|
||||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||||
if (!response.IsSuccessStatusCode)
|
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();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var item = doc.RootElement;
|
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 trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : "";
|
||||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : "";
|
||||||
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : "";
|
||||||
@@ -594,6 +763,8 @@ public class AdminController : ControllerBase
|
|||||||
artist = albumArtistEl.GetString() ?? "";
|
artist = albumArtistEl.GetString() ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Found Jellyfin track: {Title} by {Artist}", title, artist);
|
||||||
|
|
||||||
return Ok(new { id = trackId, title, artist, album });
|
return Ok(new { id = trackId, title, artist, album });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -625,11 +796,103 @@ public class AdminController : ControllerBase
|
|||||||
_logger.LogInformation("Manual mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
_logger.LogInformation("Manual mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}",
|
||||||
decodedName, request.SpotifyId, request.JellyfinId);
|
decodedName, request.SpotifyId, request.JellyfinId);
|
||||||
|
|
||||||
// Clear the matched tracks cache to force re-matching
|
// Clear all related caches to force rebuild
|
||||||
var cacheKey = $"spotify:matched:{decodedName}";
|
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||||
await _cache.DeleteAsync(cacheKey);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -721,6 +984,14 @@ public class AdminController : ControllerBase
|
|||||||
squidWtf = new
|
squidWtf = new
|
||||||
{
|
{
|
||||||
quality = _squidWtfSettings.Quality ?? "LOSSLESS"
|
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
|
// Only allow alphanumeric, underscore, and must start with letter/underscore
|
||||||
return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase);
|
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
|
public class ConfigUpdateRequest
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly SpotifyImportSettings _spotifySettings;
|
private readonly SpotifyImportSettings _spotifySettings;
|
||||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||||
private readonly IMusicMetadataService _metadataService;
|
private readonly IMusicMetadataService _metadataService;
|
||||||
|
private readonly ParallelMetadataService? _parallelMetadataService;
|
||||||
private readonly ILocalLibraryService _localLibraryService;
|
private readonly ILocalLibraryService _localLibraryService;
|
||||||
private readonly IDownloadService _downloadService;
|
private readonly IDownloadService _downloadService;
|
||||||
private readonly JellyfinResponseBuilder _responseBuilder;
|
private readonly JellyfinResponseBuilder _responseBuilder;
|
||||||
@@ -38,6 +39,7 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly PlaylistSyncService? _playlistSyncService;
|
private readonly PlaylistSyncService? _playlistSyncService;
|
||||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||||
|
private readonly LrclibService? _lrclibService;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly ILogger<JellyfinController> _logger;
|
private readonly ILogger<JellyfinController> _logger;
|
||||||
|
|
||||||
@@ -54,14 +56,17 @@ public class JellyfinController : ControllerBase
|
|||||||
JellyfinSessionManager sessionManager,
|
JellyfinSessionManager sessionManager,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
ILogger<JellyfinController> logger,
|
ILogger<JellyfinController> logger,
|
||||||
|
ParallelMetadataService? parallelMetadataService = null,
|
||||||
PlaylistSyncService? playlistSyncService = null,
|
PlaylistSyncService? playlistSyncService = null,
|
||||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||||
SpotifyLyricsService? spotifyLyricsService = null)
|
SpotifyLyricsService? spotifyLyricsService = null,
|
||||||
|
LrclibService? lrclibService = null)
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_spotifySettings = spotifySettings.Value;
|
_spotifySettings = spotifySettings.Value;
|
||||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||||
_metadataService = metadataService;
|
_metadataService = metadataService;
|
||||||
|
_parallelMetadataService = parallelMetadataService;
|
||||||
_localLibraryService = localLibraryService;
|
_localLibraryService = localLibraryService;
|
||||||
_downloadService = downloadService;
|
_downloadService = downloadService;
|
||||||
_responseBuilder = responseBuilder;
|
_responseBuilder = responseBuilder;
|
||||||
@@ -71,6 +76,7 @@ public class JellyfinController : ControllerBase
|
|||||||
_playlistSyncService = playlistSyncService;
|
_playlistSyncService = playlistSyncService;
|
||||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||||
_spotifyLyricsService = spotifyLyricsService;
|
_spotifyLyricsService = spotifyLyricsService;
|
||||||
|
_lrclibService = lrclibService;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
@@ -241,7 +247,11 @@ public class JellyfinController : ControllerBase
|
|||||||
// Run local and external searches in parallel
|
// Run local and external searches in parallel
|
||||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||||
var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers);
|
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
|
var playlistTask = _settings.EnableExternalPlaylists
|
||||||
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit)
|
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit)
|
||||||
@@ -312,6 +322,39 @@ public class JellyfinController : ControllerBase
|
|||||||
_logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
_logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||||
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
||||||
|
|
||||||
|
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
||||||
|
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
|
// Filter by item types if specified
|
||||||
var items = new List<Dictionary<string, object?>>();
|
var items = new List<Dictionary<string, object?>>();
|
||||||
|
|
||||||
@@ -555,9 +598,13 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
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)
|
// Check if asking for audio (album tracks)
|
||||||
if (itemTypes?.Contains("Audio") == true)
|
if (itemTypes?.Contains("Audio") == true)
|
||||||
{
|
{
|
||||||
|
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
|
||||||
var album = await _metadataService.GetAlbumAsync(provider, externalId);
|
var album = await _metadataService.GetAlbumAsync(provider, externalId);
|
||||||
if (album == null)
|
if (album == null)
|
||||||
{
|
{
|
||||||
@@ -568,9 +615,12 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise assume it's artist albums
|
// Otherwise assume it's artist albums
|
||||||
|
_logger.LogDebug("Fetching artist albums for {Provider}/{ExternalId}", provider, externalId);
|
||||||
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
|
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
|
||||||
var artist = await _metadataService.GetArtistAsync(provider, externalId);
|
var artist = await _metadataService.GetArtistAsync(provider, externalId);
|
||||||
|
|
||||||
|
_logger.LogInformation("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
|
||||||
|
|
||||||
// Fill artist info
|
// Fill artist info
|
||||||
if (artist != null)
|
if (artist != null)
|
||||||
{
|
{
|
||||||
@@ -1133,12 +1183,24 @@ public class JellyfinController : ControllerBase
|
|||||||
return NotFound(new { error = "Song not found" });
|
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;
|
LyricsInfo? lyrics = null;
|
||||||
|
|
||||||
// Try Spotify lyrics first (better synced lyrics quality)
|
// Try Spotify lyrics first (better synced lyrics quality)
|
||||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled)
|
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;
|
SpotifyLyricsResult? spotifyLyrics = null;
|
||||||
|
|
||||||
@@ -1149,18 +1211,18 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Search by metadata
|
// Search by metadata (without [S] tags)
|
||||||
spotifyLyrics = await _spotifyLyricsService.SearchAndGetLyricsAsync(
|
spotifyLyrics = await _spotifyLyricsService.SearchAndGetLyricsAsync(
|
||||||
song.Title,
|
searchTitle,
|
||||||
song.Artists.Count > 0 ? song.Artists[0] : song.Artist ?? "",
|
searchArtists.Count > 0 ? searchArtists[0] : searchArtist,
|
||||||
song.Album,
|
searchAlbum,
|
||||||
song.Duration.HasValue ? song.Duration.Value * 1000 : null);
|
song.Duration.HasValue ? song.Duration.Value * 1000 : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
|
_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);
|
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1169,15 +1231,15 @@ public class JellyfinController : ControllerBase
|
|||||||
if (lyrics == null)
|
if (lyrics == null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
||||||
song.Artists.Count > 0 ? string.Join(", ", song.Artists) : song.Artist,
|
string.Join(", ", searchArtists),
|
||||||
song.Title);
|
searchTitle);
|
||||||
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||||
if (lrclibService != null)
|
if (lrclibService != null)
|
||||||
{
|
{
|
||||||
lyrics = await lrclibService.GetLyricsAsync(
|
lyrics = await lrclibService.GetLyricsAsync(
|
||||||
song.Title,
|
searchTitle,
|
||||||
song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist ?? "" },
|
searchArtists.ToArray(),
|
||||||
song.Album ?? "",
|
searchAlbum,
|
||||||
song.Duration ?? 0);
|
song.Duration ?? 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2701,7 +2763,7 @@ public class JellyfinController : ControllerBase
|
|||||||
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
||||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||||
|
|
||||||
_logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks",
|
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
|
||||||
matchedTracksKey, matchedTracks?.Count ?? 0);
|
matchedTracksKey, matchedTracks?.Count ?? 0);
|
||||||
|
|
||||||
// Fallback to legacy cache format
|
// Fallback to legacy cache format
|
||||||
@@ -2716,54 +2778,71 @@ public class JellyfinController : ControllerBase
|
|||||||
Position = i,
|
Position = i,
|
||||||
MatchedSong = s
|
MatchedSong = s
|
||||||
}).ToList();
|
}).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
|
// Try loading from file cache if Redis is empty
|
||||||
var localTracksCount = 0;
|
if (matchedTracks == null || matchedTracks.Count == 0)
|
||||||
try
|
|
||||||
{
|
{
|
||||||
var (localTracksResponse, _) = await _proxyService.GetJsonAsync(
|
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
|
||||||
$"Playlists/{playlistId}/Items",
|
if (fileItems != null && fileItems.Count > 0)
|
||||||
null,
|
|
||||||
Request.Headers);
|
|
||||||
|
|
||||||
if (localTracksResponse != null &&
|
|
||||||
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
|
||||||
{
|
{
|
||||||
localTracksCount = localItems.GetArrayLength();
|
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count);
|
||||||
_logger.LogInformation("Found {Count} total items in Jellyfin playlist {Name}",
|
// Use file cache count directly
|
||||||
localTracksCount, playlistName);
|
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)
|
// Only fetch from Jellyfin if we didn't get count from file cache
|
||||||
var externalMatchedCount = 0;
|
if (!itemDict.ContainsKey("ChildCount") || (int)itemDict["ChildCount"]! == 0)
|
||||||
if (matchedTracks != null)
|
|
||||||
{
|
{
|
||||||
externalMatchedCount = matchedTracks.Count(t => t.MatchedSong != null && !t.MatchedSong.IsLocal);
|
// Get local tracks count from Jellyfin
|
||||||
}
|
var localTracksCount = 0;
|
||||||
|
try
|
||||||
// Total available tracks = what's actually in Jellyfin (local + external matched)
|
{
|
||||||
// This is what clients should see as the track count
|
var (localTracksResponse, _) = await _proxyService.GetJsonAsync(
|
||||||
var totalAvailableCount = localTracksCount;
|
$"Playlists/{playlistId}/Items",
|
||||||
|
null,
|
||||||
if (totalAvailableCount > 0)
|
Request.Headers);
|
||||||
{
|
|
||||||
// Update ChildCount to show actual available tracks
|
if (localTracksResponse != null &&
|
||||||
itemDict["ChildCount"] = totalAvailableCount;
|
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
|
||||||
modified = true;
|
{
|
||||||
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} (actual tracks in Jellyfin)",
|
localTracksCount = localItems.GetArrayLength();
|
||||||
playlistName, totalAvailableCount);
|
_logger.LogInformation("Found {Count} total items in Jellyfin playlist {Name}",
|
||||||
}
|
localTracksCount, playlistName);
|
||||||
else
|
}
|
||||||
{
|
}
|
||||||
_logger.LogWarning("No tracks found in Jellyfin for {Name}", 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
allstarr/Models/Settings/MusicBrainzSettings.cs
Normal file
21
allstarr/Models/Settings/MusicBrainzSettings.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -80,6 +80,15 @@ public class SpotifyImportSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int SyncWindowHours { get; set; } = 2;
|
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>
|
/// <summary>
|
||||||
/// Combined playlist configuration as JSON array.
|
/// Combined playlist configuration as JSON array.
|
||||||
/// Format: [["Name","Id","first|last"],...]
|
/// Format: [["Name","Id","first|last"],...]
|
||||||
|
|||||||
@@ -455,6 +455,9 @@ else if (musicService == MusicService.SquidWTF)
|
|||||||
squidWtfApiUrls));
|
squidWtfApiUrls));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register ParallelMetadataService to race all registered providers for faster searches
|
||||||
|
builder.Services.AddSingleton<ParallelMetadataService>();
|
||||||
|
|
||||||
// Startup validation - register validators based on backend
|
// Startup validation - register validators based on backend
|
||||||
if (backendType == BackendType.Jellyfin)
|
if (backendType == BackendType.Jellyfin)
|
||||||
{
|
{
|
||||||
@@ -479,6 +482,9 @@ builder.Services.AddHostedService<StartupValidationOrchestrator>();
|
|||||||
// Register cache cleanup service (only runs when StorageMode is Cache)
|
// Register cache cleanup service (only runs when StorageMode is Cache)
|
||||||
builder.Services.AddHostedService<CacheCleanupService>();
|
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
|
// Register Spotify API client, lyrics service, and settings for direct API access
|
||||||
// Configure from environment variables with SPOTIFY_API_ prefix
|
// Configure from environment variables with SPOTIFY_API_ prefix
|
||||||
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
|
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
|
||||||
@@ -553,6 +559,35 @@ builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracks
|
|||||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
|
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<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 =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddDefaultPolicy(policy =>
|
options.AddDefaultPolicy(policy =>
|
||||||
|
|||||||
172
allstarr/Services/Common/CacheWarmingService.cs
Normal file
172
allstarr/Services/Common/CacheWarmingService.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,10 +76,10 @@ public static class FuzzyMatcher
|
|||||||
|
|
||||||
// Normalize different apostrophe types to standard apostrophe
|
// Normalize different apostrophe types to standard apostrophe
|
||||||
normalized = normalized
|
normalized = normalized
|
||||||
.Replace(''', '\'') // Right single quotation mark
|
.Replace("\u2019", "'") // Right single quotation mark (')
|
||||||
.Replace(''', '\'') // Left single quotation mark
|
.Replace("\u2018", "'") // Left single quotation mark (')
|
||||||
.Replace('`', '\'') // Grave accent
|
.Replace("`", "'") // Grave accent
|
||||||
.Replace('´', '\''); // Acute accent
|
.Replace("\u00B4", "'"); // Acute accent (´)
|
||||||
|
|
||||||
// Normalize whitespace
|
// Normalize whitespace
|
||||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
|
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
|
||||||
|
|||||||
228
allstarr/Services/Common/GenreEnrichmentService.cs
Normal file
228
allstarr/Services/Common/GenreEnrichmentService.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
135
allstarr/Services/Common/ParallelMetadataService.cs
Normal file
135
allstarr/Services/Common/ParallelMetadataService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -233,25 +233,74 @@ public class JellyfinResponseBuilder
|
|||||||
{
|
{
|
||||||
// Add " [S]" suffix to external song titles (S = streaming source)
|
// Add " [S]" suffix to external song titles (S = streaming source)
|
||||||
var songTitle = song.Title;
|
var songTitle = song.Title;
|
||||||
|
var artistName = song.Artist;
|
||||||
|
var albumName = song.Album;
|
||||||
|
var artistNames = song.Artists.ToList();
|
||||||
|
|
||||||
if (!song.IsLocal)
|
if (!song.IsLocal)
|
||||||
{
|
{
|
||||||
songTitle = $"{song.Title} [S]";
|
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?>
|
var item = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = song.Id,
|
|
||||||
["Name"] = songTitle,
|
["Name"] = songTitle,
|
||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Type"] = "Audio",
|
["Id"] = song.Id,
|
||||||
["MediaType"] = "Audio",
|
["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,
|
["IsFolder"] = false,
|
||||||
["Album"] = song.Album,
|
["Type"] = "Audio",
|
||||||
["AlbumId"] = song.AlbumId ?? song.Id,
|
["ChannelId"] = (object?)null,
|
||||||
["AlbumArtist"] = song.AlbumArtist ?? song.Artist,
|
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
||||||
["Artists"] = song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist },
|
? new[] { song.Genre }
|
||||||
["ArtistItems"] = song.Artists.Count > 0
|
: new string[0],
|
||||||
? song.Artists.Select((name, index) => new Dictionary<string, object?>
|
["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,
|
["Name"] = name,
|
||||||
["Id"] = index == 0 && song.ArtistId != null
|
["Id"] = index == 0 && song.ArtistId != null
|
||||||
@@ -263,30 +312,32 @@ public class JellyfinResponseBuilder
|
|||||||
new Dictionary<string, object?>
|
new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = song.ArtistId ?? song.Id,
|
["Id"] = song.ArtistId ?? song.Id,
|
||||||
["Name"] = song.Artist
|
["Name"] = artistName ?? ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
["IndexNumber"] = song.Track,
|
["Album"] = albumName,
|
||||||
["ParentIndexNumber"] = song.DiscNumber ?? 1,
|
["AlbumId"] = song.AlbumId ?? song.Id,
|
||||||
["ProductionYear"] = song.Year,
|
["AlbumPrimaryImageTag"] = song.AlbumId ?? song.Id,
|
||||||
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
["AlbumArtist"] = song.AlbumArtist ?? artistName,
|
||||||
|
["AlbumArtists"] = new[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Name"] = song.AlbumArtist ?? artistName ?? "",
|
||||||
|
["Id"] = song.ArtistId ?? song.Id
|
||||||
|
}
|
||||||
|
},
|
||||||
["ImageTags"] = new Dictionary<string, string>
|
["ImageTags"] = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["Primary"] = song.Id
|
["Primary"] = song.Id
|
||||||
},
|
},
|
||||||
["BackdropImageTags"] = new string[0],
|
["BackdropImageTags"] = new string[0],
|
||||||
|
["ParentLogoImageTag"] = song.AlbumId ?? song.Id,
|
||||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||||
["LocationType"] = "FileSystem", // External content appears as local files to clients
|
["LocationType"] = "FileSystem",
|
||||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac", // Fake path for client compatibility
|
["MediaType"] = "Audio",
|
||||||
["ChannelId"] = (object?)null, // Match Jellyfin structure
|
["NormalizationGain"] = 0.0,
|
||||||
["UserData"] = new Dictionary<string, object>
|
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||||
{
|
|
||||||
["PlaybackPositionTicks"] = 0,
|
|
||||||
["PlayCount"] = 0,
|
|
||||||
["IsFavorite"] = false,
|
|
||||||
["Played"] = false,
|
|
||||||
["Key"] = $"Audio-{song.Id}"
|
|
||||||
},
|
|
||||||
["CanDownload"] = true,
|
["CanDownload"] = true,
|
||||||
["SupportsSync"] = true
|
["SupportsSync"] = true
|
||||||
};
|
};
|
||||||
@@ -305,21 +356,71 @@ public class JellyfinResponseBuilder
|
|||||||
providerIds["ISRC"] = song.Isrc;
|
providerIds["ISRC"] = song.Isrc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add MediaSources with bitrate for external tracks
|
// Add MediaSources with complete structure matching real Jellyfin
|
||||||
item["MediaSources"] = new[]
|
item["MediaSources"] = new[]
|
||||||
{
|
{
|
||||||
new Dictionary<string, object?>
|
new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
|
["Protocol"] = "File",
|
||||||
["Id"] = song.Id,
|
["Id"] = song.Id,
|
||||||
|
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||||
["Type"] = "Default",
|
["Type"] = "Default",
|
||||||
["Container"] = "flac",
|
["Container"] = "flac",
|
||||||
["Size"] = (song.Duration ?? 180) * 1337 * 128, // Approximate file size
|
["Size"] = (song.Duration ?? 180) * 1337 * 128,
|
||||||
["Bitrate"] = 1337000, // 1337 kbps in bps
|
["Name"] = song.Title,
|
||||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
["IsRemote"] = false,
|
||||||
["Protocol"] = "File",
|
["ETag"] = song.Id, // Use song ID as ETag
|
||||||
["SupportsDirectStream"] = true,
|
["RunTimeTicks"] = (song.Duration ?? 180) * 10000000L,
|
||||||
|
["ReadAtNativeFramerate"] = false,
|
||||||
|
["IgnoreDts"] = false,
|
||||||
|
["IgnoreIndex"] = false,
|
||||||
|
["GenPtsInput"] = false,
|
||||||
["SupportsTranscoding"] = true,
|
["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"];
|
item["MediaSources"] = song.JellyfinMetadata["MediaSources"];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(song.Genre))
|
|
||||||
{
|
|
||||||
item["Genres"] = new[] { song.Genre };
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,40 +447,68 @@ public class JellyfinResponseBuilder
|
|||||||
|
|
||||||
var item = new Dictionary<string, object?>
|
var item = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = album.Id,
|
|
||||||
["Name"] = albumName,
|
["Name"] = albumName,
|
||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Type"] = "MusicAlbum",
|
["Id"] = album.Id,
|
||||||
["IsFolder"] = true,
|
["PremiereDate"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : null,
|
||||||
["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,
|
|
||||||
["ChannelId"] = (object?)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>
|
["UserData"] = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["PlaybackPositionTicks"] = 0,
|
["PlaybackPositionTicks"] = 0,
|
||||||
["PlayCount"] = 0,
|
["PlayCount"] = 0,
|
||||||
["IsFavorite"] = false,
|
["IsFavorite"] = false,
|
||||||
["Played"] = 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
|
// 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;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,30 +537,33 @@ public class JellyfinResponseBuilder
|
|||||||
|
|
||||||
var item = new Dictionary<string, object?>
|
var item = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = artist.Id,
|
|
||||||
["Name"] = artistName,
|
["Name"] = artistName,
|
||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Type"] = "MusicArtist",
|
["Id"] = artist.Id,
|
||||||
|
["ChannelId"] = (object?)null,
|
||||||
|
["Genres"] = new string[0], // Artists aggregate genres from albums/tracks
|
||||||
|
["RunTimeTicks"] = 0,
|
||||||
["IsFolder"] = true,
|
["IsFolder"] = true,
|
||||||
["AlbumCount"] = artist.AlbumCount ?? 0,
|
["Type"] = "MusicArtist",
|
||||||
["ImageTags"] = new Dictionary<string, string>
|
["GenreItems"] = new Dictionary<string, object?>[0],
|
||||||
{
|
|
||||||
["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
|
|
||||||
["UserData"] = new Dictionary<string, object>
|
["UserData"] = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
["PlaybackPositionTicks"] = 0,
|
["PlaybackPositionTicks"] = 0,
|
||||||
["PlayCount"] = 0,
|
["PlayCount"] = 0,
|
||||||
["IsFavorite"] = false,
|
["IsFavorite"] = false,
|
||||||
["Played"] = 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
|
// Add provider IDs for external content
|
||||||
@@ -478,7 +600,7 @@ public class JellyfinResponseBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts an ExternalPlaylist to a Jellyfin album item.
|
/// Converts an ExternalPlaylist to a Jellyfin playlist item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, object?> ConvertPlaylistToJellyfinItem(ExternalPlaylist playlist)
|
public Dictionary<string, object?> ConvertPlaylistToJellyfinItem(ExternalPlaylist playlist)
|
||||||
{
|
{
|
||||||
@@ -488,13 +610,24 @@ public class JellyfinResponseBuilder
|
|||||||
|
|
||||||
var item = new Dictionary<string, object?>
|
var item = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Id"] = playlist.Id,
|
|
||||||
["Name"] = playlist.Name,
|
["Name"] = playlist.Name,
|
||||||
["ServerId"] = "allstarr",
|
["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,
|
["IsFolder"] = true,
|
||||||
["AlbumArtist"] = curatorName,
|
["Type"] = "Playlist",
|
||||||
["Genres"] = new[] { "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,
|
["ChildCount"] = playlist.TrackCount,
|
||||||
["ImageTags"] = new Dictionary<string, string>
|
["ImageTags"] = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
@@ -503,20 +636,10 @@ public class JellyfinResponseBuilder
|
|||||||
["BackdropImageTags"] = new string[0],
|
["BackdropImageTags"] = new string[0],
|
||||||
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
["ImageBlurHashes"] = new Dictionary<string, object>(),
|
||||||
["LocationType"] = "FileSystem",
|
["LocationType"] = "FileSystem",
|
||||||
["MediaType"] = (object?)null,
|
["MediaType"] = "Audio",
|
||||||
["ChannelId"] = (object?)null,
|
|
||||||
["CollectionType"] = (object?)null,
|
|
||||||
["ProviderIds"] = new Dictionary<string, string>
|
["ProviderIds"] = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
[playlist.Provider] = playlist.ExternalId
|
[playlist.Provider] = playlist.ExternalId
|
||||||
},
|
|
||||||
["UserData"] = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["PlaybackPositionTicks"] = 0,
|
|
||||||
["PlayCount"] = 0,
|
|
||||||
["IsFavorite"] = false,
|
|
||||||
["Played"] = false,
|
|
||||||
["Key"] = playlist.Id
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
342
allstarr/Services/MusicBrainz/MusicBrainzService.cs
Normal file
342
allstarr/Services/MusicBrainz/MusicBrainzService.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
|
|||||||
using allstarr.Models.Spotify;
|
using allstarr.Models.Spotify;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using allstarr.Services.Jellyfin;
|
using allstarr.Services.Jellyfin;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -72,8 +73,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Now start the periodic matching loop
|
// Now start the periodic matching loop
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// Wait 30 minutes before next run
|
// Wait for configured interval before next run (default 24 hours)
|
||||||
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
|
var intervalHours = _spotifySettings.MatchingIntervalHours;
|
||||||
|
if (intervalHours <= 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Periodic matching disabled (MatchingIntervalHours = {Hours}), only startup run will execute", intervalHours);
|
||||||
|
break; // Exit loop - only run once on startup
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -294,12 +302,32 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
// Check cache - use snapshot/timestamp to detect changes
|
// Check cache - use snapshot/timestamp to detect changes
|
||||||
var existingMatched = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
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",
|
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
||||||
playlistName, existingMatched.Count);
|
playlistName, existingMatched.Count);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasManualMappings)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName);
|
||||||
|
}
|
||||||
|
|
||||||
var matchedTracks = new List<MatchedTrack>();
|
var matchedTracks = new List<MatchedTrack>();
|
||||||
var isrcMatches = 0;
|
var isrcMatches = 0;
|
||||||
@@ -365,20 +393,20 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
||||||
matchType, matchedSong.Title);
|
matchType, matchedSong.Title);
|
||||||
|
|
||||||
return (matched, matchType);
|
return ((MatchedTrack?)matched, matchType);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
||||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||||
return (null, "none");
|
return ((MatchedTrack?)null, "none");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||||
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||||
return (null, "none");
|
return ((MatchedTrack?)null, "none");
|
||||||
}
|
}
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@@ -386,8 +414,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var batchResults = await Task.WhenAll(batchTasks);
|
var batchResults = await Task.WhenAll(batchTasks);
|
||||||
|
|
||||||
// Collect results
|
// Collect results
|
||||||
foreach (var (matched, matchType) in batchResults)
|
foreach (var result in batchResults)
|
||||||
{
|
{
|
||||||
|
var (matched, matchType) = result;
|
||||||
if (matched != null)
|
if (matched != null)
|
||||||
{
|
{
|
||||||
matchedTracks.Add(matched);
|
matchedTracks.Add(matched);
|
||||||
@@ -420,6 +449,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch})",
|
"✓ Cached {Matched}/{Total} tracks for {Playlist} (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch})",
|
||||||
matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, 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
|
else
|
||||||
{
|
{
|
||||||
@@ -649,4 +681,236 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
return avgScore;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"SyncStartHour": 16,
|
"SyncStartHour": 16,
|
||||||
"SyncStartMinute": 15,
|
"SyncStartMinute": 15,
|
||||||
"SyncWindowHours": 2,
|
"SyncWindowHours": 2,
|
||||||
|
"MatchingIntervalHours": 24,
|
||||||
"Playlists": []
|
"Playlists": []
|
||||||
},
|
},
|
||||||
"SpotifyApi": {
|
"SpotifyApi": {
|
||||||
@@ -58,5 +59,12 @@
|
|||||||
"CacheDurationMinutes": 60,
|
"CacheDurationMinutes": 60,
|
||||||
"RateLimitDelayMs": 100,
|
"RateLimitDelayMs": 100,
|
||||||
"PreferIsrcMatching": true
|
"PreferIsrcMatching": true
|
||||||
|
},
|
||||||
|
"MusicBrainz": {
|
||||||
|
"Enabled": true,
|
||||||
|
"Username": "",
|
||||||
|
"Password": "",
|
||||||
|
"BaseUrl": "https://musicbrainz.org/ws/2",
|
||||||
|
"RateLimitMs": 1000
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,8 @@
|
|||||||
|
|
||||||
.toast.success { border-color: var(--success); }
|
.toast.success { border-color: var(--success); }
|
||||||
.toast.error { border-color: var(--error); }
|
.toast.error { border-color: var(--error); }
|
||||||
|
.toast.warning { border-color: var(--warning); }
|
||||||
|
.toast.info { border-color: var(--accent); }
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
from { transform: translateX(100%); opacity: 0; }
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
@@ -734,6 +736,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="card">
|
||||||
<h2>Qobuz Settings</h2>
|
<h2>Qobuz Settings</h2>
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
@@ -792,6 +815,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</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);">
|
<div class="card" style="background: rgba(248, 81, 73, 0.1); border-color: var(--error);">
|
||||||
<h2 style="color: var(--error);">Danger Zone</h2>
|
<h2 style="color: var(--error);">Danger Zone</h2>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
@@ -985,12 +1020,12 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Toast notification
|
// Toast notification
|
||||||
function showToast(message, type = 'success') {
|
function showToast(message, type = 'success', duration = 3000) {
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = 'toast ' + type;
|
toast.className = 'toast ' + type;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
document.body.appendChild(toast);
|
document.body.appendChild(toast);
|
||||||
setTimeout(() => toast.remove(), 3000);
|
setTimeout(() => toast.remove(), duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal helpers
|
// Modal helpers
|
||||||
@@ -1143,6 +1178,16 @@
|
|||||||
const externalMissing = p.externalMissing || 0;
|
const externalMissing = p.externalMissing || 0;
|
||||||
const totalInJellyfin = p.totalInJellyfin || 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
|
// Build detailed stats string
|
||||||
let statsHtml = `<span class="track-count">${spotifyTotal}</span>`;
|
let statsHtml = `<span class="track-count">${spotifyTotal}</span>`;
|
||||||
|
|
||||||
@@ -1164,8 +1209,14 @@
|
|||||||
|
|
||||||
// Calculate completion percentage
|
// Calculate completion percentage
|
||||||
const completionPct = spotifyTotal > 0 ? Math.round((totalInJellyfin / spotifyTotal) * 100) : 0;
|
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)';
|
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 `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${escapeHtml(p.name)}</strong></td>
|
<td><strong>${escapeHtml(p.name)}</strong></td>
|
||||||
@@ -1173,8 +1224,10 @@
|
|||||||
<td>${statsHtml}${breakdown}</td>
|
<td>${statsHtml}${breakdown}</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;align-items:center;gap:8px;">
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
<div style="flex:1;background:var(--bg-tertiary);height:6px;border-radius:3px;overflow:hidden;">
|
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;">
|
||||||
<div style="width:${completionPct}%;height:100%;background:${completionColor};transition:width 0.3s;"></div>
|
<div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div>
|
||||||
|
<div style="width:${externalPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMatched} external matched tracks"></div>
|
||||||
|
<div style="width:${missingPct}%;height:100%;background:#6b7280;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
|
||||||
</div>
|
</div>
|
||||||
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1220,6 +1273,11 @@
|
|||||||
// SquidWTF settings
|
// SquidWTF settings
|
||||||
document.getElementById('config-squid-quality').textContent = data.squidWtf.quality;
|
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
|
// Qobuz settings
|
||||||
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
|
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
|
||||||
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
|
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
|
||||||
@@ -1462,7 +1520,7 @@
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast(`✓ ${data.message}`, 'success');
|
showToast(`✓ ${data.message}`, 'success');
|
||||||
// Refresh the playlists table after a delay to show updated counts
|
// Refresh the playlists table after a delay to show updated counts
|
||||||
setTimeout(fetchPlaylists, 3000);
|
setTimeout(fetchPlaylists, 2000);
|
||||||
} else {
|
} else {
|
||||||
showToast(data.error || 'Failed to match tracks', 'error');
|
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() {
|
async function clearCache() {
|
||||||
if (!confirm('Clear all cached playlist data?')) return;
|
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() {
|
async function restartContainer() {
|
||||||
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
|
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
|
||||||
return;
|
return;
|
||||||
@@ -1624,7 +1750,8 @@
|
|||||||
if (t.isLocal === true) {
|
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>';
|
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) {
|
} 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
|
// Add manual map button for external tracks using data attributes
|
||||||
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
|
||||||
mapButton = `<button class="small map-track-btn"
|
mapButton = `<button class="small map-track-btn"
|
||||||
@@ -1634,10 +1761,22 @@
|
|||||||
data-artist="${escapeHtml(firstArtist)}"
|
data-artist="${escapeHtml(firstArtist)}"
|
||||||
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
|
||||||
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>`;
|
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 `
|
return `
|
||||||
<div class="track-item">
|
<div class="track-item" data-position="${t.position}">
|
||||||
<span class="track-position">${t.position + 1}</span>
|
<span class="track-position">${t.position + 1}</span>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<h4>${escapeHtml(t.title)}${statusBadge}${mapButton}</h4>
|
<h4>${escapeHtml(t.title)}${statusBadge}${mapButton}</h4>
|
||||||
@@ -1646,6 +1785,7 @@
|
|||||||
<div class="track-meta">
|
<div class="track-meta">
|
||||||
${t.album ? escapeHtml(t.album) : ''}
|
${t.album ? escapeHtml(t.album) : ''}
|
||||||
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
|
${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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1881,10 +2021,12 @@
|
|||||||
// Remove selection from all tracks
|
// Remove selection from all tracks
|
||||||
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
|
document.querySelectorAll('#map-search-results .track-item').forEach(el => {
|
||||||
el.style.border = '2px solid transparent';
|
el.style.border = '2px solid transparent';
|
||||||
|
el.style.background = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Highlight selected track
|
// 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
|
// Store selected ID and enable save button
|
||||||
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
|
document.getElementById('map-selected-jellyfin-id').value = jellyfinId;
|
||||||
@@ -1895,31 +2037,133 @@
|
|||||||
const playlistName = document.getElementById('map-playlist-name').value;
|
const playlistName = document.getElementById('map-playlist-name').value;
|
||||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||||
const jellyfinId = document.getElementById('map-selected-jellyfin-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) {
|
if (!jellyfinId) {
|
||||||
showToast('Please select a track', 'error');
|
showToast('Please select a track', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const saveBtn = document.getElementById('map-save-btn');
|
||||||
|
const originalText = saveBtn.textContent;
|
||||||
|
saveBtn.textContent = 'Saving...';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||||
|
|
||||||
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
|
const res = await fetch('/api/admin/playlists/' + encodeURIComponent(playlistName) + '/map', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
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');
|
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 {
|
} else {
|
||||||
showToast(data.error || 'Failed to save mapping', 'error');
|
showToast(data.error || 'Failed to save mapping', 'error');
|
||||||
}
|
}
|
||||||
} catch (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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user