mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
feat: spotify playlist injection
- Add SpotifyImportSettings configuration model - Create SpotifyMissingTracksFetcher background service - Inject virtual Spotify playlists into search results - Auto-match tracks from external providers - Update README with feature documentation - Configure sync window and playlist settings
This commit is contained in:
33
.env.example
33
.env.example
@@ -95,3 +95,36 @@ STORAGE_MODE=Permanent
|
|||||||
# Based on last access time (updated each time the file is streamed)
|
# Based on last access time (updated each time the file is streamed)
|
||||||
# Cache location: /tmp/allstarr-cache (or $TMPDIR/allstarr-cache if TMPDIR is set)
|
# Cache location: /tmp/allstarr-cache (or $TMPDIR/allstarr-cache if TMPDIR is set)
|
||||||
CACHE_DURATION_HOURS=1
|
CACHE_DURATION_HOURS=1
|
||||||
|
|
||||||
|
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
|
||||||
|
# REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import)
|
||||||
|
# This feature injects virtual Spotify playlists (Release Radar, Discover Weekly) into Jellyfin
|
||||||
|
# with tracks auto-matched from external providers (SquidWTF, Deezer, Qobuz)
|
||||||
|
|
||||||
|
# Enable Spotify playlist injection (optional, default: false)
|
||||||
|
SPOTIFY_IMPORT_ENABLED=false
|
||||||
|
|
||||||
|
# Jellyfin server URL (required if SPOTIFY_IMPORT_ENABLED=true)
|
||||||
|
# Should match your JELLYFIN_URL unless using a different URL for plugin access
|
||||||
|
SPOTIFY_IMPORT_JELLYFIN_URL=http://localhost:8096
|
||||||
|
|
||||||
|
# Jellyfin API key (REQUIRED if SPOTIFY_IMPORT_ENABLED=true)
|
||||||
|
# Get from Jellyfin Dashboard > API Keys > Create new key
|
||||||
|
# This is used to fetch missing tracks files from the Spotify Import plugin
|
||||||
|
SPOTIFY_IMPORT_API_KEY=
|
||||||
|
|
||||||
|
# Sync schedule: When does the Spotify Import plugin run?
|
||||||
|
# Set these to match your plugin's sync schedule in Jellyfin
|
||||||
|
# Example: If plugin runs daily at 4:15 PM, set HOUR=16 and MINUTE=15
|
||||||
|
SPOTIFY_IMPORT_SYNC_START_HOUR=16
|
||||||
|
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
|
||||||
|
|
||||||
|
# Sync window: How long to search for missing tracks files (in hours)
|
||||||
|
# The fetcher will check every 5 minutes within this window
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Playlists to inject (comma-separated, optional)
|
||||||
|
# Available: Release Radar, Discover Weekly
|
||||||
|
# Leave empty to disable all, or specify which ones to enable
|
||||||
|
SPOTIFY_IMPORT_PLAYLISTS=Release Radar,Discover Weekly
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -90,6 +90,7 @@ This project brings together all the music streaming providers into one unified
|
|||||||
- **Artist Deduplication**: Merges local and streaming artists to avoid duplicates
|
- **Artist Deduplication**: Merges local and streaming artists to avoid duplicates
|
||||||
- **Album Enrichment**: Adds missing tracks to local albums from streaming providers
|
- **Album Enrichment**: Adds missing tracks to local albums from streaming providers
|
||||||
- **Cover Art Proxy**: Serves cover art for external content
|
- **Cover Art Proxy**: Serves cover art for external content
|
||||||
|
- **Spotify Playlist Injection** (Jellyfin only): Injects virtual Spotify playlists (Release Radar, Discover Weekly) with tracks auto-matched from streaming providers
|
||||||
|
|
||||||
## Supported Backends
|
## Supported Backends
|
||||||
|
|
||||||
@@ -287,6 +288,47 @@ Subsonic__EnableExternalPlaylists=false
|
|||||||
|
|
||||||
> **Note**: Due to client-side filtering, playlists from streaming providers may not appear in the "Playlists" tab of some clients, but will show up in global search results.
|
> **Note**: Due to client-side filtering, playlists from streaming providers may not appear in the "Playlists" tab of some clients, but will show up in global search results.
|
||||||
|
|
||||||
|
### Spotify Playlist Injection (Jellyfin Only)
|
||||||
|
|
||||||
|
Allstarr can inject virtual Spotify playlists (Release Radar, Discover Weekly) into Jellyfin with tracks automatically matched from your configured streaming provider.
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- [Jellyfin Spotify Import Plugin](https://github.com/Viperinius/jellyfin-plugin-spotify-import) installed and configured
|
||||||
|
- Plugin must run on a daily schedule (e.g., 4:15 PM daily)
|
||||||
|
- Jellyfin API key with access to plugin endpoints
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
|
||||||
|
| `SpotifyImport:JellyfinUrl` | Jellyfin server URL for plugin access |
|
||||||
|
| `SpotifyImport:ApiKey` | **REQUIRED** - Jellyfin API key for accessing missing tracks files |
|
||||||
|
| `SpotifyImport:SyncStartHour` | Hour when plugin runs (24-hour format, 0-23) |
|
||||||
|
| `SpotifyImport:SyncStartMinute` | Minute when plugin runs (0-59) |
|
||||||
|
| `SpotifyImport:SyncWindowHours` | Hours to search for missing tracks files after sync time |
|
||||||
|
| `SpotifyImport:Playlists` | Array of playlists to inject (Name, SpotifyName, Enabled) |
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Jellyfin Spotify Import plugin runs daily and creates missing tracks files for playlists
|
||||||
|
2. Allstarr fetches these files within the configured time window
|
||||||
|
3. For each missing track, Allstarr searches your streaming provider (SquidWTF, Deezer, or Qobuz)
|
||||||
|
4. Virtual playlists appear in Jellyfin with matched tracks ready to stream
|
||||||
|
5. Tracks are downloaded on-demand when played
|
||||||
|
|
||||||
|
**Environment variables:**
|
||||||
|
```bash
|
||||||
|
SPOTIFY_IMPORT_ENABLED=true
|
||||||
|
SPOTIFY_IMPORT_JELLYFIN_URL=http://localhost:8096
|
||||||
|
SPOTIFY_IMPORT_API_KEY=your-jellyfin-api-key
|
||||||
|
SPOTIFY_IMPORT_SYNC_START_HOUR=16
|
||||||
|
SPOTIFY_IMPORT_SYNC_START_MINUTE=15
|
||||||
|
SPOTIFY_IMPORT_SYNC_WINDOW_HOURS=2
|
||||||
|
SPOTIFY_IMPORT_PLAYLISTS=Release Radar,Discover Weekly
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: This feature only works with Jellyfin backend. The plugin must be configured to run on a schedule, and the sync window should cover the plugin's execution time.
|
||||||
|
|
||||||
### Getting Credentials
|
### Getting Credentials
|
||||||
|
|
||||||
#### Deezer ARL Token
|
#### Deezer ARL Token
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ namespace allstarr.Controllers;
|
|||||||
public class JellyfinController : ControllerBase
|
public class JellyfinController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly JellyfinSettings _settings;
|
private readonly JellyfinSettings _settings;
|
||||||
|
private readonly SpotifyImportSettings _spotifySettings;
|
||||||
private readonly IMusicMetadataService _metadataService;
|
private readonly IMusicMetadataService _metadataService;
|
||||||
private readonly ILocalLibraryService _localLibraryService;
|
private readonly ILocalLibraryService _localLibraryService;
|
||||||
private readonly IDownloadService _downloadService;
|
private readonly IDownloadService _downloadService;
|
||||||
@@ -29,20 +30,24 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly JellyfinModelMapper _modelMapper;
|
private readonly JellyfinModelMapper _modelMapper;
|
||||||
private readonly JellyfinProxyService _proxyService;
|
private readonly JellyfinProxyService _proxyService;
|
||||||
private readonly PlaylistSyncService? _playlistSyncService;
|
private readonly PlaylistSyncService? _playlistSyncService;
|
||||||
|
private readonly RedisCacheService _cache;
|
||||||
private readonly ILogger<JellyfinController> _logger;
|
private readonly ILogger<JellyfinController> _logger;
|
||||||
|
|
||||||
public JellyfinController(
|
public JellyfinController(
|
||||||
IOptions<JellyfinSettings> settings,
|
IOptions<JellyfinSettings> settings,
|
||||||
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
ILocalLibraryService localLibraryService,
|
ILocalLibraryService localLibraryService,
|
||||||
IDownloadService downloadService,
|
IDownloadService downloadService,
|
||||||
JellyfinResponseBuilder responseBuilder,
|
JellyfinResponseBuilder responseBuilder,
|
||||||
JellyfinModelMapper modelMapper,
|
JellyfinModelMapper modelMapper,
|
||||||
JellyfinProxyService proxyService,
|
JellyfinProxyService proxyService,
|
||||||
|
RedisCacheService cache,
|
||||||
ILogger<JellyfinController> logger,
|
ILogger<JellyfinController> logger,
|
||||||
PlaylistSyncService? playlistSyncService = null)
|
PlaylistSyncService? playlistSyncService = null)
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
|
_spotifySettings = spotifySettings.Value;
|
||||||
_metadataService = metadataService;
|
_metadataService = metadataService;
|
||||||
_localLibraryService = localLibraryService;
|
_localLibraryService = localLibraryService;
|
||||||
_downloadService = downloadService;
|
_downloadService = downloadService;
|
||||||
@@ -50,6 +55,7 @@ public class JellyfinController : ControllerBase
|
|||||||
_modelMapper = modelMapper;
|
_modelMapper = modelMapper;
|
||||||
_proxyService = proxyService;
|
_proxyService = proxyService;
|
||||||
_playlistSyncService = playlistSyncService;
|
_playlistSyncService = playlistSyncService;
|
||||||
|
_cache = cache;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(_settings.Url))
|
if (string.IsNullOrWhiteSpace(_settings.Url))
|
||||||
@@ -214,6 +220,13 @@ public class JellyfinController : ControllerBase
|
|||||||
mergedAlbums.AddRange(scoredPlaylists);
|
mergedAlbums.AddRange(scoredPlaylists);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject Spotify playlists if enabled
|
||||||
|
if (_spotifySettings.Enabled)
|
||||||
|
{
|
||||||
|
var spotifyPlaylists = await GetSpotifyPlaylistsAsync(cleanQuery);
|
||||||
|
mergedAlbums.AddRange(spotifyPlaylists);
|
||||||
|
}
|
||||||
|
|
||||||
_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);
|
||||||
|
|
||||||
@@ -1251,6 +1264,12 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Check if this is a Spotify playlist
|
||||||
|
if (playlistId.StartsWith("spotify-"))
|
||||||
|
{
|
||||||
|
return await GetSpotifyPlaylistTracksAsync(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||||
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
|
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
|
||||||
|
|
||||||
@@ -1827,5 +1846,118 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Spotify Playlist Injection
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets Spotify playlists that have cached missing tracks.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<Dictionary<string, object?>>> GetSpotifyPlaylistsAsync(string searchQuery)
|
||||||
|
{
|
||||||
|
var playlists = new List<Dictionary<string, object?>>();
|
||||||
|
|
||||||
|
foreach (var playlist in _spotifySettings.Playlists.Where(p => p.Enabled))
|
||||||
|
{
|
||||||
|
var cacheKey = $"spotify:missing:{playlist.SpotifyName}";
|
||||||
|
var hasTracks = await _cache.ExistsAsync(cacheKey);
|
||||||
|
|
||||||
|
if (!hasTracks) continue;
|
||||||
|
|
||||||
|
var score = string.IsNullOrWhiteSpace(searchQuery)
|
||||||
|
? 100
|
||||||
|
: FuzzyMatcher.CalculateSimilarity(searchQuery, playlist.Name);
|
||||||
|
|
||||||
|
if (score < 30 && !string.IsNullOrWhiteSpace(searchQuery)) continue;
|
||||||
|
|
||||||
|
playlists.Add(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Id"] = $"spotify-{playlist.SpotifyName.ToLower().Replace(" ", "-")}",
|
||||||
|
["Name"] = $"{playlist.Name} - S",
|
||||||
|
["Type"] = "Playlist",
|
||||||
|
["IsFolder"] = false,
|
||||||
|
["MediaType"] = "Audio",
|
||||||
|
["CollectionType"] = "music"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string playlistId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var playlistName = playlistId.Replace("spotify-", "").Replace("-", " ");
|
||||||
|
|
||||||
|
var matchingPlaylist = _spotifySettings.Playlists
|
||||||
|
.FirstOrDefault(p => p.SpotifyName.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (matchingPlaylist == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Spotify playlist not found in config: {PlaylistName}", playlistName);
|
||||||
|
return _responseBuilder.CreateItemsResponse(new List<Song>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheKey = $"spotify:matched:{matchingPlaylist.SpotifyName}";
|
||||||
|
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
|
||||||
|
|
||||||
|
if (cachedTracks != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Returning {Count} cached matched tracks for {Playlist}",
|
||||||
|
cachedTracks.Count, matchingPlaylist.Name);
|
||||||
|
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
var missingTracksKey = $"spotify:missing:{matchingPlaylist.SpotifyName}";
|
||||||
|
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
|
||||||
|
|
||||||
|
if (missingTracks == null || missingTracks.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No missing tracks found for {Playlist}", matchingPlaylist.Name);
|
||||||
|
return _responseBuilder.CreateItemsResponse(new List<Song>());
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Matching {Count} tracks for {Playlist}",
|
||||||
|
missingTracks.Count, matchingPlaylist.Name);
|
||||||
|
|
||||||
|
var matchTasks = missingTracks.Select(async track =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var query = $"{track.Title} {track.AllArtists} {track.Album}";
|
||||||
|
var results = await _metadataService.SearchSongsAsync(query, limit: 1);
|
||||||
|
return results.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||||
|
track.Title, track.PrimaryArtist);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var matchedTracks = (await Task.WhenAll(matchTasks))
|
||||||
|
.Where(t => t != null)
|
||||||
|
.Cast<Song>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await _cache.SetAsync(cacheKey, matchedTracks, TimeSpan.FromHours(1));
|
||||||
|
|
||||||
|
_logger.LogInformation("Matched {Matched}/{Total} tracks for {Playlist}",
|
||||||
|
matchedTracks.Count, missingTracks.Count, matchingPlaylist.Name);
|
||||||
|
|
||||||
|
return _responseBuilder.CreateItemsResponse(matchedTracks);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistId}", playlistId);
|
||||||
|
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
||||||
|
|||||||
69
allstarr/Models/Settings/SpotifyImportSettings.cs
Normal file
69
allstarr/Models/Settings/SpotifyImportSettings.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
namespace allstarr.Models.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for Spotify playlist injection feature.
|
||||||
|
/// Requires Jellyfin Spotify Import Plugin: https://github.com/Viperinius/jellyfin-plugin-spotify-import
|
||||||
|
/// </summary>
|
||||||
|
public class SpotifyImportSettings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Enable Spotify playlist injection feature
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Jellyfin server URL (for accessing plugin API)
|
||||||
|
/// </summary>
|
||||||
|
public string JellyfinUrl { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Jellyfin API key (REQUIRED for accessing missing tracks files)
|
||||||
|
/// Get from Jellyfin Dashboard > API Keys
|
||||||
|
/// </summary>
|
||||||
|
public string ApiKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hour when Spotify Import plugin runs (24-hour format, 0-23)
|
||||||
|
/// Example: 16 for 4:00 PM
|
||||||
|
/// </summary>
|
||||||
|
public int SyncStartHour { get; set; } = 16;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minute when Spotify Import plugin runs (0-59)
|
||||||
|
/// Example: 15 for 4:15 PM
|
||||||
|
/// </summary>
|
||||||
|
public int SyncStartMinute { get; set; } = 15;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many hours to search for missing tracks files after sync start time
|
||||||
|
/// Example: 2 means search from 4:00 PM to 6:00 PM
|
||||||
|
/// </summary>
|
||||||
|
public int SyncWindowHours { get; set; } = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List of playlists to inject
|
||||||
|
/// </summary>
|
||||||
|
public List<SpotifyPlaylistConfig> Playlists { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for a single Spotify playlist
|
||||||
|
/// </summary>
|
||||||
|
public class SpotifyPlaylistConfig
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Display name in Jellyfin (e.g., "Release Radar")
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playlist name in Spotify Import plugin missing tracks file
|
||||||
|
/// Must match exactly (e.g., "Release Radar")
|
||||||
|
/// </summary>
|
||||||
|
public string SpotifyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enable this playlist
|
||||||
|
/// </summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
}
|
||||||
12
allstarr/Models/Spotify/MissingTrack.cs
Normal file
12
allstarr/Models/Spotify/MissingTrack.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace allstarr.Models.Spotify;
|
||||||
|
|
||||||
|
public class MissingTrack
|
||||||
|
{
|
||||||
|
public string SpotifyId { get; set; } = string.Empty;
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string Album { get; set; } = string.Empty;
|
||||||
|
public List<string> Artists { get; set; } = new();
|
||||||
|
|
||||||
|
public string PrimaryArtist => Artists.FirstOrDefault() ?? "";
|
||||||
|
public string AllArtists => string.Join(", ", Artists);
|
||||||
|
}
|
||||||
@@ -108,6 +108,8 @@ builder.Services.Configure<SquidWTFSettings>(
|
|||||||
builder.Configuration.GetSection("SquidWTF"));
|
builder.Configuration.GetSection("SquidWTF"));
|
||||||
builder.Services.Configure<RedisSettings>(
|
builder.Services.Configure<RedisSettings>(
|
||||||
builder.Configuration.GetSection("Redis"));
|
builder.Configuration.GetSection("Redis"));
|
||||||
|
builder.Services.Configure<SpotifyImportSettings>(
|
||||||
|
builder.Configuration.GetSection("SpotifyImport"));
|
||||||
|
|
||||||
// Get shared settings from the active backend config
|
// Get shared settings from the active backend config
|
||||||
MusicService musicService;
|
MusicService musicService;
|
||||||
@@ -229,6 +231,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 Spotify missing tracks fetcher (only runs when SpotifyImport is enabled)
|
||||||
|
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>();
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddDefaultPolicy(policy =>
|
options.AddDefaultPolicy(policy =>
|
||||||
|
|||||||
167
allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs
Normal file
167
allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Models.Spotify;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
|
public class SpotifyMissingTracksFetcher : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IOptions<SpotifyImportSettings> _settings;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly RedisCacheService _cache;
|
||||||
|
private readonly ILogger<SpotifyMissingTracksFetcher> _logger;
|
||||||
|
|
||||||
|
public SpotifyMissingTracksFetcher(
|
||||||
|
IOptions<SpotifyImportSettings> settings,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
RedisCacheService cache,
|
||||||
|
ILogger<SpotifyMissingTracksFetcher> logger)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_cache = cache;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (!_settings.Value.Enabled)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Spotify playlist injection is disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Spotify missing tracks fetcher started");
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await FetchMissingTracksAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fetching Spotify missing tracks");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var settings = _settings.Value;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(settings.ApiKey))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Spotify import API key not configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var syncStart = now.Date
|
||||||
|
.AddHours(settings.SyncStartHour)
|
||||||
|
.AddMinutes(settings.SyncStartMinute);
|
||||||
|
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
|
||||||
|
|
||||||
|
if (now < syncStart || now > syncEnd)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var playlist in settings.Playlists.Where(p => p.Enabled))
|
||||||
|
{
|
||||||
|
await FetchPlaylistMissingTracksAsync(playlist, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FetchPlaylistMissingTracksAsync(
|
||||||
|
SpotifyPlaylistConfig playlist,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var cacheKey = $"spotify:missing:{playlist.SpotifyName}";
|
||||||
|
|
||||||
|
if (await _cache.ExistsAsync(cacheKey))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = _settings.Value;
|
||||||
|
var httpClient = _httpClientFactory.CreateClient();
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
var syncStart = today
|
||||||
|
.AddHours(settings.SyncStartHour)
|
||||||
|
.AddMinutes(settings.SyncStartMinute);
|
||||||
|
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
|
||||||
|
|
||||||
|
for (var time = syncStart; time <= syncEnd; time = time.AddMinutes(5))
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
var filename = $"{playlist.SpotifyName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
|
||||||
|
var url = $"{settings.JellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
|
||||||
|
$"?name={Uri.EscapeDataString(filename)}&api_key={settings.ApiKey}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await httpClient.GetAsync(url, cancellationToken);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var tracks = ParseMissingTracks(json);
|
||||||
|
|
||||||
|
if (tracks.Count > 0)
|
||||||
|
{
|
||||||
|
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Cached {Count} missing tracks for {Playlist} from {Filename}",
|
||||||
|
tracks.Count, playlist.Name, filename);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MissingTrack> ParseMissingTracks(string json)
|
||||||
|
{
|
||||||
|
var tracks = new List<MissingTrack>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
foreach (var item in doc.RootElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
var track = new MissingTrack
|
||||||
|
{
|
||||||
|
SpotifyId = item.GetProperty("Id").GetString() ?? "",
|
||||||
|
Title = item.GetProperty("Name").GetString() ?? "",
|
||||||
|
Album = item.GetProperty("AlbumName").GetString() ?? "",
|
||||||
|
Artists = item.GetProperty("ArtistNames")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(a => a.GetString() ?? "")
|
||||||
|
.Where(a => !string.IsNullOrEmpty(a))
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(track.Title))
|
||||||
|
{
|
||||||
|
tracks.Add(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to parse missing tracks JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,25 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"SpotifyImport": {
|
||||||
|
"Enabled": false,
|
||||||
|
"JellyfinUrl": "http://localhost:8096",
|
||||||
|
"ApiKey": "",
|
||||||
|
"SyncStartHour": 16,
|
||||||
|
"SyncStartMinute": 15,
|
||||||
|
"SyncWindowHours": 2,
|
||||||
|
"Playlists": [
|
||||||
|
{
|
||||||
|
"Name": "Release Radar",
|
||||||
|
"SpotifyName": "Release Radar",
|
||||||
|
"Enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Discover Weekly",
|
||||||
|
"SpotifyName": "Discover Weekly",
|
||||||
|
"Enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"Type": "Subsonic"
|
"Type": "Subsonic"
|
||||||
},
|
},
|
||||||
"Subsonic": {
|
"Subsonic": {
|
||||||
"Url": "https://navidrome.local.bransonb.com",
|
"Url": "http://localhost:4533",
|
||||||
"MusicService": "SquidWTF",
|
"MusicService": "SquidWTF",
|
||||||
"ExplicitFilter": "All",
|
"ExplicitFilter": "All",
|
||||||
"DownloadMode": "Track",
|
"DownloadMode": "Track",
|
||||||
@@ -42,5 +42,25 @@
|
|||||||
"Redis": {
|
"Redis": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"ConnectionString": "localhost:6379"
|
"ConnectionString": "localhost:6379"
|
||||||
|
},
|
||||||
|
"SpotifyImport": {
|
||||||
|
"Enabled": false,
|
||||||
|
"JellyfinUrl": "https://jellyfin.example.com",
|
||||||
|
"ApiKey": "",
|
||||||
|
"SyncStartHour": 16,
|
||||||
|
"SyncStartMinute": 15,
|
||||||
|
"SyncWindowHours": 2,
|
||||||
|
"Playlists": [
|
||||||
|
{
|
||||||
|
"Name": "Release Radar",
|
||||||
|
"SpotifyName": "Release Radar",
|
||||||
|
"Enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "Discover Weekly",
|
||||||
|
"SpotifyName": "Discover Weekly",
|
||||||
|
"Enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user