mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
36 Commits
62bfb367bc
...
74bd64c949
| Author | SHA1 | Date | |
|---|---|---|---|
|
74bd64c949
|
|||
|
1afa68064e
|
|||
|
5251c7ef6d
|
|||
|
63ab25ca91
|
|||
|
628f845e77
|
|||
|
8ef5ee7d8f
|
|||
|
fb3ea1b876
|
|||
|
3f3e1b708d
|
|||
|
bc4faead74
|
|||
|
6ffa2a3277
|
|||
|
c3c01b5559
|
|||
|
47d59ec0f5
|
|||
|
e7f72cd87a
|
|||
|
6d15d02f16
|
|||
|
3137cc4657
|
|||
|
18e700d6a4
|
|||
|
2420cd9a23
|
|||
|
65d6eb041a
|
|||
|
103808f079
|
|||
|
cd29e0de6c
|
|||
|
bd480be382
|
|||
|
293f6f5cc4
|
|||
|
e9b893eb3e
|
|||
|
51694a395d
|
|||
|
32166061ef
|
|||
|
a8845a9ef3
|
|||
|
e873cfe3bf
|
|||
|
43718eaefc
|
|||
|
5f9451f5b4
|
|||
|
2c3ef5c360
|
|||
|
4ba2245876
|
|||
|
c117fa41f6
|
|||
|
2b078453b2
|
|||
|
0ee1883ccb
|
|||
|
8912758b5e
|
|||
|
35d5249843
|
31
.env.example
31
.env.example
@@ -95,3 +95,34 @@ STORAGE_MODE=Permanent
|
||||
# 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_DURATION_HOURS=1
|
||||
|
||||
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
|
||||
# REQUIRES: Jellyfin Spotify Import Plugin (https://github.com/Viperinius/jellyfin-plugin-spotify-import)
|
||||
# This feature intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them
|
||||
# with tracks auto-matched from external providers (SquidWTF, Deezer, Qobuz)
|
||||
# Uses JELLYFIN_URL and JELLYFIN_API_KEY configured above (no separate credentials needed)
|
||||
|
||||
# Enable Spotify playlist injection (optional, default: false)
|
||||
SPOTIFY_IMPORT_ENABLED=false
|
||||
|
||||
# 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
|
||||
|
||||
# Playlist IDs to inject (comma-separated)
|
||||
# Get IDs from Jellyfin playlist URLs: https://jellyfin.example.com/web/#/details?id=PLAYLIST_ID
|
||||
# Example: SPOTIFY_IMPORT_PLAYLIST_IDS=4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0
|
||||
SPOTIFY_IMPORT_PLAYLIST_IDS=
|
||||
|
||||
# Playlist names (comma-separated, must match Spotify Import plugin format)
|
||||
# IMPORTANT: Use the exact playlist names as they appear in Jellyfin
|
||||
# Must be in same order as SPOTIFY_IMPORT_PLAYLIST_IDS
|
||||
# Example: SPOTIFY_IMPORT_PLAYLIST_NAMES=Discover Weekly,Release Radar
|
||||
SPOTIFY_IMPORT_PLAYLIST_NAMES=
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -83,4 +83,7 @@ apis/*.json
|
||||
!apis/jellyfin-openapi-stable.json
|
||||
|
||||
# Original source code for reference
|
||||
originals/
|
||||
originals/
|
||||
|
||||
# Sample missing playlists for Spotify integration testing
|
||||
sampleMissingPlaylists/
|
||||
39
README.md
39
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
|
||||
- **Album Enrichment**: Adds missing tracks to local albums from streaming providers
|
||||
- **Cover Art Proxy**: Serves cover art for external content
|
||||
- **Spotify Playlist Injection** (Jellyfin only): Intercepts Spotify Import plugin playlists (Release Radar, Discover Weekly) and fills them with tracks auto-matched from streaming providers
|
||||
|
||||
## Supported Backends
|
||||
|
||||
@@ -287,6 +288,44 @@ 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.
|
||||
|
||||
### Spotify Playlist Injection (Jellyfin Only)
|
||||
|
||||
Allstarr can intercept Spotify Import plugin playlists (Release Radar, Discover Weekly) and fill them with tracks automatically matched from your configured streaming provider (SquidWTF, Deezer, or Qobuz).
|
||||
|
||||
**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 URL and API key configured (uses existing JELLYFIN_URL and JELLYFIN_API_KEY settings)
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| `SpotifyImport:Enabled` | Enable Spotify playlist injection (default: `false`) |
|
||||
| `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 playlists + missing tracks files
|
||||
2. Allstarr fetches these missing tracks files within the configured time window
|
||||
3. For each missing track, Allstarr searches your streaming provider (SquidWTF, Deezer, or Qobuz)
|
||||
4. When you open the playlist in Jellyfin, Allstarr intercepts the request and returns matched tracks
|
||||
5. Tracks are downloaded on-demand when played
|
||||
6. On startup, Allstarr will fetch missing tracks if it hasn't run in the last 24 hours
|
||||
|
||||
**Environment variables:**
|
||||
```bash
|
||||
SPOTIFY_IMPORT_ENABLED=true
|
||||
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 uses your existing JELLYFIN_URL and JELLYFIN_API_KEY settings. The plugin must be configured to run on a schedule, and the sync window should cover the plugin's execution time.
|
||||
|
||||
### Getting Credentials
|
||||
|
||||
#### Deezer ARL Token
|
||||
|
||||
@@ -10,6 +10,7 @@ using allstarr.Services.Local;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Subsonic;
|
||||
using allstarr.Services.Lyrics;
|
||||
using allstarr.Filters;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
@@ -22,6 +23,7 @@ namespace allstarr.Controllers;
|
||||
public class JellyfinController : ControllerBase
|
||||
{
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly IMusicMetadataService _metadataService;
|
||||
private readonly ILocalLibraryService _localLibraryService;
|
||||
private readonly IDownloadService _downloadService;
|
||||
@@ -29,20 +31,24 @@ public class JellyfinController : ControllerBase
|
||||
private readonly JellyfinModelMapper _modelMapper;
|
||||
private readonly JellyfinProxyService _proxyService;
|
||||
private readonly PlaylistSyncService? _playlistSyncService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<JellyfinController> _logger;
|
||||
|
||||
public JellyfinController(
|
||||
IOptions<JellyfinSettings> settings,
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
IMusicMetadataService metadataService,
|
||||
ILocalLibraryService localLibraryService,
|
||||
IDownloadService downloadService,
|
||||
JellyfinResponseBuilder responseBuilder,
|
||||
JellyfinModelMapper modelMapper,
|
||||
JellyfinProxyService proxyService,
|
||||
RedisCacheService cache,
|
||||
ILogger<JellyfinController> logger,
|
||||
PlaylistSyncService? playlistSyncService = null)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_metadataService = metadataService;
|
||||
_localLibraryService = localLibraryService;
|
||||
_downloadService = downloadService;
|
||||
@@ -50,6 +56,7 @@ public class JellyfinController : ControllerBase
|
||||
_modelMapper = modelMapper;
|
||||
_proxyService = proxyService;
|
||||
_playlistSyncService = playlistSyncService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_settings.Url))
|
||||
@@ -1251,10 +1258,55 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
|
||||
_logger.LogInformation("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId);
|
||||
|
||||
// Check if this is an external playlist (Deezer/Qobuz) first
|
||||
if (PlaylistIdHelper.IsExternalPlaylist(playlistId))
|
||||
{
|
||||
var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(playlistId);
|
||||
var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId);
|
||||
return _responseBuilder.CreateItemsResponse(tracks);
|
||||
}
|
||||
|
||||
return _responseBuilder.CreateItemsResponse(tracks);
|
||||
// Check if this is a Spotify playlist (by ID)
|
||||
_logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured IDs: {Count}",
|
||||
_spotifySettings.Enabled, _spotifySettings.PlaylistIds.Count);
|
||||
|
||||
if (_spotifySettings.Enabled &&
|
||||
_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
// Get playlist info from Jellyfin to get the name for matching missing tracks
|
||||
_logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId);
|
||||
var playlistInfo = await _proxyService.GetJsonAsync($"Items/{playlistId}", null, Request.Headers);
|
||||
|
||||
if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement))
|
||||
{
|
||||
var playlistName = nameElement.GetString() ?? "";
|
||||
_logger.LogInformation("✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})",
|
||||
playlistName, playlistId);
|
||||
return await GetSpotifyPlaylistTracksAsync(playlistName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Could not get playlist name from Jellyfin for ID: {PlaylistId}", playlistId);
|
||||
}
|
||||
}
|
||||
|
||||
// Regular Jellyfin playlist - proxy through
|
||||
var endpoint = $"Playlists/{playlistId}/Items";
|
||||
if (Request.QueryString.HasValue)
|
||||
{
|
||||
endpoint = $"{endpoint}{Request.QueryString.Value}";
|
||||
}
|
||||
|
||||
_logger.LogInformation("Proxying to Jellyfin: {Endpoint}", endpoint);
|
||||
var result = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||
if (result == null)
|
||||
{
|
||||
return _responseBuilder.CreateError(404, "Playlist not found");
|
||||
}
|
||||
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1603,6 +1655,38 @@ public class JellyfinController : ControllerBase
|
||||
[HttpPost("{**path}", Order = 100)]
|
||||
public async Task<IActionResult> ProxyRequest(string path)
|
||||
{
|
||||
// DEBUG: Log EVERY request to see what's happening
|
||||
_logger.LogWarning("ProxyRequest called with path: {Path}", path);
|
||||
|
||||
// Intercept Spotify playlist requests by ID
|
||||
if (_spotifySettings.Enabled &&
|
||||
path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) &&
|
||||
path.Contains("/items", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Extract playlist ID from path: playlists/{id}/items
|
||||
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2 && parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var playlistId = parts[1];
|
||||
|
||||
_logger.LogWarning("=== PLAYLIST REQUEST ===");
|
||||
_logger.LogWarning("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogWarning("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
||||
_logger.LogWarning("Configured IDs: {Ids}", string.Join(", ", _spotifySettings.PlaylistIds));
|
||||
_logger.LogWarning("Is configured: {IsConfigured}", _spotifySettings.PlaylistIds.Contains(playlistId, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
// Check if this playlist ID is configured for Spotify injection
|
||||
if (_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogInformation("========================================");
|
||||
return await GetPlaylistTracks(playlistId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-JSON responses (robots.txt, etc.)
|
||||
if (path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -1827,5 +1911,231 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Spotify Playlist Injection
|
||||
|
||||
/// <summary>
|
||||
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
|
||||
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
|
||||
|
||||
if (cachedTracks != null)
|
||||
{
|
||||
_logger.LogDebug("Returning {Count} cached matched tracks for {Playlist}",
|
||||
cachedTracks.Count, spotifyPlaylistName);
|
||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||
}
|
||||
|
||||
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
|
||||
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}", spotifyPlaylistName);
|
||||
return _responseBuilder.CreateItemsResponse(new List<Song>());
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {Count} tracks for {Playlist}",
|
||||
missingTracks.Count, spotifyPlaylistName);
|
||||
|
||||
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, spotifyPlaylistName);
|
||||
|
||||
return _responseBuilder.CreateItemsResponse(matchedTracks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting Spotify playlist tracks {PlaylistName}", spotifyPlaylistName);
|
||||
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
||||
/// GET /spotify/sync?api_key=YOUR_KEY
|
||||
/// </summary>
|
||||
[HttpGet("spotify/sync")]
|
||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
||||
public async Task<IActionResult> TriggerSpotifySync()
|
||||
{
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Manual Spotify sync triggered");
|
||||
|
||||
var results = new Dictionary<string, object>();
|
||||
|
||||
for (int i = 0; i < _spotifySettings.PlaylistIds.Count; i++)
|
||||
{
|
||||
var playlistId = _spotifySettings.PlaylistIds[i];
|
||||
|
||||
try
|
||||
{
|
||||
// Use configured name if available, otherwise use ID
|
||||
var playlistName = i < _spotifySettings.PlaylistNames.Count
|
||||
? _spotifySettings.PlaylistNames[i]
|
||||
: playlistId;
|
||||
|
||||
_logger.LogInformation("Fetching missing tracks for {Playlist} (ID: {Id})", playlistName, playlistId);
|
||||
|
||||
// Try to fetch the missing tracks file - search last 24 hours
|
||||
var now = DateTime.UtcNow;
|
||||
var searchStart = now.AddHours(-24);
|
||||
|
||||
var httpClient = new HttpClient();
|
||||
var found = false;
|
||||
|
||||
// Search every minute for the last 24 hours (1440 attempts max)
|
||||
for (var time = searchStart; time <= now; time = time.AddMinutes(1))
|
||||
{
|
||||
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
|
||||
var url = $"{_settings.Url}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
|
||||
$"?name={Uri.EscapeDataString(filename)}&api_key={_settings.ApiKey}";
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying {Filename}", filename);
|
||||
var response = await httpClient.GetAsync(url);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var tracks = ParseMissingTracksJson(json);
|
||||
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
|
||||
|
||||
results[playlistName] = new {
|
||||
status = "success",
|
||||
tracks = tracks.Count,
|
||||
filename = filename
|
||||
};
|
||||
|
||||
_logger.LogInformation("✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
|
||||
tracks.Count, playlistName, filename);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
results[playlistName] = new { status = "not_found", message = "No missing tracks file found" };
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error syncing playlist {PlaylistId}", playlistId);
|
||||
results[playlistId] = new { status = "error", message = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
private List<allstarr.Models.Spotify.MissingTrack> ParseMissingTracksJson(string json)
|
||||
{
|
||||
var tracks = new List<allstarr.Models.Spotify.MissingTrack>();
|
||||
|
||||
try
|
||||
{
|
||||
var doc = JsonDocument.Parse(json);
|
||||
|
||||
foreach (var item in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var track = new allstarr.Models.Spotify.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;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Spotify Debug
|
||||
|
||||
/// <summary>
|
||||
/// Clear Spotify playlist cache to force re-matching.
|
||||
/// GET /spotify/clear-cache?api_key=YOUR_KEY
|
||||
/// </summary>
|
||||
[HttpGet("spotify/clear-cache")]
|
||||
[ServiceFilter(typeof(ApiKeyAuthFilter))]
|
||||
public async Task<IActionResult> ClearSpotifyCache()
|
||||
{
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
return BadRequest(new { error = "Spotify Import is not enabled" });
|
||||
}
|
||||
|
||||
var cleared = new List<string>();
|
||||
|
||||
foreach (var playlistName in _spotifySettings.PlaylistNames)
|
||||
{
|
||||
var matchedKey = $"spotify:matched:{playlistName}";
|
||||
await _cache.DeleteAsync(matchedKey);
|
||||
cleared.Add(playlistName);
|
||||
_logger.LogInformation("Cleared cache for {Playlist}", playlistName);
|
||||
}
|
||||
|
||||
return Ok(new { status = "success", cleared = cleared });
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
||||
|
||||
52
allstarr/Filters/ApiKeyAuthFilter.cs
Normal file
52
allstarr/Filters/ApiKeyAuthFilter.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Simple API key authentication filter for admin endpoints.
|
||||
/// Validates against Jellyfin API key via query parameter or header.
|
||||
/// </summary>
|
||||
public class ApiKeyAuthFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly ILogger<ApiKeyAuthFilter> _logger;
|
||||
|
||||
public ApiKeyAuthFilter(
|
||||
IOptions<JellyfinSettings> settings,
|
||||
ILogger<ApiKeyAuthFilter> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var request = context.HttpContext.Request;
|
||||
|
||||
// Extract API key from query parameter or header
|
||||
var apiKey = request.Query["api_key"].FirstOrDefault()
|
||||
?? request.Headers["X-Api-Key"].FirstOrDefault()
|
||||
?? request.Headers["X-Emby-Token"].FirstOrDefault();
|
||||
|
||||
// Validate API key
|
||||
if (string.IsNullOrEmpty(apiKey) || !string.Equals(apiKey, _settings.ApiKey, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("Unauthorized access attempt to {Path} from {IP}",
|
||||
request.Path,
|
||||
context.HttpContext.Connection.RemoteIpAddress);
|
||||
|
||||
context.Result = new UnauthorizedObjectResult(new
|
||||
{
|
||||
error = "Unauthorized",
|
||||
message = "Valid API key required. Provide via ?api_key=YOUR_KEY or X-Api-Key header."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("API key authentication successful for {Path}", request.Path);
|
||||
await next();
|
||||
}
|
||||
}
|
||||
47
allstarr/Models/Settings/SpotifyImportSettings.cs
Normal file
47
allstarr/Models/Settings/SpotifyImportSettings.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
namespace allstarr.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for Spotify playlist injection feature.
|
||||
/// Requires Jellyfin Spotify Import Plugin: https://github.com/Viperinius/jellyfin-plugin-spotify-import
|
||||
/// Uses JellyfinSettings.Url and JellyfinSettings.ApiKey for API access.
|
||||
/// </summary>
|
||||
public class SpotifyImportSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable Spotify playlist injection feature
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <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>
|
||||
/// Comma-separated list of Jellyfin playlist IDs to inject
|
||||
/// Example: "4383a46d8bcac3be2ef9385053ea18df,ba50e26c867ec9d57ab2f7bf24cfd6b0"
|
||||
/// Get IDs from Jellyfin playlist URLs
|
||||
/// </summary>
|
||||
public List<string> PlaylistIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of playlist names (must match Spotify Import plugin format)
|
||||
/// Example: "Discover_Weekly,Release_Radar"
|
||||
/// Must be in same order as PlaylistIds
|
||||
/// Plugin replaces spaces with underscores in filenames
|
||||
/// </summary>
|
||||
public List<string> PlaylistNames { get; set; } = new();
|
||||
}
|
||||
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,42 @@ builder.Services.Configure<SquidWTFSettings>(
|
||||
builder.Configuration.GetSection("SquidWTF"));
|
||||
builder.Services.Configure<RedisSettings>(
|
||||
builder.Configuration.GetSection("Redis"));
|
||||
// Configure Spotify Import settings with custom playlist parsing from env var
|
||||
builder.Services.Configure<SpotifyImportSettings>(options =>
|
||||
{
|
||||
builder.Configuration.GetSection("SpotifyImport").Bind(options);
|
||||
|
||||
// Parse SPOTIFY_IMPORT_PLAYLIST_IDS env var (comma-separated) into PlaylistIds array
|
||||
var playlistIdsEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistIds");
|
||||
if (!string.IsNullOrWhiteSpace(playlistIdsEnv) && options.PlaylistIds.Count == 0)
|
||||
{
|
||||
options.PlaylistIds = playlistIdsEnv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(id => id.Trim())
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Parse SPOTIFY_IMPORT_PLAYLIST_NAMES env var (comma-separated) into PlaylistNames array
|
||||
var playlistNamesEnv = builder.Configuration.GetValue<string>("SpotifyImport:PlaylistNames");
|
||||
if (!string.IsNullOrWhiteSpace(playlistNamesEnv) && options.PlaylistNames.Count == 0)
|
||||
{
|
||||
options.PlaylistNames = playlistNamesEnv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(name => name.Trim())
|
||||
.Where(name => !string.IsNullOrEmpty(name))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Log configuration at startup
|
||||
Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, SyncHour={options.SyncStartHour}:{options.SyncStartMinute:D2}, WindowHours={options.SyncWindowHours}");
|
||||
Console.WriteLine($"Spotify Import Playlist IDs: {options.PlaylistIds.Count} configured");
|
||||
for (int i = 0; i < options.PlaylistIds.Count; i++)
|
||||
{
|
||||
var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i];
|
||||
Console.WriteLine($" - {name} (ID: {options.PlaylistIds[i]})");
|
||||
}
|
||||
});
|
||||
|
||||
// Get shared settings from the active backend config
|
||||
MusicService musicService;
|
||||
@@ -138,6 +174,7 @@ if (backendType == BackendType.Jellyfin)
|
||||
builder.Services.AddSingleton<JellyfinModelMapper>();
|
||||
builder.Services.AddScoped<JellyfinProxyService>();
|
||||
builder.Services.AddScoped<JellyfinAuthFilter>();
|
||||
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -229,6 +266,9 @@ builder.Services.AddHostedService<StartupValidationOrchestrator>();
|
||||
// Register cache cleanup service (only runs when StorageMode is Cache)
|
||||
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 =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
|
||||
255
allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs
Normal file
255
allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs
Normal file
@@ -0,0 +1,255 @@
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
public class SpotifyMissingTracksFetcher : BackgroundService
|
||||
{
|
||||
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
|
||||
private readonly IOptions<JellyfinSettings> _jellyfinSettings;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<SpotifyMissingTracksFetcher> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private bool _hasRunOnce = false;
|
||||
private Dictionary<string, string> _playlistIdToName = new();
|
||||
|
||||
public SpotifyMissingTracksFetcher(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
IOptions<JellyfinSettings> jellyfinSettings,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SpotifyMissingTracksFetcher> logger)
|
||||
{
|
||||
_spotifySettings = spotifySettings;
|
||||
_jellyfinSettings = jellyfinSettings;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("SpotifyMissingTracksFetcher: Starting up...");
|
||||
|
||||
if (!_spotifySettings.Value.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify playlist injection is DISABLED");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
var jellyfinUrl = _jellyfinSettings.Value.Url;
|
||||
var apiKey = _jellyfinSettings.Value.ApiKey;
|
||||
|
||||
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
_logger.LogWarning("Jellyfin URL or API key not configured, Spotify playlist injection disabled");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Spotify Import ENABLED");
|
||||
_logger.LogInformation("Configured Playlist IDs: {Count}", _spotifySettings.Value.PlaylistIds.Count);
|
||||
|
||||
// Fetch playlist names from Jellyfin
|
||||
await LoadPlaylistNamesAsync();
|
||||
|
||||
foreach (var kvp in _playlistIdToName)
|
||||
{
|
||||
_logger.LogInformation(" - {Name} (ID: {Id})", kvp.Value, kvp.Key);
|
||||
}
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Run once on startup if we haven't run in the last 24 hours
|
||||
if (!_hasRunOnce)
|
||||
{
|
||||
var shouldRunOnStartup = await ShouldRunOnStartupAsync();
|
||||
if (shouldRunOnStartup)
|
||||
{
|
||||
_logger.LogInformation("Running initial fetch on startup");
|
||||
try
|
||||
{
|
||||
await FetchMissingTracksAsync(stoppingToken);
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during startup fetch");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Skipping startup fetch - already ran within last 24 hours");
|
||||
_hasRunOnce = true;
|
||||
}
|
||||
}
|
||||
|
||||
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 LoadPlaylistNamesAsync()
|
||||
{
|
||||
_playlistIdToName.Clear();
|
||||
|
||||
// Use configured playlist names instead of fetching from API
|
||||
for (int i = 0; i < _spotifySettings.Value.PlaylistIds.Count; i++)
|
||||
{
|
||||
var playlistId = _spotifySettings.Value.PlaylistIds[i];
|
||||
var playlistName = i < _spotifySettings.Value.PlaylistNames.Count
|
||||
? _spotifySettings.Value.PlaylistNames[i]
|
||||
: playlistId; // Fallback to ID if name not configured
|
||||
|
||||
_playlistIdToName[playlistId] = playlistName;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ShouldRunOnStartupAsync()
|
||||
{
|
||||
// Check if any playlist has cached data from the last 24 hours
|
||||
foreach (var playlistName in _playlistIdToName.Values)
|
||||
{
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
if (await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
return false; // Already have recent data
|
||||
}
|
||||
}
|
||||
return true; // No recent data, should fetch
|
||||
}
|
||||
|
||||
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = _spotifySettings.Value;
|
||||
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;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Within sync window, fetching missing tracks...");
|
||||
|
||||
foreach (var kvp in _playlistIdToName)
|
||||
{
|
||||
await FetchPlaylistMissingTracksAsync(kvp.Value, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FetchPlaylistMissingTracksAsync(
|
||||
string playlistName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = $"spotify:missing:{playlistName}";
|
||||
|
||||
if (await _cache.ExistsAsync(cacheKey))
|
||||
{
|
||||
_logger.LogDebug("Cache already exists for {Playlist}", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = _spotifySettings.Value;
|
||||
var jellyfinUrl = _jellyfinSettings.Value.Url;
|
||||
var apiKey = _jellyfinSettings.Value.ApiKey;
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var syncStart = today
|
||||
.AddHours(settings.SyncStartHour)
|
||||
.AddMinutes(settings.SyncStartMinute);
|
||||
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
|
||||
|
||||
_logger.LogInformation("Searching for missing tracks file for {Playlist}", playlistName);
|
||||
|
||||
for (var time = syncStart; time <= syncEnd; time = time.AddMinutes(5))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
|
||||
var url = $"{jellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
|
||||
$"?name={Uri.EscapeDataString(filename)}&api_key={apiKey}";
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Trying {Filename}", filename);
|
||||
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, playlistName, 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,23 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"SpotifyImport": {
|
||||
"Enabled": false,
|
||||
"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"
|
||||
},
|
||||
"Subsonic": {
|
||||
"Url": "https://navidrome.local.bransonb.com",
|
||||
"Url": "http://localhost:4533",
|
||||
"MusicService": "SquidWTF",
|
||||
"ExplicitFilter": "All",
|
||||
"DownloadMode": "Track",
|
||||
@@ -42,5 +42,23 @@
|
||||
"Redis": {
|
||||
"Enabled": true,
|
||||
"ConnectionString": "localhost:6379"
|
||||
},
|
||||
"SpotifyImport": {
|
||||
"Enabled": false,
|
||||
"SyncStartHour": 16,
|
||||
"SyncStartMinute": 15,
|
||||
"SyncWindowHours": 2,
|
||||
"Playlists": [
|
||||
{
|
||||
"Name": "Release Radar",
|
||||
"SpotifyName": "Release Radar",
|
||||
"Enabled": true
|
||||
},
|
||||
{
|
||||
"Name": "Discover Weekly",
|
||||
"SpotifyName": "Discover Weekly",
|
||||
"Enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,14 @@ services:
|
||||
- Jellyfin__CacheDurationHours=${CACHE_DURATION_HOURS:-1}
|
||||
- Jellyfin__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true}
|
||||
|
||||
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
|
||||
- SpotifyImport__Enabled=${SPOTIFY_IMPORT_ENABLED:-false}
|
||||
- SpotifyImport__SyncStartHour=${SPOTIFY_IMPORT_SYNC_START_HOUR:-16}
|
||||
- SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15}
|
||||
- SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2}
|
||||
- SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-}
|
||||
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
|
||||
|
||||
# ===== SHARED =====
|
||||
- Library__DownloadPath=/app/downloads
|
||||
- SquidWTF__Quality=${SQUIDWTF_QUALITY:-FLAC}
|
||||
|
||||
Reference in New Issue
Block a user